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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user