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 <alex.bikfalvi@grafana.com>
This commit is contained in:
Alex Bikfalvi
2025-08-22 09:42:28 +02:00
committed by GitHub
parent 6822bea9ed
commit fe2d9ce16a
6 changed files with 233 additions and 9 deletions
@@ -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[]) {
@@ -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}`);
}
@@ -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) => {
@@ -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,
@@ -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', () => {
@@ -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',