mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Loki: Implement hints for query builder (#51795)
* Loki: Implement hints for query builder
* Update name of file
* Update imports
* Refactor
* Remove unused import
* Unify
* Revert "Unify"
This reverts commit 78da0e27e3.
* Unify
* Fix types
* Fix tests
* Fix type error
* Simplify
* Update test
* Add documentation
* Update comment
* Add tests for addParserToQuery
* Smaller updates
This commit is contained in:
22557
.betterer.results
22557
.betterer.results
File diff suppressed because it is too large
Load Diff
@@ -123,6 +123,10 @@ Operation can have additional parameters under the operation header. See the ope
|
||||
|
||||
Some operations make sense only in specific order, if adding an operation would result in nonsensical query, operation will be added to the correct place. To order operations manually drag operation box by the operation name and drop in appropriate place.
|
||||
|
||||
##### Hints
|
||||
|
||||
In same cases the query editor can detect which operations would be most appropriate for a selected log stream. In such cases it will show a hint next to the `+ Operations` button. Click on the hint to add the operations to your query.
|
||||
|
||||
#### Raw query
|
||||
|
||||
This section is shown only if the `Raw query` switch from the query editor top toolbar is set to `on`. It shows the raw query that will be created and executed by the query editor.
|
||||
|
||||
@@ -4,8 +4,8 @@ import { QueryBuilderLabelFilter } from '../prometheus/querybuilder/shared/types
|
||||
|
||||
import { LokiQueryModeller } from './querybuilder/LokiQueryModeller';
|
||||
import { buildVisualQueryFromString } from './querybuilder/parsing';
|
||||
import { LokiVisualQuery } from './querybuilder/types';
|
||||
|
||||
type Position = { from: number; to: number };
|
||||
/**
|
||||
* Adds label filter to existing query. Useful for query modification for example for ad hoc filters.
|
||||
*
|
||||
@@ -39,22 +39,36 @@ export function addLabelToQuery(query: string, key: string, operator: string, va
|
||||
}
|
||||
}
|
||||
|
||||
type StreamSelectorPosition = { from: number; to: number; query: LokiVisualQuery };
|
||||
type PipelineStagePosition = { from: number; to: number };
|
||||
/**
|
||||
* Adds parser to existing query. Useful for query modification for hints.
|
||||
* It uses LogQL parser to find instances of stream selectors or line filters and adds parser after them.
|
||||
*
|
||||
* @param query
|
||||
* @param parser
|
||||
*/
|
||||
export function addParserToQuery(query: string, parser: string): string {
|
||||
const lineFilterPositions = getLineFiltersPositions(query);
|
||||
|
||||
if (lineFilterPositions.length) {
|
||||
return addParser(query, lineFilterPositions, parser);
|
||||
} else {
|
||||
const streamSelectorPositions = getStreamSelectorPositions(query);
|
||||
return addParser(query, streamSelectorPositions, parser);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the string and get all Selector positions in the query together with parsed representation of the
|
||||
* selector.
|
||||
* @param query
|
||||
*/
|
||||
function getStreamSelectorPositions(query: string): StreamSelectorPosition[] {
|
||||
function getStreamSelectorPositions(query: string): Position[] {
|
||||
const tree = parser.parse(query);
|
||||
const positions: StreamSelectorPosition[] = [];
|
||||
const positions: Position[] = [];
|
||||
tree.iterate({
|
||||
enter: (type, from, to, get): false | void => {
|
||||
if (type.name === 'Selector') {
|
||||
const visQuery = buildVisualQueryFromString(query.substring(from, to));
|
||||
positions.push({ query: visQuery.query, from, to });
|
||||
positions.push({ from, to });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
@@ -66,9 +80,9 @@ function getStreamSelectorPositions(query: string): StreamSelectorPosition[] {
|
||||
* Parse the string and get all LabelParser positions in the query.
|
||||
* @param query
|
||||
*/
|
||||
function getParserPositions(query: string): PipelineStagePosition[] {
|
||||
export function getParserPositions(query: string): Position[] {
|
||||
const tree = parser.parse(query);
|
||||
const positions: PipelineStagePosition[] = [];
|
||||
const positions: Position[] = [];
|
||||
tree.iterate({
|
||||
enter: (type, from, to, get): false | void => {
|
||||
if (type.name === 'LabelParser') {
|
||||
@@ -80,6 +94,24 @@ function getParserPositions(query: string): PipelineStagePosition[] {
|
||||
return positions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the string and get all Line filter positions in the query.
|
||||
* @param query
|
||||
*/
|
||||
function getLineFiltersPositions(query: string): Position[] {
|
||||
const tree = parser.parse(query);
|
||||
const positions: Position[] = [];
|
||||
tree.iterate({
|
||||
enter: (type, from, to, get): false | void => {
|
||||
if (type.name === 'LineFilters') {
|
||||
positions.push({ from, to });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
return positions;
|
||||
}
|
||||
|
||||
function toLabelFilter(key: string, value: string, operator: string): QueryBuilderLabelFilter {
|
||||
// We need to make sure that we convert the value back to string because it may be a number
|
||||
return { label: key, op: operator, value };
|
||||
@@ -93,7 +125,7 @@ function toLabelFilter(key: string, value: string, operator: string): QueryBuild
|
||||
*/
|
||||
function addFilterToStreamSelector(
|
||||
query: string,
|
||||
vectorSelectorPositions: StreamSelectorPosition[],
|
||||
vectorSelectorPositions: Position[],
|
||||
filter: QueryBuilderLabelFilter
|
||||
): string {
|
||||
const modeller = new LokiQueryModeller();
|
||||
@@ -108,12 +140,13 @@ function addFilterToStreamSelector(
|
||||
|
||||
const start = query.substring(prev, match.from);
|
||||
const end = isLast ? query.substring(match.to) : '';
|
||||
const matchVisQuery = buildVisualQueryFromString(query.substring(match.from, match.to));
|
||||
|
||||
if (!labelExists(match.query.labels, filter)) {
|
||||
if (!labelExists(matchVisQuery.query.labels, filter)) {
|
||||
// We don't want to add duplicate labels.
|
||||
match.query.labels.push(filter);
|
||||
matchVisQuery.query.labels.push(filter);
|
||||
}
|
||||
const newLabels = modeller.renderQuery(match.query);
|
||||
const newLabels = modeller.renderQuery(matchVisQuery.query);
|
||||
newQuery += start + newLabels + end;
|
||||
prev = match.to;
|
||||
}
|
||||
@@ -126,11 +159,7 @@ function addFilterToStreamSelector(
|
||||
* @param parserPositions
|
||||
* @param filter
|
||||
*/
|
||||
function addFilterAsLabelFilter(
|
||||
query: string,
|
||||
parserPositions: PipelineStagePosition[],
|
||||
filter: QueryBuilderLabelFilter
|
||||
): string {
|
||||
function addFilterAsLabelFilter(query: string, parserPositions: Position[], filter: QueryBuilderLabelFilter): string {
|
||||
let newQuery = '';
|
||||
let prev = 0;
|
||||
|
||||
@@ -149,6 +178,31 @@ function addFilterAsLabelFilter(
|
||||
return newQuery;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add parser after line filter or stream selector
|
||||
* @param query
|
||||
* @param queryPartPositions
|
||||
* @param parser
|
||||
*/
|
||||
function addParser(query: string, queryPartPositions: Position[], parser: string): string {
|
||||
let newQuery = '';
|
||||
let prev = 0;
|
||||
|
||||
for (let i = 0; i < queryPartPositions.length; i++) {
|
||||
// Splice on a string for each matched vector selector
|
||||
const match = queryPartPositions[i];
|
||||
const isLast = i === queryPartPositions.length - 1;
|
||||
|
||||
const start = query.substring(prev, match.to);
|
||||
const end = isLast ? query.substring(match.to) : '';
|
||||
|
||||
// Add parser
|
||||
newQuery += start + ` | ${parser}` + end;
|
||||
prev = match.to;
|
||||
}
|
||||
return newQuery;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if label exists in the list of labels but ignore the operator.
|
||||
* @param labels
|
||||
@@ -1,4 +1,4 @@
|
||||
import { addLabelToQuery } from './add_label_to_query';
|
||||
import { addLabelToQuery, addParserToQuery } from './addToQuery';
|
||||
|
||||
describe('addLabelToQuery()', () => {
|
||||
it('should add label to simple query', () => {
|
||||
@@ -153,3 +153,27 @@ describe('addLabelToQuery()', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('addParserToQuery', () => {
|
||||
describe('when query had line filter', () => {
|
||||
it('should add parser after line filter', () => {
|
||||
expect(addParserToQuery('{job="grafana"} |= "error"', 'logfmt')).toBe('{job="grafana"} |= "error" | logfmt');
|
||||
});
|
||||
|
||||
it('should add parser after multiple line filters', () => {
|
||||
expect(addParserToQuery('{job="grafana"} |= "error" |= "info" |= "debug"', 'logfmt')).toBe(
|
||||
'{job="grafana"} |= "error" |= "info" |= "debug" | logfmt'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when query has no line filters', () => {
|
||||
it('should add parser after log stream selector in logs query', () => {
|
||||
expect(addParserToQuery('{job="grafana"}', 'logfmt')).toBe('{job="grafana"} | logfmt');
|
||||
});
|
||||
|
||||
it('should add parser after log stream selector in metric query', () => {
|
||||
expect(addParserToQuery('rate({job="grafana"} [5m])', 'logfmt')).toBe('rate({job="grafana"} | logfmt [5m])');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,7 @@ import { TemplateSrv } from 'app/features/templating/template_srv';
|
||||
import { initialCustomVariableModelState } from '../../../features/variables/custom/reducer';
|
||||
import { CustomVariableModel } from '../../../features/variables/types';
|
||||
|
||||
import { isMetricsQuery, LokiDatasource } from './datasource';
|
||||
import { LokiDatasource } from './datasource';
|
||||
import { makeMockLokiDatasource } from './mocks';
|
||||
import { LokiQuery, LokiQueryType } from './types';
|
||||
|
||||
@@ -802,23 +802,6 @@ describe('LokiDatasource', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMetricsQuery', () => {
|
||||
it('should return true for metrics query', () => {
|
||||
const query = 'rate({label=value}[1m])';
|
||||
expect(isMetricsQuery(query)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return false for logs query', () => {
|
||||
const query = '{label=value}';
|
||||
expect(isMetricsQuery(query)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should not blow up on empty query', () => {
|
||||
const query = '';
|
||||
expect(isMetricsQuery(query)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyTemplateVariables', () => {
|
||||
it('should add the adhoc filter to the query', () => {
|
||||
const ds = createLokiDSForTests();
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Libraries
|
||||
import { cloneDeep, map as lodashMap } from 'lodash';
|
||||
import Prism from 'prismjs';
|
||||
import { lastValueFrom, merge, Observable, of, throwError } from 'rxjs';
|
||||
import { catchError, map, switchMap } from 'rxjs/operators';
|
||||
|
||||
@@ -33,6 +32,8 @@ import {
|
||||
TimeRange,
|
||||
rangeUtil,
|
||||
toUtc,
|
||||
QueryHint,
|
||||
getDefaultTimeRange,
|
||||
} from '@grafana/data';
|
||||
import { FetchError, config, DataSourceWithBackend } from '@grafana/runtime';
|
||||
import { RowContextOptions } from '@grafana/ui/src/components/Logs/LogRowContextProvider';
|
||||
@@ -44,16 +45,16 @@ import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_sr
|
||||
import { serializeParams } from '../../../core/utils/fetch';
|
||||
import { renderLegendFormat } from '../prometheus/legend';
|
||||
|
||||
import { addLabelToQuery } from './add_label_to_query';
|
||||
import { addLabelToQuery, addParserToQuery } from './addToQuery';
|
||||
import { transformBackendResult } from './backendResultTransformer';
|
||||
import { LokiAnnotationsQueryEditor } from './components/AnnotationsQueryEditor';
|
||||
import LanguageProvider from './language_provider';
|
||||
import { escapeLabelValueInSelector } from './language_utils';
|
||||
import { LiveStreams, LokiLiveTarget } from './live_streams';
|
||||
import { getNormalizedLokiQuery } from './query_utils';
|
||||
import { getQueryHints } from './queryHints';
|
||||
import { getNormalizedLokiQuery, isLogsQuery, isValidQuery } from './query_utils';
|
||||
import { sortDataFrameByTime } from './sortDataFrame';
|
||||
import { doLokiChannelStream } from './streaming';
|
||||
import syntax from './syntax';
|
||||
import { LokiOptions, LokiQuery, LokiQueryDirection, LokiQueryType } from './types';
|
||||
|
||||
export type RangeQueryOptions = DataQueryRequest<LokiQuery> | AnnotationQueryRequest<LokiQuery>;
|
||||
@@ -108,7 +109,7 @@ export class LokiDatasource
|
||||
const normalized = getNormalizedLokiQuery(query);
|
||||
const { expr } = normalized;
|
||||
// it has to be a logs-producing range-query
|
||||
return expr && !isMetricsQuery(expr) && normalized.queryType === LokiQueryType.Range;
|
||||
return expr && isLogsQuery(expr) && normalized.queryType === LokiQueryType.Range;
|
||||
};
|
||||
|
||||
const isLogsVolumeAvailable = request.targets.some(isQuerySuitable);
|
||||
@@ -171,7 +172,7 @@ export class LokiDatasource
|
||||
runLiveQueryThroughBackend(request: DataQueryRequest<LokiQuery>): Observable<DataQueryResponse> {
|
||||
// this only works in explore-mode, so variables don't need to be handled,
|
||||
// and only for logs-queries, not metric queries
|
||||
const logsQueries = request.targets.filter((query) => query.expr !== '' && !isMetricsQuery(query.expr));
|
||||
const logsQueries = request.targets.filter((query) => query.expr !== '' && isLogsQuery(query.expr));
|
||||
|
||||
if (logsQueries.length === 0) {
|
||||
return of({
|
||||
@@ -348,7 +349,26 @@ export class LokiDatasource
|
||||
return Array.from(streams);
|
||||
}
|
||||
|
||||
// By implementing getTagKeys and getTagValues we add ad-hoc filtters functionality
|
||||
async getDataSamples(query: LokiQuery): Promise<DataFrame[]> {
|
||||
// Currently works only for log samples
|
||||
if (!isValidQuery(query.expr) || !isLogsQuery(query.expr)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lokiLogsQuery: LokiQuery = {
|
||||
expr: query.expr,
|
||||
queryType: LokiQueryType.Range,
|
||||
refId: 'log-samples',
|
||||
maxLines: 10,
|
||||
};
|
||||
|
||||
// For samples, we use defaultTimeRange (now-6h/now) and limit od 10 lines so queries are small and fast
|
||||
const timeRange = getDefaultTimeRange();
|
||||
const request = makeRequest(lokiLogsQuery, timeRange, CoreApp.Explore, 'log-samples');
|
||||
return await lastValueFrom(this.query(request).pipe(switchMap((res) => of(res.data))));
|
||||
}
|
||||
|
||||
// By implementing getTagKeys and getTagValues we add ad-hoc filters functionality
|
||||
async getTagKeys() {
|
||||
return await this.labelNamesQuery();
|
||||
}
|
||||
@@ -382,6 +402,14 @@ export class LokiDatasource
|
||||
expression = this.addLabelToQuery(expression, action.key, '!=', action.value);
|
||||
break;
|
||||
}
|
||||
case 'ADD_LOGFMT_PARSER': {
|
||||
expression = addParserToQuery(expression, 'logfmt');
|
||||
break;
|
||||
}
|
||||
case 'ADD_JSON_PARSER': {
|
||||
expression = addParserToQuery(expression, 'json');
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -681,6 +709,10 @@ export class LokiDatasource
|
||||
getVariables(): string[] {
|
||||
return this.templateSrv.getVariables().map((v) => `$${v.name}`);
|
||||
}
|
||||
|
||||
getQueryHints(query: LokiQuery, result: DataFrame[]): QueryHint[] {
|
||||
return getQueryHints(query.expr, result);
|
||||
}
|
||||
}
|
||||
|
||||
export function lokiRegularEscape(value: any) {
|
||||
@@ -697,21 +729,6 @@ export function lokiSpecialRegexEscape(value: any) {
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the query expression uses function and so should return a time series instead of logs.
|
||||
* Sometimes important to know that before we actually do the query.
|
||||
*/
|
||||
export function isMetricsQuery(query: string): boolean {
|
||||
if (!query) {
|
||||
return false;
|
||||
}
|
||||
const tokens = Prism.tokenize(query, syntax);
|
||||
return tokens.some((t) => {
|
||||
// Not sure in which cases it can be string maybe if nothing matched which means it should not be a function
|
||||
return typeof t !== 'string' && t.type === 'function';
|
||||
});
|
||||
}
|
||||
|
||||
function extractLevel(dataFrame: DataFrame): LogLevel {
|
||||
let valueField;
|
||||
try {
|
||||
|
||||
73
public/app/plugins/datasource/loki/queryHints.test.ts
Normal file
73
public/app/plugins/datasource/loki/queryHints.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { ArrayVector, DataFrame, FieldType } from '@grafana/data';
|
||||
|
||||
import { getQueryHints } from './queryHints';
|
||||
|
||||
describe('getQueryHints', () => {
|
||||
describe('when series with json logs', () => {
|
||||
const jsonSeries: DataFrame = {
|
||||
name: 'logs',
|
||||
length: 2,
|
||||
fields: [
|
||||
{
|
||||
name: 'Line',
|
||||
type: FieldType.string,
|
||||
config: {},
|
||||
values: new ArrayVector(['{"foo": "bar", "bar": "baz"}', '{"foo": "bar", "bar": "baz"}']),
|
||||
},
|
||||
],
|
||||
};
|
||||
it('suggest json parser when no parser in query', () => {
|
||||
expect(getQueryHints('{job="grafana"', [jsonSeries])).toMatchObject([{ type: 'ADD_JSON_PARSER' }]);
|
||||
});
|
||||
it('does not suggest parser when parser in query', () => {
|
||||
expect(getQueryHints('{job="grafana" | json', [jsonSeries])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when series with logfmt logs', () => {
|
||||
const logfmtSeries: DataFrame = {
|
||||
name: 'logs',
|
||||
length: 2,
|
||||
fields: [
|
||||
{
|
||||
name: 'Line',
|
||||
type: FieldType.string,
|
||||
config: {},
|
||||
values: new ArrayVector(['foo="bar" bar="baz"', 'foo="bar" bar="baz"']),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('suggest logfmt parser when no parser in query', () => {
|
||||
expect(getQueryHints('{job="grafana"', [logfmtSeries])).toMatchObject([{ type: 'ADD_LOGFMT_PARSER' }]);
|
||||
});
|
||||
it('does not suggest parser when parser in query', () => {
|
||||
expect(getQueryHints('{job="grafana" | json', [logfmtSeries])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when series with json and logfmt logs', () => {
|
||||
const jsonAndLogfmtSeries: DataFrame = {
|
||||
name: 'logs',
|
||||
length: 2,
|
||||
fields: [
|
||||
{
|
||||
name: 'Line',
|
||||
type: FieldType.string,
|
||||
config: {},
|
||||
values: new ArrayVector(['{"foo": "bar", "bar": "baz"}', 'foo="bar" bar="baz"']),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('suggest logfmt parser when no parser in query', () => {
|
||||
expect(getQueryHints('{job="grafana"', [jsonAndLogfmtSeries])).toMatchObject([
|
||||
{ type: 'ADD_JSON_PARSER' },
|
||||
{ type: 'ADD_LOGFMT_PARSER' },
|
||||
]);
|
||||
});
|
||||
it('does not suggest parser when parser in query', () => {
|
||||
expect(getQueryHints('{job="grafana" | json', [jsonAndLogfmtSeries])).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
42
public/app/plugins/datasource/loki/queryHints.ts
Normal file
42
public/app/plugins/datasource/loki/queryHints.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { DataFrame, QueryHint } from '@grafana/data';
|
||||
|
||||
import { isQueryWithParser } from './query_utils';
|
||||
import { extractLogParserFromDataFrame } from './responseUtils';
|
||||
|
||||
export function getQueryHints(query: string, series: DataFrame[]): QueryHint[] {
|
||||
const hints: QueryHint[] = [];
|
||||
if (series.length > 0) {
|
||||
const { hasLogfmt, hasJSON } = extractLogParserFromDataFrame(series[0]);
|
||||
const queryWithParser = isQueryWithParser(query);
|
||||
|
||||
if (hasJSON && !queryWithParser) {
|
||||
hints.push({
|
||||
type: 'ADD_JSON_PARSER',
|
||||
label: 'Selected log stream selector has JSON formatted logs.',
|
||||
fix: {
|
||||
label: 'Consider using JSON parser.',
|
||||
action: {
|
||||
type: 'ADD_JSON_PARSER',
|
||||
query,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (hasLogfmt && !queryWithParser) {
|
||||
hints.push({
|
||||
type: 'ADD_LOGFMT_PARSER',
|
||||
label: 'Selected log stream selector has logfmt formatted logs.',
|
||||
fix: {
|
||||
label: 'Consider using logfmt parser.',
|
||||
action: {
|
||||
type: 'ADD_LOGFMT_PARSER',
|
||||
query,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return hints;
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
import { getHighlighterExpressionsFromQuery, getNormalizedLokiQuery } from './query_utils';
|
||||
import {
|
||||
getHighlighterExpressionsFromQuery,
|
||||
getNormalizedLokiQuery,
|
||||
isLogsQuery,
|
||||
isQueryWithParser,
|
||||
isValidQuery,
|
||||
} from './query_utils';
|
||||
import { LokiQuery, LokiQueryType } from './types';
|
||||
|
||||
describe('getHighlighterExpressionsFromQuery', () => {
|
||||
@@ -26,7 +32,7 @@ describe('getHighlighterExpressionsFromQuery', () => {
|
||||
expect(getHighlighterExpressionsFromQuery('{foo="bar"} |= "x" | logfmt')).toEqual(['x']);
|
||||
});
|
||||
|
||||
it('returns expressions for query with filter chain folowed by log parser', () => {
|
||||
it('returns expressions for query with filter chain followed by log parser', () => {
|
||||
expect(getHighlighterExpressionsFromQuery('{foo="bar"} |= "x" |~ "y" | logfmt')).toEqual(['x', 'y']);
|
||||
});
|
||||
|
||||
@@ -110,3 +116,34 @@ describe('getNormalizedLokiQuery', () => {
|
||||
expectNormalized({ instant: true, range: false, queryType: 'invalid' }, LokiQueryType.Instant);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidQuery', () => {
|
||||
it('returns false if invalid query', () => {
|
||||
expect(isValidQuery('{job="grafana')).toBe(false);
|
||||
});
|
||||
it('returns true if valid query', () => {
|
||||
expect(isValidQuery('{job="grafana"}')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLogsQuery', () => {
|
||||
it('returns false if metrics query', () => {
|
||||
expect(isLogsQuery('rate({job="grafana"}[5m])')).toBe(false);
|
||||
});
|
||||
it('returns true if valid query', () => {
|
||||
expect(isLogsQuery('{job="grafana"}')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isQueryWithParser', () => {
|
||||
it('returns false if query without parser', () => {
|
||||
expect(isQueryWithParser('rate({job="grafana" |= "error" }[5m])')).toBe(false);
|
||||
});
|
||||
it('returns true if log query with parser', () => {
|
||||
expect(isQueryWithParser('{job="grafana"} | json')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true if metric query with parser', () => {
|
||||
expect(isQueryWithParser('rate({job="grafana"} | json [5m])')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { escapeRegExp } from 'lodash';
|
||||
|
||||
import { parser } from '@grafana/lezer-logql';
|
||||
|
||||
import { ErrorName } from '../prometheus/querybuilder/shared/parsingUtils';
|
||||
|
||||
import { LokiQuery, LokiQueryType } from './types';
|
||||
|
||||
export function formatQuery(selector: string | undefined): string {
|
||||
@@ -90,3 +94,42 @@ export function getNormalizedLokiQuery(query: LokiQuery): LokiQuery {
|
||||
const { instant, range, ...rest } = query;
|
||||
return { ...rest, queryType: LokiQueryType.Range };
|
||||
}
|
||||
|
||||
export function isValidQuery(query: string): boolean {
|
||||
let isValid = true;
|
||||
const tree = parser.parse(query);
|
||||
tree.iterate({
|
||||
enter: (type): false | void => {
|
||||
if (type.name === ErrorName) {
|
||||
isValid = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
return isValid;
|
||||
}
|
||||
|
||||
export function isLogsQuery(query: string): boolean {
|
||||
let isLogsQuery = true;
|
||||
const tree = parser.parse(query);
|
||||
tree.iterate({
|
||||
enter: (type): false | void => {
|
||||
if (type.name === 'MetricExpr') {
|
||||
isLogsQuery = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
return isLogsQuery;
|
||||
}
|
||||
|
||||
export function isQueryWithParser(query: string): boolean {
|
||||
let hasParser = false;
|
||||
const tree = parser.parse(query);
|
||||
tree.iterate({
|
||||
enter: (type): false | void => {
|
||||
if (type.name === 'LabelParser') {
|
||||
hasParser = true;
|
||||
}
|
||||
},
|
||||
});
|
||||
return hasParser;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@ import { render, screen, getAllByRole, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import { PanelData } from '@grafana/data';
|
||||
|
||||
import { LokiDatasource } from '../../datasource';
|
||||
import { LokiOperationId, LokiVisualQuery } from '../types';
|
||||
|
||||
@@ -14,29 +12,7 @@ const defaultQuery: LokiVisualQuery = {
|
||||
operations: [],
|
||||
};
|
||||
|
||||
describe('LokiQueryBuilder', () => {
|
||||
it('tries to load labels when no labels are selected', async () => {
|
||||
const { datasource } = setup();
|
||||
datasource.languageProvider.fetchSeriesLabels = jest.fn().mockReturnValue({ job: ['a'], instance: ['b'] });
|
||||
await userEvent.click(screen.getByLabelText('Add'));
|
||||
const labels = screen.getByText(/Labels/);
|
||||
const selects = getAllByRole(labels.parentElement!.parentElement!.parentElement!, 'combobox');
|
||||
await userEvent.click(selects[3]);
|
||||
await waitFor(() => expect(screen.getByText('job')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows error for query with operations and no stream selector', async () => {
|
||||
setup({ labels: [], operations: [{ id: LokiOperationId.Logfmt, params: [] }] });
|
||||
expect(screen.getByText('You need to specify at least 1 label filter (stream selector)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no error for query with empty __line_contains operation and no stream selector', async () => {
|
||||
setup({ labels: [], operations: [{ id: LokiOperationId.LineContains, params: [''] }] });
|
||||
expect(screen.queryByText('You need to specify at least 1 label filter (stream selector)')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
function setup(query: LokiVisualQuery = defaultQuery, data?: PanelData) {
|
||||
const createDefaultProps = () => {
|
||||
const datasource = new LokiDatasource(
|
||||
{
|
||||
url: '',
|
||||
@@ -46,13 +22,47 @@ function setup(query: LokiVisualQuery = defaultQuery, data?: PanelData) {
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
|
||||
const props = {
|
||||
datasource,
|
||||
onRunQuery: () => {},
|
||||
onChange: () => {},
|
||||
data,
|
||||
};
|
||||
|
||||
const { container } = render(<LokiQueryBuilder {...props} query={query} />);
|
||||
return { datasource, container };
|
||||
}
|
||||
return props;
|
||||
};
|
||||
|
||||
describe('LokiQueryBuilder', () => {
|
||||
it('tries to load labels when no labels are selected', async () => {
|
||||
const props = createDefaultProps();
|
||||
props.datasource.getDataSamples = jest.fn().mockResolvedValue([]);
|
||||
props.datasource.languageProvider.fetchSeriesLabels = jest.fn().mockReturnValue({ job: ['a'], instance: ['b'] });
|
||||
|
||||
render(<LokiQueryBuilder {...props} query={defaultQuery} />);
|
||||
await userEvent.click(screen.getByLabelText('Add'));
|
||||
const labels = screen.getByText(/Labels/);
|
||||
const selects = getAllByRole(labels.parentElement!.parentElement!.parentElement!, 'combobox');
|
||||
await userEvent.click(selects[3]);
|
||||
await waitFor(() => expect(screen.getByText('job')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows error for query with operations and no stream selector', async () => {
|
||||
const query = { labels: [], operations: [{ id: LokiOperationId.Logfmt, params: [] }] };
|
||||
render(<LokiQueryBuilder {...createDefaultProps()} query={query} />);
|
||||
|
||||
expect(
|
||||
await screen.findByText('You need to specify at least 1 label filter (stream selector)')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no error for query with empty __line_contains operation and no stream selector', async () => {
|
||||
const query = { labels: [], operations: [{ id: LokiOperationId.LineContains, params: [''] }] };
|
||||
render(<LokiQueryBuilder {...createDefaultProps()} query={query} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText('You need to specify at least 1 label filter (stream selector)')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { DataSourceApi, SelectableValue } from '@grafana/data';
|
||||
import { DataSourceApi, getDefaultTimeRange, LoadingState, PanelData, SelectableValue } from '@grafana/data';
|
||||
import { EditorRow } from '@grafana/experimental';
|
||||
import { LabelFilters } from 'app/plugins/datasource/prometheus/querybuilder/shared/LabelFilters';
|
||||
import { OperationList } from 'app/plugins/datasource/prometheus/querybuilder/shared/OperationList';
|
||||
import { OperationsEditorRow } from 'app/plugins/datasource/prometheus/querybuilder/shared/OperationsEditorRow';
|
||||
import { QueryBuilderHints } from 'app/plugins/datasource/prometheus/querybuilder/shared/QueryBuilderHints';
|
||||
import { QueryBuilderLabelFilter } from 'app/plugins/datasource/prometheus/querybuilder/shared/types';
|
||||
|
||||
import { LokiDatasource } from '../../datasource';
|
||||
import { escapeLabelValueInSelector } from '../../language_utils';
|
||||
import { lokiQueryModeller } from '../LokiQueryModeller';
|
||||
import { buildVisualQueryFromString } from '../parsing';
|
||||
import { LokiOperationId, LokiVisualQuery } from '../types';
|
||||
|
||||
import { NestedQueryList } from './NestedQueryList';
|
||||
@@ -22,6 +24,8 @@ export interface Props {
|
||||
}
|
||||
|
||||
export const LokiQueryBuilder = React.memo<Props>(({ datasource, query, onChange, onRunQuery }) => {
|
||||
const [sampleData, setSampleData] = useState<PanelData>();
|
||||
|
||||
const onChangeLabels = (labels: QueryBuilderLabelFilter[]) => {
|
||||
onChange({ ...query, labels });
|
||||
};
|
||||
@@ -74,6 +78,17 @@ export const LokiQueryBuilder = React.memo<Props>(({ datasource, query, onChange
|
||||
return undefined;
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
const onGetSampleData = async () => {
|
||||
const lokiQuery = { expr: lokiQueryModeller.renderQuery(query), refId: 'data-samples' };
|
||||
const series = await datasource.getDataSamples(lokiQuery);
|
||||
const sampleData = { series, state: LoadingState.Done, timeRange: getDefaultTimeRange() };
|
||||
setSampleData(sampleData);
|
||||
};
|
||||
|
||||
onGetSampleData().catch(console.error);
|
||||
}, [datasource, query]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditorRow>
|
||||
@@ -97,6 +112,14 @@ export const LokiQueryBuilder = React.memo<Props>(({ datasource, query, onChange
|
||||
onRunQuery={onRunQuery}
|
||||
datasource={datasource as DataSourceApi}
|
||||
/>
|
||||
<QueryBuilderHints<LokiVisualQuery>
|
||||
datasource={datasource}
|
||||
query={query}
|
||||
onChange={onChange}
|
||||
data={sampleData}
|
||||
queryModeller={lokiQueryModeller}
|
||||
buildVisualQueryFromString={buildVisualQueryFromString}
|
||||
/>
|
||||
</OperationsEditorRow>
|
||||
{query.binaryQueries && query.binaryQueries.length > 0 && (
|
||||
<NestedQueryList query={query} datasource={datasource} onChange={onChange} onRunQuery={onRunQuery} />
|
||||
|
||||
@@ -32,9 +32,13 @@ describe('LokiQueryBuilderContainer', () => {
|
||||
onRunQuery: () => {},
|
||||
showRawQuery: true,
|
||||
};
|
||||
props.datasource.getDataSamples = jest.fn().mockResolvedValue([]);
|
||||
|
||||
render(<LokiQueryBuilderContainer {...props} />);
|
||||
expect(screen.getByText('testjob')).toBeInTheDocument();
|
||||
const selector = await screen.findByLabelText('selector');
|
||||
expect(selector.textContent).toBe('{job="testjob"}');
|
||||
await addOperation('Range functions', 'Rate');
|
||||
expect(await screen.findByText('Rate')).toBeInTheDocument();
|
||||
expect(props.onChange).toBeCalledWith({
|
||||
expr: 'rate({job="testjob"} [$__interval])',
|
||||
refId: 'A',
|
||||
|
||||
@@ -7,7 +7,7 @@ import { RadioButtonGroup, Select, AutoSizeInput } from '@grafana/ui';
|
||||
import { QueryOptionGroup } from 'app/plugins/datasource/prometheus/querybuilder/shared/QueryOptionGroup';
|
||||
|
||||
import { preprocessMaxLines, queryTypeOptions, RESOLUTION_OPTIONS } from '../../components/LokiOptionFields';
|
||||
import { isMetricsQuery } from '../../datasource';
|
||||
import { isLogsQuery } from '../../query_utils';
|
||||
import { LokiQuery, LokiQueryType } from '../../types';
|
||||
|
||||
export interface Props {
|
||||
@@ -46,7 +46,7 @@ export const LokiQueryBuilderOptions = React.memo<Props>(({ app, query, onChange
|
||||
}
|
||||
|
||||
let queryType = query.queryType ?? (query.instant ? LokiQueryType.Instant : LokiQueryType.Range);
|
||||
let showMaxLines = !isMetricsQuery(query.expr);
|
||||
let showMaxLines = isLogsQuery(query.expr);
|
||||
|
||||
return (
|
||||
<EditorRow>
|
||||
|
||||
@@ -50,6 +50,7 @@ const datasource = new LokiDatasource(
|
||||
);
|
||||
|
||||
datasource.languageProvider.fetchLabels = jest.fn().mockResolvedValue([]);
|
||||
datasource.getDataSamples = jest.fn().mockResolvedValue([]);
|
||||
|
||||
const defaultProps = {
|
||||
datasource,
|
||||
@@ -75,7 +76,7 @@ describe('LokiQueryEditorSelector', () => {
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expectBuilder();
|
||||
await expectBuilder();
|
||||
});
|
||||
|
||||
it('shows code editor when code mode is set', async () => {
|
||||
@@ -85,7 +86,7 @@ describe('LokiQueryEditorSelector', () => {
|
||||
|
||||
it('shows builder when builder mode is set', async () => {
|
||||
renderWithMode(QueryEditorMode.Builder);
|
||||
expectBuilder();
|
||||
await expectBuilder();
|
||||
});
|
||||
|
||||
it('shows explain when explain mode is set', async () => {
|
||||
@@ -106,7 +107,7 @@ describe('LokiQueryEditorSelector', () => {
|
||||
|
||||
it('Can enable raw query', async () => {
|
||||
renderWithMode(QueryEditorMode.Builder);
|
||||
expect(screen.queryByLabelText('selector')).toBeInTheDocument();
|
||||
expect(await screen.findByLabelText('selector')).toBeInTheDocument();
|
||||
screen.getByLabelText('Raw query').click();
|
||||
expect(screen.queryByLabelText('selector')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -116,7 +117,9 @@ describe('LokiQueryEditorSelector', () => {
|
||||
editorMode: QueryEditorMode.Builder,
|
||||
expr: '{job="grafana"}',
|
||||
});
|
||||
expect(screen.getByLabelText('selector').textContent).toBe('{job="grafana"}');
|
||||
const selector = await screen.findByLabelText('selector');
|
||||
expect(selector).toBeInTheDocument();
|
||||
expect(selector.textContent).toBe('{job="grafana"}');
|
||||
});
|
||||
|
||||
it('changes to code mode', async () => {
|
||||
@@ -182,8 +185,8 @@ function expectCodeEditor() {
|
||||
expect(screen.getByText('Loading labels...')).toBeInTheDocument();
|
||||
}
|
||||
|
||||
function expectBuilder() {
|
||||
expect(screen.getByText('Labels')).toBeInTheDocument();
|
||||
async function expectBuilder() {
|
||||
expect(await screen.findByText('Labels')).toBeInTheDocument();
|
||||
}
|
||||
|
||||
function expectExplain() {
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
import { DataFrame, Labels } from '@grafana/data';
|
||||
import { DataFrame, FieldType, getParser, Labels, LogsParsers } from '@grafana/data';
|
||||
|
||||
export function dataFrameHasLokiError(frame: DataFrame): boolean {
|
||||
const labelSets: Labels[] = frame.fields.find((f) => f.name === 'labels')?.values.toArray() ?? [];
|
||||
return labelSets.some((labels) => labels.__error__ !== undefined);
|
||||
}
|
||||
export function extractLogParserFromDataFrame(frame: DataFrame): { hasLogfmt: boolean; hasJSON: boolean } {
|
||||
const lineField = frame.fields.find((field) => field.type === FieldType.string);
|
||||
if (lineField == null) {
|
||||
return { hasJSON: false, hasLogfmt: false };
|
||||
}
|
||||
|
||||
const logLines: string[] = lineField.values.toArray();
|
||||
|
||||
let hasJSON = false;
|
||||
let hasLogfmt = false;
|
||||
|
||||
logLines.forEach((line) => {
|
||||
const parser = getParser(line);
|
||||
if (parser === LogsParsers.JSON) {
|
||||
hasJSON = true;
|
||||
}
|
||||
if (parser === LogsParsers.logfmt) {
|
||||
hasLogfmt = true;
|
||||
}
|
||||
});
|
||||
|
||||
return { hasLogfmt, hasJSON };
|
||||
}
|
||||
|
||||
@@ -6,15 +6,16 @@ import { EditorRow } from '@grafana/experimental';
|
||||
import { PrometheusDatasource } from '../../datasource';
|
||||
import { getMetadataString } from '../../language_provider';
|
||||
import { promQueryModeller } from '../PromQueryModeller';
|
||||
import { buildVisualQueryFromString } from '../parsing';
|
||||
import { LabelFilters } from '../shared/LabelFilters';
|
||||
import { OperationList } from '../shared/OperationList';
|
||||
import { OperationsEditorRow } from '../shared/OperationsEditorRow';
|
||||
import { QueryBuilderHints } from '../shared/QueryBuilderHints';
|
||||
import { QueryBuilderLabelFilter } from '../shared/types';
|
||||
import { PromVisualQuery } from '../types';
|
||||
|
||||
import { MetricSelect } from './MetricSelect';
|
||||
import { NestedQueryList } from './NestedQueryList';
|
||||
import { PromQueryBuilderHints } from './PromQueryBuilderHints';
|
||||
|
||||
export interface Props {
|
||||
query: PromVisualQuery;
|
||||
@@ -108,7 +109,14 @@ export const PromQueryBuilder = React.memo<Props>(({ datasource, query, onChange
|
||||
onChange={onChange}
|
||||
onRunQuery={onRunQuery}
|
||||
/>
|
||||
<PromQueryBuilderHints datasource={datasource} query={query} onChange={onChange} data={data} />
|
||||
<QueryBuilderHints<PromVisualQuery>
|
||||
datasource={datasource}
|
||||
query={query}
|
||||
onChange={onChange}
|
||||
data={data}
|
||||
queryModeller={promQueryModeller}
|
||||
buildVisualQueryFromString={buildVisualQueryFromString}
|
||||
/>
|
||||
</OperationsEditorRow>
|
||||
{query.binaryQueries && query.binaryQueries.length > 0 && (
|
||||
<NestedQueryList query={query} datasource={datasource} onChange={onChange} onRunQuery={onRunQuery} />
|
||||
|
||||
@@ -3,29 +3,38 @@ import React, { useState, useEffect } from 'react';
|
||||
|
||||
import { GrafanaTheme2, PanelData, QueryHint } from '@grafana/data';
|
||||
import { Button, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
import { LokiDatasource } from 'app/plugins/datasource/loki/datasource';
|
||||
|
||||
import { PrometheusDatasource } from '../../datasource';
|
||||
import { promQueryModeller } from '../PromQueryModeller';
|
||||
import { buildVisualQueryFromString } from '../parsing';
|
||||
import { PromVisualQuery } from '../types';
|
||||
|
||||
export interface Props {
|
||||
query: PromVisualQuery;
|
||||
datasource: PrometheusDatasource;
|
||||
onChange: (update: PromVisualQuery) => void;
|
||||
import { LokiAndPromQueryModellerBase, PromLokiVisualQuery } from './LokiAndPromQueryModellerBase';
|
||||
|
||||
export interface Props<T extends PromLokiVisualQuery> {
|
||||
query: T;
|
||||
datasource: PrometheusDatasource | LokiDatasource;
|
||||
queryModeller: LokiAndPromQueryModellerBase;
|
||||
buildVisualQueryFromString: (expr: string) => { query: T };
|
||||
onChange: (update: T) => void;
|
||||
data?: PanelData;
|
||||
}
|
||||
|
||||
export const PromQueryBuilderHints = React.memo<Props>(({ datasource, query, onChange, data }) => {
|
||||
export const QueryBuilderHints = <T extends PromLokiVisualQuery>({
|
||||
datasource,
|
||||
query: visualQuery,
|
||||
onChange,
|
||||
data,
|
||||
queryModeller,
|
||||
buildVisualQueryFromString,
|
||||
}: Props<T>) => {
|
||||
const [hints, setHints] = useState<QueryHint[]>([]);
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
useEffect(() => {
|
||||
const promQuery = { expr: promQueryModeller.renderQuery(query), refId: '' };
|
||||
const query = { expr: queryModeller.renderQuery(visualQuery), refId: '' };
|
||||
// For now show only actionable hints
|
||||
const hints = datasource.getQueryHints(promQuery, data?.series || []).filter((hint) => hint.fix?.action);
|
||||
const hints = datasource.getQueryHints(query, data?.series || []).filter((hint) => hint.fix?.action);
|
||||
setHints(hints);
|
||||
}, [datasource, query, onChange, data, styles.hint]);
|
||||
}, [datasource, visualQuery, data, queryModeller]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -36,10 +45,10 @@ export const PromQueryBuilderHints = React.memo<Props>(({ datasource, query, onC
|
||||
<Tooltip content={`${hint.label} ${hint.fix?.label}`} key={hint.type}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const promQuery = { expr: promQueryModeller.renderQuery(query), refId: '' };
|
||||
const newPromQuery = datasource.modifyQuery(promQuery, hint!.fix!.action);
|
||||
const visualQuery = buildVisualQueryFromString(newPromQuery.expr);
|
||||
return onChange(visualQuery.query);
|
||||
const query = { expr: queryModeller.renderQuery(visualQuery), refId: '' };
|
||||
const newQuery = datasource.modifyQuery(query, hint!.fix!.action);
|
||||
const newVisualQuery = buildVisualQueryFromString(newQuery.expr);
|
||||
return onChange(newVisualQuery.query);
|
||||
}}
|
||||
fill="outline"
|
||||
size="sm"
|
||||
@@ -54,16 +63,15 @@ export const PromQueryBuilderHints = React.memo<Props>(({ datasource, query, onC
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
PromQueryBuilderHints.displayName = 'PromQueryBuilderHints';
|
||||
QueryBuilderHints.displayName = 'QueryBuilderHints';
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
container: css`
|
||||
display: flex;
|
||||
margin-bottom: ${theme.spacing(1)};
|
||||
align-items: center;
|
||||
align-items: start;
|
||||
`,
|
||||
hint: css`
|
||||
margin-right: ${theme.spacing(1)};
|
||||
Reference in New Issue
Block a user