diff --git a/package.json b/package.json index c9c65416c5a..33b7fac2f8f 100644 --- a/package.json +++ b/package.json @@ -242,7 +242,7 @@ "@grafana/faro-web-sdk": "1.1.2", "@grafana/google-sdk": "0.1.1", "@grafana/lezer-logql": "0.1.11", - "@grafana/lezer-traceql": "0.0.4", + "@grafana/lezer-traceql": "0.0.5", "@grafana/monaco-logql": "^0.0.7", "@grafana/runtime": "workspace:*", "@grafana/scenes": "^0.29.0", diff --git a/public/app/plugins/datasource/tempo/traceql/autocomplete.test.ts b/public/app/plugins/datasource/tempo/traceql/autocomplete.test.ts index d9e4a7aff77..e174bb554b3 100644 --- a/public/app/plugins/datasource/tempo/traceql/autocomplete.test.ts +++ b/public/app/plugins/datasource/tempo/traceql/autocomplete.test.ts @@ -9,6 +9,8 @@ import { Scope, TempoJsonData } from '../types'; import { CompletionProvider } from './autocomplete'; import { intrinsics, scopes } from './traceql'; +const emptyPosition = {} as monacoTypes.Position; + jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), })); @@ -16,10 +18,7 @@ jest.mock('@grafana/runtime', () => ({ describe('CompletionProvider', () => { it('suggests tags, intrinsics and scopes (API v1)', async () => { const { provider, model } = setup('{}', 1, v1Tags); - const result = await provider.provideCompletionItems( - model as unknown as monacoTypes.editor.ITextModel, - {} as monacoTypes.Position - ); + const result = await provider.provideCompletionItems(model, emptyPosition); expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([ ...scopes.map((s) => expect.objectContaining({ label: s, insertText: s })), ...intrinsics.map((s) => expect.objectContaining({ label: s, insertText: s })), @@ -31,10 +30,7 @@ describe('CompletionProvider', () => { it('suggests tags, intrinsics and scopes (API v2)', async () => { const { provider, model } = setup('{}', 1, undefined, v2Tags); - const result = await provider.provideCompletionItems( - model as unknown as monacoTypes.editor.ITextModel, - {} as monacoTypes.Position - ); + const result = await provider.provideCompletionItems(model, emptyPosition); expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([ ...scopes.map((s) => expect.objectContaining({ label: s, insertText: s })), ...intrinsics.map((s) => expect.objectContaining({ label: s, insertText: s })), @@ -60,10 +56,7 @@ describe('CompletionProvider', () => { }) ); - const result = await provider.provideCompletionItems( - model as unknown as monacoTypes.editor.ITextModel, - {} as monacoTypes.Position - ); + const result = await provider.provideCompletionItems(model, emptyPosition); expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([ expect.objectContaining({ label: 'foobar', insertText: 'foobar' }), ]); @@ -85,10 +78,7 @@ describe('CompletionProvider', () => { }) ); - const result = await provider.provideCompletionItems( - model as unknown as monacoTypes.editor.ITextModel, - {} as monacoTypes.Position - ); + const result = await provider.provideCompletionItems(model, emptyPosition); expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([ expect.objectContaining({ label: 'foobar', insertText: '"foobar"' }), ]); @@ -109,10 +99,7 @@ describe('CompletionProvider', () => { }) ); - const result = await provider.provideCompletionItems( - model as unknown as monacoTypes.editor.ITextModel, - {} as monacoTypes.Position - ); + const result = await provider.provideCompletionItems(model, emptyPosition); expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([ expect.objectContaining({ label: 'foobar', insertText: 'foobar' }), ]); @@ -120,19 +107,13 @@ describe('CompletionProvider', () => { it('suggests nothing without tags', async () => { const { provider, model } = setup('{.foo="}', 8, emptyTags); - const result = await provider.provideCompletionItems( - model as unknown as monacoTypes.editor.ITextModel, - {} as monacoTypes.Position - ); + const result = await provider.provideCompletionItems(model, emptyPosition); expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([]); }); it('suggests tags on empty input (API v1)', async () => { const { provider, model } = setup('', 0, v1Tags); - const result = await provider.provideCompletionItems( - model as unknown as monacoTypes.editor.ITextModel, - {} as monacoTypes.Position - ); + const result = await provider.provideCompletionItems(model, emptyPosition); expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([ ...scopes.map((s) => expect.objectContaining({ label: s, insertText: `{ ${s}` })), ...intrinsics.map((s) => expect.objectContaining({ label: s, insertText: `{ ${s}` })), @@ -144,10 +125,7 @@ describe('CompletionProvider', () => { it('suggests tags on empty input (API v2)', async () => { const { provider, model } = setup('', 0, undefined, v2Tags); - const result = await provider.provideCompletionItems( - model as unknown as monacoTypes.editor.ITextModel, - {} as monacoTypes.Position - ); + const result = await provider.provideCompletionItems(model, emptyPosition); expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([ ...scopes.map((s) => expect.objectContaining({ label: s, insertText: `{ ${s}` })), ...intrinsics.map((s) => expect.objectContaining({ label: s, insertText: `{ ${s}` })), @@ -159,10 +137,7 @@ describe('CompletionProvider', () => { it('only suggests tags after typing the global attribute scope (API v1)', async () => { const { provider, model } = setup('{.}', 2, v1Tags); - const result = await provider.provideCompletionItems( - model as unknown as monacoTypes.editor.ITextModel, - {} as monacoTypes.Position - ); + const result = await provider.provideCompletionItems(model, emptyPosition); expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual( v1Tags.map((s) => expect.objectContaining({ label: s, insertText: s })) ); @@ -170,34 +145,15 @@ describe('CompletionProvider', () => { it('only suggests tags after typing the global attribute scope (API v2)', async () => { const { provider, model } = setup('{.}', 2, undefined, v2Tags); - const result = await provider.provideCompletionItems( - model as unknown as monacoTypes.editor.ITextModel, - {} as monacoTypes.Position - ); + const result = await provider.provideCompletionItems(model, emptyPosition); expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual( ['cluster', 'container', 'db'].map((s) => expect.objectContaining({ label: s, insertText: s })) ); }); - it('suggests operators after a space after the tag name', async () => { - const { provider, model } = setup('{ .foo }', 7, v1Tags); - const result = await provider.provideCompletionItems( - model as unknown as monacoTypes.editor.ITextModel, - {} as monacoTypes.Position - ); - expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual( - [...CompletionProvider.logicalOps, ...CompletionProvider.operators].map((s) => - expect.objectContaining({ label: s, insertText: s }) - ) - ); - }); - it('suggests tags after a scope (API v1)', async () => { const { provider, model } = setup('{ resource. }', 11, v1Tags); - const result = await provider.provideCompletionItems( - model as unknown as monacoTypes.editor.ITextModel, - {} as monacoTypes.Position - ); + const result = await provider.provideCompletionItems(model, emptyPosition); expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual( v1Tags.map((s) => expect.objectContaining({ label: s, insertText: s })) ); @@ -205,10 +161,7 @@ describe('CompletionProvider', () => { it('suggests correct tags after the resource scope (API v2)', async () => { const { provider, model } = setup('{ resource. }', 11, undefined, v2Tags); - const result = await provider.provideCompletionItems( - model as unknown as monacoTypes.editor.ITextModel, - {} as monacoTypes.Position - ); + const result = await provider.provideCompletionItems(model, emptyPosition); expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual( ['cluster', 'container'].map((s) => expect.objectContaining({ label: s, insertText: s })) ); @@ -216,10 +169,7 @@ describe('CompletionProvider', () => { it('suggests correct tags after the span scope (API v2)', async () => { const { provider, model } = setup('{ span. }', 7, undefined, v2Tags); - const result = await provider.provideCompletionItems( - model as unknown as monacoTypes.editor.ITextModel, - {} as monacoTypes.Position - ); + const result = await provider.provideCompletionItems(model, emptyPosition); expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual( ['db'].map((s) => expect.objectContaining({ label: s, insertText: s })) ); @@ -227,16 +177,149 @@ describe('CompletionProvider', () => { it('suggests logical operators and close bracket after the value', async () => { const { provider, model } = setup('{.foo=300 }', 10, v1Tags); - const result = await provider.provideCompletionItems( - model as unknown as monacoTypes.editor.ITextModel, - {} as monacoTypes.Position - ); + const result = await provider.provideCompletionItems(model, emptyPosition); expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual( - [...CompletionProvider.logicalOps, ...CompletionProvider.operators].map((s) => - expect.objectContaining({ label: s, insertText: s }) + [...CompletionProvider.logicalOps, ...CompletionProvider.arithmeticOps, ...CompletionProvider.comparisonOps].map( + (s) => expect.objectContaining({ label: s.label, insertText: s.insertText }) ) ); }); + + it('suggests spanset combining operators after spanset selector', async () => { + const { provider, model } = setup('{.foo=300} ', 11); + const result = await provider.provideCompletionItems(model, emptyPosition); + expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual( + CompletionProvider.spansetOps.map((s) => expect.objectContaining({ label: s.label, insertText: s.insertText })) + ); + }); + + it.each([ + ['{.foo=300} | ', 13], + ['{.foo=300} && {.bar=200} | ', 27], + ['{.foo=300} && {.bar=300} && {.foo=300} | ', 41], + ])( + 'suggests operators that go after `|` (aggregators, selectorts, ...) - %s, %i', + async (input: string, offset: number) => { + const { provider, model } = setup(input, offset); + const result = await provider.provideCompletionItems(model, emptyPosition); + expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual( + CompletionProvider.functions.map((s) => + expect.objectContaining({ label: s.label, insertText: s.insertText, documentation: s.documentation }) + ) + ); + } + ); + + it.each([ + ['{.foo=300} | avg(.value) ', 25], + ['{.foo=300} && {.foo=300} | avg(.value) ', 39], + ])( + 'suggests comparison operators after aggregator (avg, max, ...) - %s, %i', + async (input: string, offset: number) => { + const { provider, model } = setup(input, offset); + const result = await provider.provideCompletionItems(model, emptyPosition); + expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual( + CompletionProvider.comparisonOps.map((s) => + expect.objectContaining({ label: s.label, insertText: s.insertText }) + ) + ); + } + ); + + it.each([ + ['{.foo=300} | avg(.value) = ', 27], + ['{.foo=300} && {.foo=300} | avg(.value) = ', 41], + ])('does not suggest after aggregator and comparison operator - %s, %i', async (input: string, offset: number) => { + const { provider, model } = setup(input, offset); + const result = await provider.provideCompletionItems(model, emptyPosition); + expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([]); + }); + + it('suggests when `}` missing', async () => { + const { provider, model } = setup('{ span.http.status_code ', 24); + const result = await provider.provideCompletionItems(model, emptyPosition); + expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual( + [...CompletionProvider.comparisonOps, ...CompletionProvider.logicalOps].map((s) => + expect.objectContaining({ label: s.label, insertText: s.insertText }) + ) + ); + }); + + it.each([ + ['{ .foo }', 6], + ['{ .foo }', 7], + ['{.foo 300}', 5], + ['{.foo 300}', 6], + ['{.foo 300}', 7], + ['{.foo 300}', 8], + ['{.foo 300 && .bar = 200}', 5], + ['{.foo 300 && .bar = 200}', 6], + ['{.foo 300 && .bar = 200}', 7], + ['{.foo 300 && .bar 200}', 18], + ['{.foo 300 && .bar 200}', 19], + ['{.foo 300 && .bar 200}', 20], + ['{ .foo = 1 && .bar }', 18], + ['{ .foo = 1 && .bar }', 19], + ['{ .foo = 1 && .bar }', 19], + ])('suggests with incomplete spanset - %s, %i', async (input: string, offset: number) => { + const { provider, model } = setup(input, offset); + const result = await provider.provideCompletionItems(model, emptyPosition); + expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual( + [...CompletionProvider.comparisonOps, ...CompletionProvider.logicalOps, ...CompletionProvider.arithmeticOps].map( + (s) => expect.objectContaining({ label: s.label, insertText: s.insertText }) + ) + ); + }); + + it.each([ + ['{.foo=1} {.bar=2}', 8], + ['{.foo=1} {.bar=2}', 9], + ['{.foo=1} {.bar=2}', 10], + ])( + 'suggests spanset combining operators in an incomplete, multi-spanset query - %s, %i', + async (input: string, offset: number) => { + const { provider, model } = setup(input, offset); + const result = await provider.provideCompletionItems(model, emptyPosition); + expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual( + CompletionProvider.spansetOps.map((completionItem) => + expect.objectContaining({ + detail: completionItem.detail, + documentation: completionItem.documentation, + insertText: completionItem.insertText, + label: completionItem.label, + }) + ) + ); + } + ); + + it.each([ + // After spanset + ['{ span.http.status_code = 200 && }', 33], + ['{ span.http.status_code = 200 || }', 33], + ['{ span.http.status_code = 200 && }', 34], + ['{ span.http.status_code = 200 || }', 34], + ['{ span.http.status_code = 200 && }', 35], + ['{ span.http.status_code = 200 || }', 35], + ['{ .foo = 200 } && ', 18], + ['{ .foo = 200 } && ', 19], + ['{ .foo = 200 } || ', 18], + ['{ .foo = 200 } >> ', 18], + // Between spansets + ['{ .foo = 1 } && { .bar = 2 }', 16], + // Inside `()` + ['{.foo=1} | avg()', 15], + ['{.foo=1} | avg() < 1s', 15], + ['{.foo=1} | max() = 3', 15], + ['{.foo=1} | by()', 14], + ['{.foo=1} | select()', 18], + ])('suggests attributes - %s, %i', async (input: string, offset: number) => { + const { provider, model } = setup(input, offset); + const result = await provider.provideCompletionItems(model, emptyPosition); + expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual( + [...scopes, ...intrinsics].map((s) => expect.objectContaining({ label: s })) + ); + }); }); function setup(value: string, offset: number, tagsV1?: string[], tagsV2?: Scope[]) { @@ -268,7 +351,7 @@ function setup(value: string, offset: number, tagsV1?: string[], tagsV2?: Scope[ }, } as any; - return { provider, model }; + return { provider, model } as unknown as { provider: CompletionProvider; model: monacoTypes.editor.ITextModel }; } function makeModel(value: string, offset: number) { diff --git a/public/app/plugins/datasource/tempo/traceql/autocomplete.ts b/public/app/plugins/datasource/tempo/traceql/autocomplete.ts index 38f61694513..2b8c6b306f7 100644 --- a/public/app/plugins/datasource/tempo/traceql/autocomplete.ts +++ b/public/app/plugins/datasource/tempo/traceql/autocomplete.ts @@ -1,3 +1,5 @@ +import { IMarkdownString } from 'monaco-editor'; + import { SelectableValue } from '@grafana/data'; import { isFetchError } from '@grafana/runtime'; import type { Monaco, monacoTypes } from '@grafana/ui'; @@ -14,9 +16,19 @@ interface Props { languageProvider: TempoLanguageProvider; } +type MinimalCompletionItem = { + label: string; + insertText: string; + detail?: string; + documentation?: string | IMarkdownString; +}; + /** * Class that implements CompletionItemProvider interface and allows us to provide suggestion for the Monaco * autocomplete system. + * + * Here we want to provide suggestions for TraceQL. Please refer to + * https://grafana.com/docs/tempo/latest/traceql for the syntax of the language. */ export class CompletionProvider implements monacoTypes.languages.CompletionItemProvider { languageProvider: TempoLanguageProvider; @@ -28,8 +40,171 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP } triggerCharacters = ['{', '.', '[', '(', '=', '~', ' ', '"']; - static readonly operators: string[] = ['=', '-', '+', '<', '>', '>=', '<=', '=~']; - static readonly logicalOps: string[] = ['&&', '||']; + + // Operators + static readonly arithmeticOps: MinimalCompletionItem[] = [ + { + label: '+', + insertText: '+', + detail: 'Plus', + }, + { + label: '-', + insertText: '-', + detail: 'Minus', + }, + { + label: '*', + insertText: '*', + detail: 'Times', + }, + { + label: '/', + insertText: '/', + detail: 'Over', + }, + ]; + + static readonly logicalOps: MinimalCompletionItem[] = [ + { + label: '&&', + insertText: '&&', + detail: 'And', + documentation: 'And (intersection) operator. Checks that both conditions found matches.', + }, + { + label: '||', + insertText: '||', + detail: 'Or', + documentation: 'Or (union) operator. Checks that either condition found matches.', + }, + ]; + + static readonly comparisonOps: MinimalCompletionItem[] = [ + { + label: '=', + insertText: '=', + detail: 'Equality', + }, + { + label: '!=', + insertText: '!=', + detail: 'Inequality', + }, + { + label: '>', + insertText: '>', + detail: 'Greater than', + }, + { + label: '>=', + insertText: '>=', + detail: 'Greater than or equal to', + }, + { + label: '<', + insertText: '<', + detail: 'Less than', + }, + { + label: '<=', + insertText: '<=', + detail: 'Less than or equal to', + }, + { + label: '=~', + insertText: '=~', + detail: 'Regular expression', + }, + { + label: '!~', + insertText: '!~', + detail: 'Negated regular expression', + }, + ]; + static readonly structuralOps: MinimalCompletionItem[] = [ + { + label: '>>', + insertText: '>>', + detail: 'Descendant', + documentation: + 'Descendant operator. Looks for spans matching {condB} that are descendants of a span matching {condA}', + }, + { + label: '>', + insertText: '>', + detail: 'Child', + documentation: + 'Child operator. Looks for spans matching {condB} that are direct child spans of a parent matching {condA}', + }, + { + label: '~', + insertText: '~', + detail: 'Sibling', + documentation: + 'Sibling operator. Checks that spans matching {condA} and {condB} are siblings of the same parent span.', + }, + ]; + + static readonly spansetOps: MinimalCompletionItem[] = [ + { + label: '|', + insertText: '|', + detail: 'Pipe', + }, + ...CompletionProvider.logicalOps, + ...CompletionProvider.structuralOps, + ]; + + // Functions (aggregator, selector, and combining operators) + static readonly spansetAggregatorOps: MinimalCompletionItem[] = [ + { + label: 'count', + insertText: 'count()$0', + detail: 'Number of spans', + documentation: 'Counts the number of spans in a spanset', + }, + { + label: 'avg', + insertText: 'avg($0)', + detail: 'Average of attribute', + documentation: 'Computes the average of a given numeric attribute or intrinsic for a spanset.', + }, + { + label: 'max', + insertText: 'max($0)', + detail: 'Max value of attribute', + documentation: 'Computes the maximum value of a given numeric attribute or intrinsic for a spanset.', + }, + { + label: 'min', + insertText: 'min($0)', + detail: 'Min value of attribute', + documentation: 'Computes the minimum value of a given numeric attribute or intrinsic for a spanset.', + }, + { + label: 'sum', + insertText: 'sum($0)', + detail: 'Sum value of attribute', + documentation: 'Computes the sum value of a given numeric attribute or intrinsic for a spanset.', + }, + ]; + + static readonly functions: MinimalCompletionItem[] = [ + ...this.spansetAggregatorOps, + { + label: 'by', + insertText: 'by($0)', + detail: 'Grouping of attributes', + documentation: 'Groups by arbitrary attributes.', + }, + { + label: 'select', + insertText: 'select($0)', + detail: 'Selection of fields', + documentation: 'Selects arbitrary fields from spans.', + }, + ]; // We set these directly and ae required for the provider to function. monaco: Monaco | undefined; @@ -66,6 +241,9 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP kind: getMonacoCompletionItemKind(item.type, this.monaco!), label: item.label, insertText: item.insertText, + insertTextRules: item.insertTextRules, + detail: item.detail, + documentation: item.documentation, sortText: index.toString().padStart(maxIndexDigits, '0'), // to force the order we have range, command: { @@ -107,7 +285,7 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP */ private async getCompletions(situation: Situation): Promise { switch (situation.type) { - // Not really sure what would make sense to suggest in this case so just leave it + // This should only happen for cases that we do not support yet case 'UNKNOWN': { return []; } @@ -121,14 +299,52 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP case 'SPANSET_ONLY_DOT': { return this.getTagsCompletions(); } + case 'SPANSET_IN_THE_MIDDLE': + return [...CompletionProvider.comparisonOps, ...CompletionProvider.logicalOps].map((key) => ({ + ...key, + type: 'OPERATOR', + })); + case 'SPANSET_EXPRESSION_OPERATORS_WITH_MISSING_CLOSED_BRACE': + return [...CompletionProvider.comparisonOps, ...CompletionProvider.logicalOps].map((key) => ({ + ...key, + type: 'OPERATOR', + })); case 'SPANSET_IN_NAME': return this.getScopesCompletions().concat(this.getIntrinsicsCompletions()).concat(this.getTagsCompletions()); case 'SPANSET_IN_NAME_SCOPE': return this.getTagsCompletions(undefined, situation.scope); case 'SPANSET_EXPRESSION_OPERATORS': - return [...CompletionProvider.logicalOps, ...CompletionProvider.operators].map((key) => ({ - label: key, - insertText: key, + return [ + ...CompletionProvider.comparisonOps, + ...CompletionProvider.logicalOps, + ...CompletionProvider.arithmeticOps, + ].map((key) => ({ + ...key, + type: 'OPERATOR', + })); + case 'SPANFIELD_COMBINING_OPERATORS': + return [ + ...CompletionProvider.logicalOps, + ...CompletionProvider.arithmeticOps, + ...CompletionProvider.comparisonOps, + ].map((key) => ({ + ...key, + type: 'OPERATOR', + })); + case 'SPANSET_COMBINING_OPERATORS': + return CompletionProvider.spansetOps.map((key) => ({ + ...key, + type: 'OPERATOR', + })); + case 'SPANSET_PIPELINE_AFTER_OPERATOR': + return CompletionProvider.functions.map((key) => ({ + ...key, + insertTextRules: this.monaco?.languages.CompletionItemInsertTextRule?.InsertAsSnippet, + type: 'FUNCTION', + })); + case 'SPANSET_COMPARISON_OPERATORS': + return CompletionProvider.comparisonOps.map((key) => ({ + ...key, type: 'OPERATOR', })); case 'SPANSET_IN_VALUE': @@ -163,11 +379,17 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP }); return items; case 'SPANSET_AFTER_VALUE': - return CompletionProvider.logicalOps.concat('}').map((key) => ({ - label: key, - insertText: key, + return CompletionProvider.logicalOps.map((key) => ({ + label: key.label, + insertText: key.insertText + '}', type: 'OPERATOR', })); + case 'NEW_SPANSET': + return this.getScopesCompletions('{ ', '$0 }') + .concat(this.getIntrinsicsCompletions('{ ', '$0 }')) + .concat(this.getTagsCompletions('.')); + case 'ATTRIBUTE_FOR_FUNCTION': + return this.getScopesCompletions().concat(this.getIntrinsicsCompletions()).concat(this.getTagsCompletions('.')); default: throw new Error(`Unexpected situation ${situation}`); } @@ -184,19 +406,21 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP })); } - private getIntrinsicsCompletions(prepend?: string): Completion[] { + private getIntrinsicsCompletions(prepend?: string, append?: string): Completion[] { return intrinsics.map((key) => ({ label: key, - insertText: (prepend || '') + key, + insertText: (prepend || '') + key + (append || ''), type: 'KEYWORD', + insertTextRules: this.monaco?.languages.CompletionItemInsertTextRule?.InsertAsSnippet, })); } - private getScopesCompletions(prepend?: string): Completion[] { + private getScopesCompletions(prepend?: string, append?: string): Completion[] { return scopes.map((key) => ({ label: key, - insertText: (prepend || '') + key, + insertText: (prepend || '') + key + (append || ''), type: 'SCOPE', + insertTextRules: this.monaco?.languages.CompletionItemInsertTextRule?.InsertAsSnippet, })); } } @@ -218,16 +442,21 @@ function getMonacoCompletionItemKind(type: CompletionType, monaco: Monaco): mona return monaco.languages.CompletionItemKind.EnumMember; case 'SCOPE': return monaco.languages.CompletionItemKind.Class; + case 'FUNCTION': + return monaco.languages.CompletionItemKind.Function; default: throw new Error(`Unexpected CompletionType: ${type}`); } } -export type CompletionType = 'TAG_NAME' | 'TAG_VALUE' | 'KEYWORD' | 'OPERATOR' | 'SCOPE'; +export type CompletionType = 'TAG_NAME' | 'TAG_VALUE' | 'KEYWORD' | 'OPERATOR' | 'SCOPE' | 'FUNCTION'; type Completion = { type: CompletionType; label: string; insertText: string; + insertTextRules?: monacoTypes.languages.CompletionItemInsertTextRule; // we used it to position the cursor + documentation?: string | IMarkdownString; + detail?: string; }; export type Tag = { diff --git a/public/app/plugins/datasource/tempo/traceql/situation.test.ts b/public/app/plugins/datasource/tempo/traceql/situation.test.ts index 89ad2e4befc..bda871ba18e 100644 --- a/public/app/plugins/datasource/tempo/traceql/situation.test.ts +++ b/public/app/plugins/datasource/tempo/traceql/situation.test.ts @@ -23,9 +23,9 @@ describe('situation', () => { expected: { type: 'SPANSET_ONLY_DOT' }, }, { - query: '{foo}', - cursorPos: 4, - expected: { type: 'SPANSET_IN_NAME' }, + query: '{.foo}', + cursorPos: 5, + expected: { type: 'SPANSET_EXPRESSION_OPERATORS' }, }, { query: '{span.}', @@ -45,7 +45,12 @@ describe('situation', () => { { query: '{span.foo = "val" }', cursorPos: 18, - expected: { type: 'SPANSET_EXPRESSION_OPERATORS' }, + expected: { type: 'SPANFIELD_COMBINING_OPERATORS' }, + }, + { + query: '{span.foo = 200 }', + cursorPos: 15, + expected: { type: 'SPANFIELD_COMBINING_OPERATORS' }, }, { query: '{span.foo = "val" && }', @@ -57,6 +62,11 @@ describe('situation', () => { cursorPos: 30, expected: { type: 'SPANSET_IN_NAME_SCOPE', scope: 'resource' }, }, + { + query: '{ .sla && span.http.status_code && span.http.status_code = 200 }', + cursorPos: 57, + expected: { type: 'SPANSET_EXPRESSION_OPERATORS' }, + }, ]; tests.forEach((test) => { diff --git a/public/app/plugins/datasource/tempo/traceql/situation.ts b/public/app/plugins/datasource/tempo/traceql/situation.ts index fe96276192b..e505faf58de 100644 --- a/public/app/plugins/datasource/tempo/traceql/situation.ts +++ b/public/app/plugins/datasource/tempo/traceql/situation.ts @@ -1,6 +1,24 @@ import { SyntaxNode, Tree } from '@lezer/common'; -import { AttributeField, FieldExpression, FieldOp, parser, SpansetFilter } from '@grafana/lezer-traceql'; +import { + Aggregate, + And, + AttributeField, + ComparisonOp, + FieldExpression, + FieldOp, + GroupOperation, + IntrinsicField, + Or, + parser, + ScalarFilter, + SelectArgs, + SpansetFilter, + SpansetPipeline, + SpansetPipelineExpression, + Static, + TraceQL, +} from '@grafana/lezer-traceql'; type Direction = 'parent' | 'firstChild' | 'lastChild' | 'nextSibling' | 'prevSibling'; type NodeType = number; @@ -23,6 +41,9 @@ export type SituationType = | { type: 'SPANSET_EXPRESSION_OPERATORS'; } + | { + type: 'SPANFIELD_COMBINING_OPERATORS'; + } | { type: 'SPANSET_IN_NAME'; } @@ -37,6 +58,27 @@ export type SituationType = } | { type: 'SPANSET_AFTER_VALUE'; + } + | { + type: 'SPANSET_COMBINING_OPERATORS'; + } + | { + type: 'SPANSET_PIPELINE_AFTER_OPERATOR'; + } + | { + type: 'SPANSET_IN_THE_MIDDLE'; + } + | { + type: 'SPANSET_EXPRESSION_OPERATORS_WITH_MISSING_CLOSED_BRACE'; + } + | { + type: 'NEW_SPANSET'; + } + | { + type: 'ATTRIBUTE_FOR_FUNCTION'; + } + | { + type: 'SPANSET_COMPARISON_OPERATORS'; }; type Path = Array<[Direction, NodeType[]]>; @@ -65,13 +107,15 @@ function move(node: SyntaxNode, direction: Direction): SyntaxNode | null { function walk(node: SyntaxNode, path: Path): SyntaxNode | null { let current: SyntaxNode | null = node; - for (const [direction, expectedNodes] of path) { + for (const [direction, expectedNodeIDs] of path) { current = move(current, direction); if (current === null) { // we could not move in the direction, we stop return null; } - if (!expectedNodes.find((en) => en === current?.type.id)) { + + // note that the found value can be 0, which is acceptable + if (expectedNodeIDs.find((id) => id === current?.type.id) === undefined) { // the reached node has wrong type, we stop return null; } @@ -90,8 +134,8 @@ function isPathMatch(resolverPath: NodeType[], cursorPath: number[]): boolean { /** * Figure out where is the cursor and what kind of suggestions are appropriate. - * @param text - * @param offset + * @param text the user input + * @param offset the position of the cursor (starting from 0) in the user input */ export function getSituation(text: string, offset: number): Situation | null { // there is a special case when we are at the start of writing text, @@ -105,21 +149,29 @@ export function getSituation(text: string, offset: number): Situation | null { const tree = parser.parse(text); + // Whitespaces (especially when multiple) on the left of the text cursor can trick the Lezer parser, + // causing a wrong tree cursor to be picked. + // Example: `{ span.foo = ↓ }`, with `↓` being the cursor, tricks the parser. + // Quick and dirty hack: Shift the cursor to the left until we find a non-whitespace character on its left. + let shiftedOffset = offset; + while (shiftedOffset - 1 >= 0 && text[shiftedOffset - 1] === ' ') { + shiftedOffset -= 1; + } + // if the tree contains error, it is very probable that // our node is one of those error nodes. // also, if there are errors, the node lezer finds us, // might not be the best node. // so first we check if there is an error node at the cursor position - let maybeErrorNode = getErrorNode(tree, offset); + let maybeErrorNode = getErrorNode(tree, shiftedOffset); if (!maybeErrorNode) { // try again with the previous character - maybeErrorNode = getErrorNode(tree, offset - 1); + maybeErrorNode = getErrorNode(tree, shiftedOffset - 1); } - const cur = maybeErrorNode != null ? maybeErrorNode.cursor() : tree.cursorAt(offset); + const cur = maybeErrorNode != null ? maybeErrorNode.cursor() : tree.cursorAt(shiftedOffset); const currentNode = cur.node; - const ids = [cur.type.id]; while (cur.parent()) { ids.push(cur.type.id); @@ -128,7 +180,7 @@ export function getSituation(text: string, offset: number): Situation | null { let situationType: SituationType | null = null; for (let resolver of RESOLVERS) { if (isPathMatch(resolver.path, ids)) { - situationType = resolver.fun(currentNode, text, offset); + situationType = resolver.fun(currentNode, text, shiftedOffset); } } @@ -138,6 +190,7 @@ export function getSituation(text: string, offset: number): Situation | null { const ERROR_NODE_ID = 0; const RESOLVERS: Resolver[] = [ + // Incomplete query cases { path: [ERROR_NODE_ID, AttributeField], fun: resolveAttribute, @@ -148,15 +201,85 @@ const RESOLVERS: Resolver[] = [ }, { path: [ERROR_NODE_ID, SpansetFilter], - fun: resolveErrorInFilterRoot, + fun: () => ({ + type: 'SPANSET_EXPRESSION_OPERATORS_WITH_MISSING_CLOSED_BRACE', + }), + }, + { + path: [ERROR_NODE_ID, SpansetPipeline], + fun: resolveSpansetPipeline, + }, + { + path: [ERROR_NODE_ID, Aggregate], + fun: resolveAttributeForFunction, + }, + { + path: [ERROR_NODE_ID, IntrinsicField], + fun: resolveAttributeForFunction, + }, + { + path: [ERROR_NODE_ID, SpansetPipelineExpression], + fun: () => { + return { + type: 'NEW_SPANSET', + }; + }, + }, + { + path: [ERROR_NODE_ID, ScalarFilter, SpansetPipeline], + fun: resolveArithmeticOperator, + }, + { + path: [ERROR_NODE_ID, TraceQL], + fun: () => { + return { + type: 'UNKNOWN', + }; + }, + }, + // Valid query cases + { + path: [FieldExpression], + fun: () => ({ + type: 'SPANSET_EXPRESSION_OPERATORS', + }), }, { path: [SpansetFilter], fun: resolveSpanset, }, + { + path: [SpansetPipelineExpression], + fun: resolveNewSpansetExpression, + }, + { + path: [TraceQL], + fun: resolveNewSpansetExpression, + }, ]; function resolveSpanset(node: SyntaxNode): SituationType { + const firstChild = walk(node, [ + ['firstChild', [FieldExpression]], + ['firstChild', [AttributeField]], + ]); + if (firstChild) { + return { + type: 'SPANSET_EXPRESSION_OPERATORS', + }; + } + + const lastFieldExpression1 = walk(node, [ + ['lastChild', [FieldExpression]], + ['lastChild', [FieldExpression]], + ['lastChild', [Static]], + ]); + if (lastFieldExpression1) { + return { + type: 'SPANFIELD_COMBINING_OPERATORS', + }; + } + const lastFieldExpression = walk(node, [['lastChild', [FieldExpression]]]); if (lastFieldExpression) { return { @@ -204,13 +327,67 @@ function resolveExpression(node: SyntaxNode, text: string): SituationType { }; } } + + if (node.prevSibling?.type.name === 'And' || node.prevSibling?.type.name === 'Or') { + return { + type: 'SPANSET_EMPTY', + }; + } + return { - type: 'SPANSET_EMPTY', + type: 'SPANSET_IN_THE_MIDDLE', }; } -function resolveErrorInFilterRoot(): SituationType { +function resolveArithmeticOperator(node: SyntaxNode, _0: string, _1: number): SituationType { + if (node.prevSibling?.type.id === ComparisonOp) { + return { + type: 'UNKNOWN', + }; + } + return { - type: 'SPANSET_IN_NAME', + type: 'SPANSET_COMPARISON_OPERATORS', + }; +} + +function resolveNewSpansetExpression(node: SyntaxNode, text: string, offset: number): SituationType { + // Select the node immediately before the one pointed by the cursor + let previousNode = node.firstChild; + try { + previousNode = node.firstChild; + while (previousNode!.to < offset) { + previousNode = previousNode!.nextSibling; + } + } catch (error) { + console.error('Unexpected error while searching for previous node', error); + } + + if (previousNode?.type.id === And || previousNode?.type.id === Or) { + return { + type: 'NEW_SPANSET', + }; + } + + return { + type: 'SPANSET_COMBINING_OPERATORS', + }; +} + +function resolveAttributeForFunction(node: SyntaxNode, _0: string, _1: number): SituationType { + const parent = node?.parent; + if (!!parent && [IntrinsicField, Aggregate, GroupOperation, SelectArgs].includes(parent.type.id)) { + return { + type: 'ATTRIBUTE_FOR_FUNCTION', + }; + } + return { + type: 'UNKNOWN', + }; +} + +function resolveSpansetPipeline(_0: SyntaxNode, _1: string, _2: number): SituationType { + return { + type: 'SPANSET_PIPELINE_AFTER_OPERATOR', }; } diff --git a/public/app/plugins/datasource/tempo/traceql/traceql.ts b/public/app/plugins/datasource/tempo/traceql/traceql.ts index 74a89a56abd..0ee885433a7 100644 --- a/public/app/plugins/datasource/tempo/traceql/traceql.ts +++ b/public/app/plugins/datasource/tempo/traceql/traceql.ts @@ -27,9 +27,10 @@ export const stringOperators = ['=', '!=', '=~']; export const numberOperators = ['=', '!=', '>', '<', '>=', '<=']; export const intrinsics = ['duration', 'kind', 'name', 'status']; - export const scopes: string[] = ['resource', 'span']; +export const functions = ['avg', 'min', 'max', 'sum', 'count', 'by']; + const keywords = intrinsics.concat(scopes); const statusValues = ['ok', 'unset', 'error', 'false', 'true']; @@ -42,8 +43,8 @@ export const language = { keywords, operators, statusValues, + functions, - // we include these common regular expressions symbols: /[=>|<|>=|<=|=~|!~))/, 'tag'], - [/[a-z_.][\w./_-]*/, 'tag'], - // durations - [/[0-9.]+(s|ms|ns|m)/, 'number'], + [/[0-9]+(.[0-9]+)?(us|µs|ns|ms|s|m|h)/, 'number'], // trace ID [/^\s*[0-9A-Fa-f]+\s*$/, 'tag'], - // all keywords have the same color + // functions, keywords, predefined values [ /[a-zA-Z_.]\w*/, { cases: { - '@keywords': 'type', - '@statusValues': 'type.identifier', - '@default': 'identifier', + '@functions': 'predefined', + '@keywords': 'keyword', + '@statusValues': 'type', + '@default': 'tag', }, }, ], @@ -80,12 +78,8 @@ export const language = { [/"/, 'string', '@string_double'], [/'/, 'string', '@string_single'], - // whitespace - { include: '@whitespace' }, - // delimiters and operators - [/[{}()\[\]]/, '@brackets'], - [/[<>](?!@symbols)/, '@brackets'], + [/[{}()\[\]]/, 'delimiter.bracket'], [ /@symbols/, { @@ -118,13 +112,6 @@ export const language = { [/\\./, 'string.escape.invalid'], [/'/, 'string', '@pop'], ], - - clauses: [ - [/[^(,)]/, 'tag'], - [/\)/, 'identifier', '@pop'], - ], - - whitespace: [[/[ \t\r\n]+/, 'white']], }, }; diff --git a/yarn.lock b/yarn.lock index 4b4ac5dd635..14873fcd437 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3877,12 +3877,12 @@ __metadata: languageName: node linkType: hard -"@grafana/lezer-traceql@npm:0.0.4": - version: 0.0.4 - resolution: "@grafana/lezer-traceql@npm:0.0.4" +"@grafana/lezer-traceql@npm:0.0.5": + version: 0.0.5 + resolution: "@grafana/lezer-traceql@npm:0.0.5" peerDependencies: "@lezer/lr": ^1.3.0 - checksum: 69acea33476d3cdabfb99f3eb62bb34289bc205da69920e0eccc82f40407f9d584fa03f9662706ab667ee97d3ea84b964b22a11925e6699deef5afe1ca9e1906 + checksum: 6fcf48acde1e444c155a4b4009f4c7211843b07960713821a2649b1db0e0ef819fd1062eec101173c2e6b8249b253faf0b6052e96f551663618fd2fe0d17e3c9 languageName: node linkType: hard @@ -19226,7 +19226,7 @@ __metadata: "@grafana/faro-web-sdk": 1.1.2 "@grafana/google-sdk": 0.1.1 "@grafana/lezer-logql": 0.1.11 - "@grafana/lezer-traceql": 0.0.4 + "@grafana/lezer-traceql": 0.0.5 "@grafana/monaco-logql": ^0.0.7 "@grafana/runtime": "workspace:*" "@grafana/scenes": ^0.29.0