From fe2d9ce16a4180d0cc0831d18910f8962c2a9bd7 Mon Sep 17 00:00:00 2001 From: Alex Bikfalvi Date: Fri, 22 Aug 2025 09:42:28 +0200 Subject: [PATCH] feat(Tempo): Syntax and autocompletion for compare and with (#108824) Support syntax highlighting and autocompletion for: * `compare(...)` TraceQL metrics function * `with(...)` query hint Partially fixes: https://github.com/grafana/grafana/issues/103764 Signed-off-by: Alex Bikfalvi --- .../tempo/traceql/autocomplete.test.ts | 114 ++++++++++++++++-- .../datasource/tempo/traceql/autocomplete.ts | 61 +++++++++- .../tempo/traceql/situation.test.ts | 36 ++++++ .../datasource/tempo/traceql/situation.ts | 25 ++++ .../datasource/tempo/traceql/traceql.test.ts | 5 + .../datasource/tempo/traceql/traceql.ts | 1 + 6 files changed, 233 insertions(+), 9 deletions(-) diff --git a/public/app/plugins/datasource/tempo/traceql/autocomplete.test.ts b/public/app/plugins/datasource/tempo/traceql/autocomplete.test.ts index 91b534f120c..5e23fe5e810 100644 --- a/public/app/plugins/datasource/tempo/traceql/autocomplete.test.ts +++ b/public/app/plugins/datasource/tempo/traceql/autocomplete.test.ts @@ -211,7 +211,9 @@ describe('CompletionProvider', () => { 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 })) + expect.arrayContaining( + CompletionProvider.spansetOps.map((s) => expect.objectContaining({ label: s.label, insertText: s.insertText })) + ) ); }); @@ -237,6 +239,38 @@ describe('CompletionProvider', () => { } ); + it('suggests compare function in pipeline operators', async () => { + const { provider, model } = setup('{.foo=300} | ', 13); + const result = await provider.provideCompletionItems(model, emptyPosition); + const suggestions = (result! as monacoTypes.languages.CompletionList).suggestions; + + expect(suggestions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + label: 'compare', + insertText: 'compare($0)', + documentation: expect.stringContaining('Splits spans into two groups'), + }), + ]) + ); + }); + + it('suggests with keyword after spanset completion', async () => { + const { provider, model } = setup('{.foo=300} ', 11); + const result = await provider.provideCompletionItems(model, emptyPosition); + const suggestions = (result! as monacoTypes.languages.CompletionList).suggestions; + + expect(suggestions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + label: 'with', + insertText: 'with($0)', + documentation: expect.stringContaining('query hints'), + }), + ]) + ); + }); + it.each([ ['{.foo=300} | avg(.value) ', 25], ['{.foo=300} && {.foo=300} | avg(.value) ', 39], @@ -325,13 +359,15 @@ describe('CompletionProvider', () => { 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, - }) + expect.arrayContaining( + CompletionProvider.spansetOps.map((completionItem) => + expect.objectContaining({ + detail: completionItem.detail, + documentation: completionItem.documentation, + insertText: completionItem.insertText, + label: completionItem.label, + }) + ) ) ); } @@ -385,6 +421,68 @@ describe('CompletionProvider', () => { ]); } ); + + describe('Query hint autocompletion', () => { + it('suggests most_recent parameter inside with clause', async () => { + const { provider, model } = setup('{.foo=300} with(', 17); + const result = await provider.provideCompletionItems(model, emptyPosition); + const suggestions = (result! as monacoTypes.languages.CompletionList).suggestions; + + expect(suggestions).toEqual([ + expect.objectContaining({ + label: 'most_recent', + insertText: 'most_recent=$0', + detail: 'Get latest traces', + documentation: expect.stringContaining('Forces Tempo to return the most recent results'), + }), + ]); + }); + + it('suggests boolean values after most_recent parameter', async () => { + const { provider, model } = setup('{.foo=300} with(most_recent=', 29); + const result = await provider.provideCompletionItems(model, emptyPosition); + const suggestions = (result! as monacoTypes.languages.CompletionList).suggestions; + + expect(suggestions).toEqual([ + expect.objectContaining({ + label: 'true', + insertText: 'true', + detail: 'Boolean true', + }), + expect.objectContaining({ + label: 'false', + insertText: 'false', + detail: 'Boolean false', + }), + ]); + }); + + it('suggests most_recent parameter with whitespace variations', async () => { + const { provider, model } = setup('{.foo=300} with( ', 18); + const result = await provider.provideCompletionItems(model, emptyPosition); + const suggestions = (result! as monacoTypes.languages.CompletionList).suggestions; + + expect(suggestions).toEqual([ + expect.objectContaining({ + label: 'most_recent', + insertText: 'most_recent=$0', + }), + ]); + }); + + it('suggests boolean values with whitespace around equals', async () => { + const { provider, model } = setup('{.foo=300} with(most_recent = ', 31); + const result = await provider.provideCompletionItems(model, emptyPosition); + const suggestions = (result! as monacoTypes.languages.CompletionList).suggestions; + + expect(suggestions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ label: 'true', insertText: 'true' }), + expect.objectContaining({ label: 'false', insertText: 'false' }), + ]) + ); + }); + }); }); function setup(value: string, offset: number, tagsV1?: string[], tagsV2?: Scope[]) { diff --git a/public/app/plugins/datasource/tempo/traceql/autocomplete.ts b/public/app/plugins/datasource/tempo/traceql/autocomplete.ts index 68af850bc7d..a0b22239cfe 100644 --- a/public/app/plugins/datasource/tempo/traceql/autocomplete.ts +++ b/public/app/plugins/datasource/tempo/traceql/autocomplete.ts @@ -297,6 +297,13 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP detail: 'Grouping of attributes', documentation: 'Groups by arbitrary attributes.', }, + { + label: 'compare', + insertText: 'compare($0)', + detail: 'Compare span groups', + documentation: + 'Splits spans into two groups (selection and baseline) and returns time-series for all attributes to highlight differences. First parameter is a spanset filter for the selection group (e.g., {status=error}). Optional parameters: topN limit (default 10), start timestamp, end timestamp.', + }, { label: 'count_over_time', insertText: 'count_over_time()$0', @@ -353,6 +360,41 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP }, ]; + // Query hints + static readonly queryHints: MinimalCompletionItem[] = [ + { + label: 'with', + insertText: 'with($0)', + detail: 'Query hints', + documentation: + 'Provides query hints to modify search behavior. Use with parameters like most_recent=true to get the latest traces.', + }, + ]; + + static readonly withParameters: MinimalCompletionItem[] = [ + { + label: 'most_recent', + insertText: 'most_recent=$0', + detail: 'Get latest traces', + documentation: + 'Forces Tempo to return the most recent results ordered by time. Use most_recent=true to see the freshest data when troubleshooting incidents.', + }, + // Future parameters can be added here as simple objects + ]; + + static readonly withValues: MinimalCompletionItem[] = [ + { + label: 'true', + insertText: 'true', + detail: 'Boolean true', + }, + { + label: 'false', + insertText: 'false', + detail: 'Boolean false', + }, + ]; + // We set these directly and ae required for the provider to function. monaco: Monaco | undefined; editor: monacoTypes.editor.IStandaloneCodeEditor | undefined; @@ -375,6 +417,7 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP } const { range, offset } = getRangeAndOffset(this.monaco, model, position); + const situation = getSituation(model.getValue(), offset); const completionItems = situation != null ? this.getCompletions(situation, this.setAlertText) : Promise.resolve([]); @@ -461,7 +504,12 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP ...CompletionProvider.comparisonOps, ]); case 'SPANSET_COMBINING_OPERATORS': - return this.getOperatorsCompletions(CompletionProvider.spansetOps); + const withKeywords = CompletionProvider.queryHints.map((key) => ({ + ...key, + insertTextRules: languages.CompletionItemInsertTextRule.InsertAsSnippet, + type: 'KEYWORD' as const, + })); + return [...this.getOperatorsCompletions(CompletionProvider.spansetOps), ...withKeywords]; case 'SPANSET_PIPELINE_AFTER_OPERATOR': const functions = CompletionProvider.functions.map((key) => ({ ...key, @@ -517,6 +565,17 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP .concat(this.getTagsCompletions('.')); case 'ATTRIBUTE_FOR_FUNCTION': return this.getScopesCompletions().concat(this.getIntrinsicsCompletions()).concat(this.getTagsCompletions('.')); + case 'QUERY_HINT_NAME': + return CompletionProvider.withParameters.map((key) => ({ + ...key, + type: 'TAG_NAME' as const, + insertTextRules: languages.CompletionItemInsertTextRule.InsertAsSnippet, + })); + case 'QUERY_HINT_VALUE': + return CompletionProvider.withValues.map((key) => ({ + ...key, + type: 'TAG_VALUE' as const, + })); default: throw new Error(`Unexpected situation ${situation}`); } diff --git a/public/app/plugins/datasource/tempo/traceql/situation.test.ts b/public/app/plugins/datasource/tempo/traceql/situation.test.ts index 8f6dc0da1da..109d401cb50 100644 --- a/public/app/plugins/datasource/tempo/traceql/situation.test.ts +++ b/public/app/plugins/datasource/tempo/traceql/situation.test.ts @@ -72,6 +72,42 @@ describe('situation', () => { cursorPos: 57, expected: { type: 'SPANSET_EXPRESSION_OPERATORS' }, }, + // Query hint situations + { + query: '{.foo=300} with(', + cursorPos: 16, + expected: { type: 'QUERY_HINT_NAME' }, + }, + { + query: '{.foo=300} with( ', + cursorPos: 17, + expected: { type: 'QUERY_HINT_NAME' }, + }, + { + query: '{.foo=300} with(most_recent=', + cursorPos: 28, + expected: { type: 'QUERY_HINT_VALUE' }, + }, + { + query: '{.foo=300} with(most_recent= ', + cursorPos: 29, + expected: { type: 'QUERY_HINT_VALUE' }, + }, + { + query: '{.foo=300} with(most_recent=true', + cursorPos: 32, + expected: { type: 'QUERY_HINT_VALUE' }, + }, + { + query: '{} with(', + cursorPos: 8, + expected: { type: 'QUERY_HINT_NAME' }, + }, + { + query: '{} with(most_recent=', + cursorPos: 20, + expected: { type: 'QUERY_HINT_VALUE' }, + }, ]; tests.forEach((test) => { diff --git a/public/app/plugins/datasource/tempo/traceql/situation.ts b/public/app/plugins/datasource/tempo/traceql/situation.ts index 65259a7a9ab..be75cba0553 100644 --- a/public/app/plugins/datasource/tempo/traceql/situation.ts +++ b/public/app/plugins/datasource/tempo/traceql/situation.ts @@ -82,6 +82,12 @@ export type SituationType = } | { type: 'SPANSET_COMPARISON_OPERATORS'; + } + | { + type: 'QUERY_HINT_NAME'; + } + | { + type: 'QUERY_HINT_VALUE'; }; type Path = Array<[Direction, NodeType[]]>; @@ -150,6 +156,25 @@ export function getSituation(text: string, offset: number): Situation | null { }; } + // Check for with clause hint situations first + const textUpToOffset = text.substring(0, offset); + + // Check if we're inside with(...) waiting for parameter names + if (/\bwith\s*\(\s*$/.test(textUpToOffset)) { + return { + query: text, + type: 'QUERY_HINT_NAME', + }; + } + + // Check if we're after parameter= waiting for values + if (/\bwith\s*\(\s*\w+\s*=\s*[\w]*$/.test(textUpToOffset)) { + return { + query: text, + type: 'QUERY_HINT_VALUE', + }; + } + const tree = parser.parse(text); // Whitespaces (especially when multiple) on the left of the text cursor can trick the Lezer parser, diff --git a/public/app/plugins/datasource/tempo/traceql/traceql.test.ts b/public/app/plugins/datasource/tempo/traceql/traceql.test.ts index 8f91d637950..b488e085cdf 100644 --- a/public/app/plugins/datasource/tempo/traceql/traceql.test.ts +++ b/public/app/plugins/datasource/tempo/traceql/traceql.test.ts @@ -26,6 +26,11 @@ describe('TraceQL grammar', () => { expect(withClauseKeywords).toContain('with'); expect(withParameters).toContain('most_recent'); }); + + it('should include compare function in the functions list', () => { + const { functions } = languageDefinition.def.language; + expect(functions).toContain('compare'); + }); }); describe('Operators', () => { diff --git a/public/app/plugins/datasource/tempo/traceql/traceql.ts b/public/app/plugins/datasource/tempo/traceql/traceql.ts index eeb7d52101f..074b86d9af0 100644 --- a/public/app/plugins/datasource/tempo/traceql/traceql.ts +++ b/public/app/plugins/datasource/tempo/traceql/traceql.ts @@ -64,6 +64,7 @@ export const enumIntrinsics = ['kind', 'span:kind', 'status', 'span:status']; const aggregatorFunctions = ['avg', 'count', 'max', 'min', 'sum']; const functions = aggregatorFunctions.concat([ 'by', + 'compare', 'count_over_time', 'min_over_time', 'max_over_time',