Loki Query Editor: Add support to display query parsing errors to users (#59427)

* feat(loki-query-validation): validation proof of concept

* feat(loki-query-validation): refactor and properly display the error portion

* feat(loki-query-validation): add support for multi-line queries

* feat(loki-query-validation): improve display of linting errors to users

* feat(loki-query-validation): add unit tests

* Chore: remove unused import

* wip

* Revert "wip"

This reverts commit 44896f7fa2d33251033f8c37776f4d6f2f43787d.

* Revert "Revert "wip""

This reverts commit f7889f49a6b0bdc5a4b677e9bbb8c62ea3cccb74.

* feat(loki-query-validation): parse original and interpolated query for better validation feedback

* feat(loki-query-validation): refactor interpolated query validation support

* feat(loki-query-validation): improve validation for interpolated queries
This commit is contained in:
Matias Chomicki 2022-12-13 18:07:59 +01:00 committed by GitHub
parent c2dcf78fac
commit 58a41af3f3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 249 additions and 13 deletions

View File

@ -178,7 +178,7 @@ export class LokiQueryField extends React.PureComponent<LokiQueryFieldProps, Lok
{config.featureToggles.lokiMonacoEditor ? (
<MonacoQueryFieldWrapper
runQueryOnBlur={app !== CoreApp.Explore}
languageProvider={datasource.languageProvider}
datasource={datasource}
history={history ?? []}
onChange={this.onChangeQuery}
onRunQuery={onRunQuery}

View File

@ -1,7 +1,6 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import LokiLanguageProvider from '../../LanguageProvider';
import { createLokiDatasource } from '../../mocks';
import { MonacoQueryFieldWrapper, Props } from './MonacoQueryFieldWrapper';
@ -13,11 +12,10 @@ function renderComponent({
runQueryOnBlur = false,
}: Partial<Props> = {}) {
const datasource = createLokiDatasource();
const languageProvider = new LokiLanguageProvider(datasource);
render(
<MonacoQueryFieldWrapper
languageProvider={languageProvider}
datasource={datasource}
history={[]}
initialValue={initialValue}
onChange={onChange}

View File

@ -1,7 +1,6 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import LokiLanguageProvider from '../../LanguageProvider';
import { createLokiDatasource } from '../../mocks';
import MonacoQueryField from './MonacoQueryField';
@ -9,11 +8,10 @@ import { Props } from './MonacoQueryFieldProps';
function renderComponent({ initialValue = '', onRunQuery = jest.fn(), onBlur = jest.fn() }: Partial<Props> = {}) {
const datasource = createLokiDatasource();
const languageProvider = new LokiLanguageProvider(datasource);
render(
<MonacoQueryField
languageProvider={languageProvider}
datasource={datasource}
initialValue={initialValue}
history={[]}
onRunQuery={onRunQuery}

View File

@ -12,6 +12,7 @@ import { Props } from './MonacoQueryFieldProps';
import { getOverrideServices } from './getOverrideServices';
import { getCompletionProvider, getSuggestOptions } from './monaco-completion-provider';
import { CompletionDataProvider } from './monaco-completion-provider/CompletionDataProvider';
import { placeHolderScopedVars, validateQuery } from './monaco-completion-provider/validation';
const options: monacoTypes.editor.IStandaloneEditorConstructionOptions = {
codeLens: false,
@ -78,12 +79,13 @@ const getStyles = (theme: GrafanaTheme2) => {
};
};
const MonacoQueryField = ({ languageProvider, history, onBlur, onRunQuery, initialValue }: Props) => {
const MonacoQueryField = ({ history, onBlur, onRunQuery, initialValue, datasource }: Props) => {
const id = uuidv4();
// we need only one instance of `overrideServices` during the lifetime of the react component
const overrideServicesRef = useRef(getOverrideServices());
const containerRef = useRef<HTMLDivElement>(null);
const langProviderRef = useLatest(languageProvider);
const langProviderRef = useLatest(datasource.languageProvider);
const historyRef = useLatest(history);
const onRunQueryRef = useLatest(onRunQuery);
const onBlurRef = useLatest(onBlur);
@ -123,6 +125,28 @@ const MonacoQueryField = ({ languageProvider, history, onBlur, onRunQuery, initi
isEditorFocused.set(false);
onBlurRef.current(editor.getValue());
});
editor.onDidChangeModelContent((e) => {
const model = editor.getModel();
if (!model) {
return;
}
const query = model.getValue();
const errors =
validateQuery(
query,
datasource.interpolateString(query, placeHolderScopedVars),
model.getLinesContent()
) || [];
const markers = errors.map(({ error, ...boundary }) => ({
message: `${
error ? `Error parsing "${error}"` : 'Parse error'
}. The query appears to be incorrect and could fail to be executed.`,
severity: monaco.MarkerSeverity.Error,
...boundary,
}));
monaco.editor.setModelMarkers(model, 'owner', markers);
});
const dataProvider = new CompletionDataProvider(langProviderRef.current, historyRef.current);
const completionProvider = getCompletionProvider(monaco, dataProvider);

View File

@ -1,6 +1,6 @@
import { HistoryItem } from '@grafana/data';
import type LanguageProvider from '../../LanguageProvider';
import { LokiDatasource } from '../../datasource';
import { LokiQuery } from '../../types';
// we need to store this in a separate file,
@ -9,8 +9,8 @@ import { LokiQuery } from '../../types';
// props as the sync-component.
export type Props = {
initialValue: string;
languageProvider: LanguageProvider;
history: Array<HistoryItem<LokiQuery>>;
onRunQuery: (value: string) => void;
onBlur: (value: string) => void;
datasource: LokiDatasource;
};

View File

@ -0,0 +1,101 @@
import { validateQuery } from './validation';
describe('Monaco Query Validation', () => {
test('Identifies empty queries as valid', () => {
expect(validateQuery('', '', [])).toBeFalsy();
});
test('Identifies valid queries', () => {
const query = '{place="luna"}';
expect(validateQuery(query, query, [])).toBeFalsy();
});
test('Validates logs queries', () => {
let query = '{place="incomplete"';
expect(validateQuery(query, query, [query])).toEqual([
{
endColumn: 20,
endLineNumber: 1,
error: '{place="incomplete"',
startColumn: 1,
startLineNumber: 1,
},
]);
query = '{place="luna"} | notaparser';
expect(validateQuery(query, query, [query])).toEqual([
{
endColumn: 28,
endLineNumber: 1,
error: 'notaparser',
startColumn: 18,
startLineNumber: 1,
},
]);
query = '{place="luna"} | logfmt |';
expect(validateQuery(query, query, [query])).toEqual([
{
endColumn: 26,
endLineNumber: 1,
error: '|',
startColumn: 25,
startLineNumber: 1,
},
]);
});
test('Validates metric queries', () => {
let query = 'sum(count_over_time({place="luna" | unwrap request_time [5m])) by (level)';
expect(validateQuery(query, query, [query])).toEqual([
{
endColumn: 35,
endLineNumber: 1,
error: '{place="luna" ',
startColumn: 21,
startLineNumber: 1,
},
]);
query = 'sum(count_over_time({place="luna"} | unwrap [5m])) by (level)';
expect(validateQuery(query, query, [query])).toEqual([
{
endColumn: 45,
endLineNumber: 1,
error: '| unwrap ',
startColumn: 36,
startLineNumber: 1,
},
]);
query = 'sum()';
expect(validateQuery(query, query, [query])).toEqual([
{
endColumn: 5,
endLineNumber: 1,
error: '',
startColumn: 5,
startLineNumber: 1,
},
]);
});
test('Validates multi-line queries', () => {
const query = `
{place="luna"}
# this is a comment
|
logfmt fail
|= "a"`;
const queryLines = query.split('\n');
expect(validateQuery(query, query, queryLines)).toEqual([
{
endColumn: 12,
endLineNumber: 5,
error: 'fail',
startColumn: 8,
startLineNumber: 5,
},
]);
});
});

View File

@ -0,0 +1,115 @@
import { SyntaxNode } from '@lezer/common';
import { parser } from '@grafana/lezer-logql';
import { ErrorId } from 'app/plugins/datasource/prometheus/querybuilder/shared/parsingUtils';
interface ParserErrorBoundary {
startLineNumber: number;
startColumn: number;
endLineNumber: number;
endColumn: number;
error: string;
}
interface ParseError {
text: string;
node: SyntaxNode;
}
export function validateQuery(
query: string,
interpolatedQuery: string,
queryLines: string[]
): ParserErrorBoundary[] | false {
if (!query) {
return false;
}
/**
* To provide support to variable interpolation in query validation, we run the parser in the interpolated
* query. If there are errors there, we trace them back to the original unparsed query, so we can more
* accurately highlight the error in the query, since it's likely that the variable name and variable value
* have different lengths. With this, we also exclude irrelevant parser errors that are produced by
* lezer not understanding $variables and $__variables, which usually generate 2 or 3 error SyntaxNode.
*/
const interpolatedErrors: ParseError[] = parseQuery(interpolatedQuery);
if (!interpolatedErrors.length) {
return false;
}
let parseErrors: ParseError[] = interpolatedErrors;
if (query !== interpolatedQuery) {
const queryErrors: ParseError[] = parseQuery(query);
parseErrors = interpolatedErrors.flatMap(
(interpolatedError) =>
queryErrors.filter((queryError) => interpolatedError.text === queryError.text) || interpolatedError
);
}
return parseErrors.map((parseError) => findErrorBoundary(query, queryLines, parseError)).filter(isErrorBoundary);
}
function parseQuery(query: string) {
const parseErrors: ParseError[] = [];
const tree = parser.parse(query);
tree.iterate({
enter: (nodeRef): false | void => {
if (nodeRef.type.id === ErrorId) {
const node = nodeRef.node;
parseErrors.push({
node: node,
text: query.substring(node.from, node.to),
});
}
},
});
return parseErrors;
}
function findErrorBoundary(query: string, queryLines: string[], parseError: ParseError): ParserErrorBoundary | null {
if (queryLines.length === 1) {
const isEmptyString = parseError.node.from === parseError.node.to;
const errorNode = isEmptyString && parseError.node.parent ? parseError.node.parent : parseError.node;
const error = isEmptyString ? query.substring(errorNode.from, errorNode.to) : parseError.text;
return {
startLineNumber: 1,
startColumn: errorNode.from + 1,
endLineNumber: 1,
endColumn: errorNode.to + 1,
error,
};
}
let startPos = 0,
endPos = 0;
for (let line = 0; line < queryLines.length; line++) {
endPos = startPos + queryLines[line].length;
if (parseError.node.from > endPos) {
startPos += queryLines[line].length + 1;
continue;
}
return {
startLineNumber: line + 1,
startColumn: parseError.node.from - startPos + 1,
endLineNumber: line + 1,
endColumn: parseError.node.to - startPos + 1,
error: parseError.text,
};
}
return null;
}
function isErrorBoundary(boundary: ParserErrorBoundary | null): boundary is ParserErrorBoundary {
return boundary !== null;
}
export const placeHolderScopedVars = {
__interval: { text: '1s', value: '1s' },
__interval_ms: { text: '1000', value: 1000 },
__range_ms: { text: '1000', value: 1000 },
__range_s: { text: '1', value: 1 },
__range: { text: '1s', value: '1s' },
};

View File

@ -794,8 +794,8 @@ export class LokiDatasource
};
}
interpolateString(string: string) {
return this.templateSrv.replace(string, undefined, this.interpolateQueryExpr);
interpolateString(string: string, scopedVars?: ScopedVars) {
return this.templateSrv.replace(string, scopedVars, this.interpolateQueryExpr);
}
getVariables(): string[] {