Commit 927e1cbd by David Committed by GitHub

(feat/explore): Support for new LogQL filtering syntax (#16674)

* (feat/explore): Support for new LogQL filtering syntax

Loki is adding syntax to support chained filtering.
This PR adapts Grafana to support this.

- Send only `query` parameter in loki request
- Automatically wrap search text in simple syntax, e.g., `{} foo` is
sent as `{} |~ "foo"`.


* Adapted to regexp parameter staying on in Loki

* Dont wrap single regexp in new filter syntax

* Fix datasource test

* Fallback regexp parameter for legacy queries

* Fix search highlighting

* Make highlighting work for filter chains

* Fix datasource test
parent bf5b60f7
......@@ -21,7 +21,7 @@ export interface QueryResultMeta {
requestId?: string;
// Used in Explore for highlighting
search?: string;
searchWords?: string[];
// Used in Explore to show limit applied to search result
limit?: number;
......
......@@ -194,7 +194,7 @@ export abstract class ExploreDataSourceApi<
TOptions extends DataSourceJsonData = DataSourceJsonData
> extends DataSourceApi<TQuery, TOptions> {
modifyQuery?(query: TQuery, action: QueryFixAction): TQuery;
getHighlighterExpression?(query: TQuery): string;
getHighlighterExpression?(query: TQuery): string[];
languageProvider?: any;
}
......
......@@ -446,7 +446,7 @@ export function processLogSeriesRow(
const timeLocal = time.format('YYYY-MM-DD HH:mm:ss');
const logLevel = getLogLevel(message);
const hasAnsi = hasAnsiCodes(message);
const search = series.meta && series.meta.search ? series.meta.search : '';
const searchWords = series.meta && series.meta.searchWords ? series.meta.searchWords : [];
return {
logLevel,
......@@ -455,10 +455,10 @@ export function processLogSeriesRow(
timeLocal,
uniqueLabels,
hasAnsi,
searchWords,
entry: hasAnsi ? ansicolor.strip(message) : message,
raw: message,
labels: series.labels,
searchWords: search ? [search] : [],
timestamp: ts,
};
}
......@@ -6,7 +6,7 @@ import xss from 'xss';
* See https://github.com/bvaughn/react-highlight-words#props
*/
export function findHighlightChunksInText({ searchWords, textToHighlight }) {
return findMatchesInText(textToHighlight, searchWords.join(' '));
return searchWords.reduce((acc, term) => [...acc, ...findMatchesInText(textToHighlight, term)], []);
}
const cleanNeedle = (needle: string): string => {
......
......@@ -133,7 +133,7 @@ export class LogRow extends PureComponent<Props, State> {
const { entry, hasAnsi, raw } = row;
const previewHighlights = highlighterExpressions && !_.isEqual(highlighterExpressions, row.searchWords);
const highlights = previewHighlights ? highlighterExpressions : row.searchWords;
const needsHighlighter = highlights && highlights.length > 0 && highlights[0].length > 0;
const needsHighlighter = highlights && highlights.length > 0 && highlights[0] && highlights[0].length > 0;
const highlightClassName = classnames('logs-row__match-highlight', {
'logs-row__match-highlight--preview': previewHighlights,
});
......
......@@ -98,7 +98,7 @@ export class QueryRow extends PureComponent<QueryRowProps> {
const { datasourceInstance } = this.props;
if (datasourceInstance.getHighlighterExpression) {
const { exploreId } = this.props;
const expressions = [datasourceInstance.getHighlighterExpression(value)];
const expressions = datasourceInstance.getHighlighterExpression(value);
this.props.highlightLogsExpressionAction({ exploreId, expressions });
}
}, 500);
......
......@@ -61,7 +61,7 @@ describe('LokiDatasource', () => {
backendSrvMock.datasourceRequest = jest.fn(() => Promise.resolve(testResp));
const options = getQueryOptions<LokiQuery>({
targets: [{ expr: 'foo', refId: 'B' }],
targets: [{ expr: '{} foo', refId: 'B' }],
});
const res = await ds.query(options);
......@@ -69,7 +69,7 @@ describe('LokiDatasource', () => {
const seriesData = res.data[0] as SeriesData;
expect(seriesData.rows[0][1]).toBe('hello');
expect(seriesData.meta.limit).toBe(20);
expect(seriesData.meta.search).toBe('(?i)foo');
expect(seriesData.meta.searchWords).toEqual(['(?i)foo']);
done();
});
});
......
......@@ -6,7 +6,7 @@ import * as dateMath from '@grafana/ui/src/utils/datemath';
import { addLabelToSelector } from 'app/plugins/datasource/prometheus/add_label_to_query';
import LanguageProvider from './language_provider';
import { logStreamToSeriesData } from './result_transformer';
import { formatQuery, parseQuery } from './query_utils';
import { formatQuery, parseQuery, getHighlighterExpressionsFromQuery } from './query_utils';
// Types
import {
......@@ -69,12 +69,14 @@ export class LokiDatasource extends DataSourceApi<LokiQuery, LokiOptions> {
prepareQueryTarget(target: LokiQuery, options: DataQueryRequest<LokiQuery>) {
const interpolated = this.templateSrv.replace(target.expr);
const { query, regexp } = parseQuery(interpolated);
const start = this.getTime(options.range.from, false);
const end = this.getTime(options.range.to, true);
const refId = target.refId;
return {
...DEFAULT_QUERY_PARAMS,
...parseQuery(interpolated),
query,
regexp,
start,
end,
limit: this.maxLines,
......@@ -126,14 +128,15 @@ export class LokiDatasource extends DataSourceApi<LokiQuery, LokiOptions> {
for (let i = 0; i < results.length; i++) {
const result = results[i];
if (result.data) {
const refId = queryTargets[i].refId;
for (const stream of result.data.streams || []) {
const seriesData = logStreamToSeriesData(stream);
seriesData.refId = refId;
seriesData.meta = {
search: queryTargets[i].regexp,
searchWords: getHighlighterExpressionsFromQuery(
formatQuery(queryTargets[i].query, queryTargets[i].regexp)
),
limit: this.maxLines,
};
series.push(seriesData);
......@@ -160,7 +163,7 @@ export class LokiDatasource extends DataSourceApi<LokiQuery, LokiOptions> {
modifyQuery(query: LokiQuery, action: any): LokiQuery {
const parsed = parseQuery(query.expr || '');
let selector = parsed.query;
let { query: selector } = parsed;
switch (action.type) {
case 'ADD_FILTER': {
selector = addLabelToSelector(selector, action.key, action.value);
......@@ -173,8 +176,8 @@ export class LokiDatasource extends DataSourceApi<LokiQuery, LokiOptions> {
return { ...query, expr: expression };
}
getHighlighterExpression(query: LokiQuery): string {
return parseQuery(query.expr).regexp;
getHighlighterExpression(query: LokiQuery): string[] {
return getHighlighterExpressionsFromQuery(query.expr);
}
getTime(date, roundUp) {
......
import { parseQuery } from './query_utils';
import { parseQuery, getHighlighterExpressionsFromQuery } from './query_utils';
import { LokiExpression } from './types';
describe('parseQuery', () => {
it('returns empty for empty string', () => {
expect(parseQuery('')).toEqual({
query: '',
regexp: '',
});
} as LokiExpression);
});
it('returns regexp for strings without query', () => {
expect(parseQuery('test')).toEqual({
query: '',
regexp: '(?i)test',
});
query: 'test',
regexp: '',
} as LokiExpression);
});
it('returns query for strings without regexp', () => {
expect(parseQuery('{foo="bar"}')).toEqual({
query: '{foo="bar"}',
regexp: '',
});
} as LokiExpression);
});
it('returns query for strings with query and search string', () => {
expect(parseQuery('x {foo="bar"}')).toEqual({
query: '{foo="bar"}',
regexp: '(?i)x',
});
} as LokiExpression);
});
it('returns query for strings with query and regexp', () => {
expect(parseQuery('{foo="bar"} x|y')).toEqual({
query: '{foo="bar"}',
regexp: '(?i)x|y',
});
} as LokiExpression);
});
it('returns query for selector with two labels', () => {
expect(parseQuery('{foo="bar", baz="42"}')).toEqual({
query: '{foo="bar", baz="42"}',
regexp: '',
});
} as LokiExpression);
});
it('returns query and regexp with quantifiers', () => {
expect(parseQuery('{foo="bar"} \\.java:[0-9]{1,5}')).toEqual({
query: '{foo="bar"}',
regexp: '(?i)\\.java:[0-9]{1,5}',
});
} as LokiExpression);
expect(parseQuery('\\.java:[0-9]{1,5} {foo="bar"}')).toEqual({
query: '{foo="bar"}',
regexp: '(?i)\\.java:[0-9]{1,5}',
} as LokiExpression);
});
it('returns query with filter operands as is', () => {
expect(parseQuery('{foo="bar"} |= "x|y"')).toEqual({
query: '{foo="bar"} |= "x|y"',
regexp: '',
} as LokiExpression);
expect(parseQuery('{foo="bar"} |~ "42"')).toEqual({
query: '{foo="bar"} |~ "42"',
regexp: '',
} as LokiExpression);
});
});
describe('getHighlighterExpressionsFromQuery', () => {
it('returns no expressions for empty query', () => {
expect(getHighlighterExpressionsFromQuery('')).toEqual([]);
});
it('returns a single expressions for legacy query', () => {
expect(getHighlighterExpressionsFromQuery('{} x')).toEqual(['(?i)x']);
expect(getHighlighterExpressionsFromQuery('{foo="bar"} x')).toEqual(['(?i)x']);
});
it('returns an expression for query with filter', () => {
expect(getHighlighterExpressionsFromQuery('{foo="bar"} |= "x"')).toEqual(['x']);
});
it('returns expressions for query with filter chain', () => {
expect(getHighlighterExpressionsFromQuery('{foo="bar"} |= "x" |~ "y"')).toEqual(['x', 'y']);
});
it('returns drops expressions for query with negative filter chain', () => {
expect(getHighlighterExpressionsFromQuery('{foo="bar"} |= "x" != "y"')).toEqual(['x']);
});
});
import { LokiExpression } from './types';
const selectorRegexp = /(?:^|\s){[^{]*}/g;
const caseInsensitive = '(?i)'; // Golang mode modifier for Loki, doesn't work in JavaScript
export function parseQuery(input: string) {
export function parseQuery(input: string): LokiExpression {
input = input || '';
const match = input.match(selectorRegexp);
let query = '';
let regexp = input;
let query = input;
let regexp = '';
if (match) {
query = match[0].trim();
regexp = input.replace(selectorRegexp, '').trim();
// Keep old-style regexp, otherwise take whole query
if (regexp && regexp.search(/\|=|\|~|!=|!~/) === -1) {
query = match[0].trim();
if (!regexp.startsWith(caseInsensitive)) {
regexp = `${caseInsensitive}${regexp}`;
}
if (regexp) {
regexp = caseInsensitive + regexp;
} else {
regexp = '';
}
return { query, regexp };
}
return { regexp, query };
}
export function formatQuery(selector: string, search: string): string {
return `${selector || ''} ${search || ''}`.trim();
}
/**
* Returns search terms from a LogQL query.
* E.g., `{} |= foo |=bar != baz` returns `['foo', 'bar']`.
*/
export function getHighlighterExpressionsFromQuery(input: string): string[] {
const parsed = parseQuery(input);
// Legacy syntax
if (parsed.regexp) {
return [parsed.regexp];
}
let expression = input;
const results = [];
// Consume filter expression from left to right
while (expression) {
const filterStart = expression.search(/\|=|\|~|!=|!~/);
// Nothing more to search
if (filterStart === -1) {
break;
}
// Drop terms for negative filters
const skip = expression.substr(filterStart).search(/!=|!~/) === 0;
expression = expression.substr(filterStart + 2);
if (skip) {
continue;
}
// Check if there is more chained
const filterEnd = expression.search(/\|=|\|~|!=|!~/);
let filterTerm;
if (filterEnd === -1) {
filterTerm = expression.trim();
} else {
filterTerm = expression.substr(0, filterEnd);
expression = expression.substr(filterEnd);
}
// Unwrap the filter term by removing quotes
results.push(filterTerm.replace(/^\s*"/g, '').replace(/"\s*$/g, ''));
}
return results;
}
......@@ -22,3 +22,8 @@ export interface LokiLogsStreamEntry {
// Legacy, was renamed to ts
timestamp?: string;
}
export interface LokiExpression {
regexp: string;
query: string;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment