mirror of
https://github.com/grafana/grafana.git
synced 2025-02-15 01:53:33 -06:00
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:
parent
c2dcf78fac
commit
58a41af3f3
@ -178,7 +178,7 @@ export class LokiQueryField extends React.PureComponent<LokiQueryFieldProps, Lok
|
|||||||
{config.featureToggles.lokiMonacoEditor ? (
|
{config.featureToggles.lokiMonacoEditor ? (
|
||||||
<MonacoQueryFieldWrapper
|
<MonacoQueryFieldWrapper
|
||||||
runQueryOnBlur={app !== CoreApp.Explore}
|
runQueryOnBlur={app !== CoreApp.Explore}
|
||||||
languageProvider={datasource.languageProvider}
|
datasource={datasource}
|
||||||
history={history ?? []}
|
history={history ?? []}
|
||||||
onChange={this.onChangeQuery}
|
onChange={this.onChangeQuery}
|
||||||
onRunQuery={onRunQuery}
|
onRunQuery={onRunQuery}
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import LokiLanguageProvider from '../../LanguageProvider';
|
|
||||||
import { createLokiDatasource } from '../../mocks';
|
import { createLokiDatasource } from '../../mocks';
|
||||||
|
|
||||||
import { MonacoQueryFieldWrapper, Props } from './MonacoQueryFieldWrapper';
|
import { MonacoQueryFieldWrapper, Props } from './MonacoQueryFieldWrapper';
|
||||||
@ -13,11 +12,10 @@ function renderComponent({
|
|||||||
runQueryOnBlur = false,
|
runQueryOnBlur = false,
|
||||||
}: Partial<Props> = {}) {
|
}: Partial<Props> = {}) {
|
||||||
const datasource = createLokiDatasource();
|
const datasource = createLokiDatasource();
|
||||||
const languageProvider = new LokiLanguageProvider(datasource);
|
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<MonacoQueryFieldWrapper
|
<MonacoQueryFieldWrapper
|
||||||
languageProvider={languageProvider}
|
datasource={datasource}
|
||||||
history={[]}
|
history={[]}
|
||||||
initialValue={initialValue}
|
initialValue={initialValue}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import LokiLanguageProvider from '../../LanguageProvider';
|
|
||||||
import { createLokiDatasource } from '../../mocks';
|
import { createLokiDatasource } from '../../mocks';
|
||||||
|
|
||||||
import MonacoQueryField from './MonacoQueryField';
|
import MonacoQueryField from './MonacoQueryField';
|
||||||
@ -9,11 +8,10 @@ import { Props } from './MonacoQueryFieldProps';
|
|||||||
|
|
||||||
function renderComponent({ initialValue = '', onRunQuery = jest.fn(), onBlur = jest.fn() }: Partial<Props> = {}) {
|
function renderComponent({ initialValue = '', onRunQuery = jest.fn(), onBlur = jest.fn() }: Partial<Props> = {}) {
|
||||||
const datasource = createLokiDatasource();
|
const datasource = createLokiDatasource();
|
||||||
const languageProvider = new LokiLanguageProvider(datasource);
|
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<MonacoQueryField
|
<MonacoQueryField
|
||||||
languageProvider={languageProvider}
|
datasource={datasource}
|
||||||
initialValue={initialValue}
|
initialValue={initialValue}
|
||||||
history={[]}
|
history={[]}
|
||||||
onRunQuery={onRunQuery}
|
onRunQuery={onRunQuery}
|
||||||
|
@ -12,6 +12,7 @@ import { Props } from './MonacoQueryFieldProps';
|
|||||||
import { getOverrideServices } from './getOverrideServices';
|
import { getOverrideServices } from './getOverrideServices';
|
||||||
import { getCompletionProvider, getSuggestOptions } from './monaco-completion-provider';
|
import { getCompletionProvider, getSuggestOptions } from './monaco-completion-provider';
|
||||||
import { CompletionDataProvider } from './monaco-completion-provider/CompletionDataProvider';
|
import { CompletionDataProvider } from './monaco-completion-provider/CompletionDataProvider';
|
||||||
|
import { placeHolderScopedVars, validateQuery } from './monaco-completion-provider/validation';
|
||||||
|
|
||||||
const options: monacoTypes.editor.IStandaloneEditorConstructionOptions = {
|
const options: monacoTypes.editor.IStandaloneEditorConstructionOptions = {
|
||||||
codeLens: false,
|
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();
|
const id = uuidv4();
|
||||||
// we need only one instance of `overrideServices` during the lifetime of the react component
|
// we need only one instance of `overrideServices` during the lifetime of the react component
|
||||||
const overrideServicesRef = useRef(getOverrideServices());
|
const overrideServicesRef = useRef(getOverrideServices());
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const langProviderRef = useLatest(languageProvider);
|
|
||||||
|
const langProviderRef = useLatest(datasource.languageProvider);
|
||||||
const historyRef = useLatest(history);
|
const historyRef = useLatest(history);
|
||||||
const onRunQueryRef = useLatest(onRunQuery);
|
const onRunQueryRef = useLatest(onRunQuery);
|
||||||
const onBlurRef = useLatest(onBlur);
|
const onBlurRef = useLatest(onBlur);
|
||||||
@ -123,6 +125,28 @@ const MonacoQueryField = ({ languageProvider, history, onBlur, onRunQuery, initi
|
|||||||
isEditorFocused.set(false);
|
isEditorFocused.set(false);
|
||||||
onBlurRef.current(editor.getValue());
|
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 dataProvider = new CompletionDataProvider(langProviderRef.current, historyRef.current);
|
||||||
const completionProvider = getCompletionProvider(monaco, dataProvider);
|
const completionProvider = getCompletionProvider(monaco, dataProvider);
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { HistoryItem } from '@grafana/data';
|
import { HistoryItem } from '@grafana/data';
|
||||||
|
|
||||||
import type LanguageProvider from '../../LanguageProvider';
|
import { LokiDatasource } from '../../datasource';
|
||||||
import { LokiQuery } from '../../types';
|
import { LokiQuery } from '../../types';
|
||||||
|
|
||||||
// we need to store this in a separate file,
|
// we need to store this in a separate file,
|
||||||
@ -9,8 +9,8 @@ import { LokiQuery } from '../../types';
|
|||||||
// props as the sync-component.
|
// props as the sync-component.
|
||||||
export type Props = {
|
export type Props = {
|
||||||
initialValue: string;
|
initialValue: string;
|
||||||
languageProvider: LanguageProvider;
|
|
||||||
history: Array<HistoryItem<LokiQuery>>;
|
history: Array<HistoryItem<LokiQuery>>;
|
||||||
onRunQuery: (value: string) => void;
|
onRunQuery: (value: string) => void;
|
||||||
onBlur: (value: string) => void;
|
onBlur: (value: string) => void;
|
||||||
|
datasource: LokiDatasource;
|
||||||
};
|
};
|
||||||
|
@ -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,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
@ -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' },
|
||||||
|
};
|
@ -794,8 +794,8 @@ export class LokiDatasource
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interpolateString(string: string) {
|
interpolateString(string: string, scopedVars?: ScopedVars) {
|
||||||
return this.templateSrv.replace(string, undefined, this.interpolateQueryExpr);
|
return this.templateSrv.replace(string, scopedVars, this.interpolateQueryExpr);
|
||||||
}
|
}
|
||||||
|
|
||||||
getVariables(): string[] {
|
getVariables(): string[] {
|
||||||
|
Loading…
Reference in New Issue
Block a user