diff --git a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/CompletionDataProvider.test.ts b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/CompletionDataProvider.test.ts index 7c076ec3101..90a938f673f 100644 --- a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/CompletionDataProvider.test.ts +++ b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/CompletionDataProvider.test.ts @@ -88,7 +88,7 @@ describe('CompletionDataProvider', () => { }); test('Returns the expected parser and label keys', async () => { - expect(await completionProvider.getParserAndLabelKeys([])).toEqual(parserAndLabelKeys); + expect(await completionProvider.getParserAndLabelKeys('')).toEqual(parserAndLabelKeys); }); test('Returns the expected series labels', async () => { diff --git a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/CompletionDataProvider.ts b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/CompletionDataProvider.ts index 9f15bd09cdf..e3948f9d602 100644 --- a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/CompletionDataProvider.ts +++ b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/CompletionDataProvider.ts @@ -55,8 +55,8 @@ export class CompletionDataProvider { return data[labelName] ?? []; } - async getParserAndLabelKeys(labels: Label[]) { - return await this.languageProvider.getParserAndLabelKeys(this.buildSelector(labels)); + async getParserAndLabelKeys(logQuery: string) { + return await this.languageProvider.getParserAndLabelKeys(logQuery); } async getSeriesLabels(labels: Label[]) { diff --git a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/completions.test.ts b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/completions.test.ts index b1a9e8e7974..a87aa7f5be6 100644 --- a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/completions.test.ts +++ b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/completions.test.ts @@ -100,17 +100,17 @@ const afterSelectorCompletions = [ { insertText: '| unwrap extracted', label: 'unwrap extracted', - type: 'LINE_FILTER', + type: 'PIPE_OPERATION', }, { insertText: '| unwrap place', label: 'unwrap place', - type: 'LINE_FILTER', + type: 'PIPE_OPERATION', }, { insertText: '| unwrap source', label: 'unwrap source', - type: 'LINE_FILTER', + type: 'PIPE_OPERATION', }, { insertText: '| unwrap', @@ -134,18 +134,13 @@ const afterSelectorCompletions = [ }, ]; -function buildAfterSelectorCompletions( - detectedParser: string, - detectedParserType: string, - otherParser: string, - afterPipe: boolean -) { +function buildAfterSelectorCompletions(detectedParser: string, otherParser: string, afterPipe: boolean) { const explanation = '(detected)'; const expectedCompletions = afterSelectorCompletions.map((completion) => { if (completion.type === 'DETECTED_PARSER_PLACEHOLDER') { return { ...completion, - type: detectedParserType, + type: 'PARSER', label: `${detectedParser} ${explanation}`, insertText: `| ${detectedParser}`, }; @@ -200,8 +195,8 @@ describe('getCompletions', () => { expect(completions).toHaveLength(24); }); - test('Returns completion options when the situation is IN_DURATION', async () => { - const situation: Situation = { type: 'IN_DURATION' }; + test('Returns completion options when the situation is IN_RANGE', async () => { + const situation: Situation = { type: 'IN_RANGE' }; const completions = await getCompletions(situation, completionProvider); expect(completions).toEqual([ @@ -217,7 +212,7 @@ describe('getCompletions', () => { }); test('Returns completion options when the situation is IN_GROUPING', async () => { - const situation: Situation = { type: 'IN_GROUPING', otherLabels }; + const situation: Situation = { type: 'IN_GROUPING', logQuery: '' }; const completions = await getCompletions(situation, completionProvider); expect(completions).toEqual([ @@ -311,33 +306,33 @@ describe('getCompletions', () => { }); test.each([true, false])( - 'Returns completion options when the situation is AFTER_SELECTOR, JSON parser, and afterPipe %s', + 'Returns completion options when the situation is AFTER_SELECTOR, detected JSON parser, and afterPipe %s', async (afterPipe: boolean) => { jest.spyOn(completionProvider, 'getParserAndLabelKeys').mockResolvedValue({ extractedLabelKeys, hasJSON: true, hasLogfmt: false, }); - const situation: Situation = { type: 'AFTER_SELECTOR', labels: [], afterPipe }; + const situation: Situation = { type: 'AFTER_SELECTOR', logQuery: '', afterPipe }; const completions = await getCompletions(situation, completionProvider); - const expected = buildAfterSelectorCompletions('json', 'PARSER', 'logfmt', afterPipe); + const expected = buildAfterSelectorCompletions('json', 'logfmt', afterPipe); expect(completions).toEqual(expected); } ); test.each([true, false])( - 'Returns completion options when the situation is AFTER_SELECTOR, Logfmt parser, and afterPipe %s', + 'Returns completion options when the situation is AFTER_SELECTOR, detected Logfmt parser, and afterPipe %s', async (afterPipe: boolean) => { jest.spyOn(completionProvider, 'getParserAndLabelKeys').mockResolvedValue({ extractedLabelKeys, hasJSON: false, hasLogfmt: true, }); - const situation: Situation = { type: 'AFTER_SELECTOR', labels: [], afterPipe }; + const situation: Situation = { type: 'AFTER_SELECTOR', logQuery: '', afterPipe }; const completions = await getCompletions(situation, completionProvider); - const expected = buildAfterSelectorCompletions('logfmt', 'DURATION', 'json', afterPipe); + const expected = buildAfterSelectorCompletions('logfmt', 'json', afterPipe); expect(completions).toEqual(expected); } ); @@ -348,4 +343,15 @@ describe('getCompletions', () => { expect(completions).toHaveLength(22); }); + + test('Returns completion options when the situation is AFTER_UNWRAP', async () => { + const situation: Situation = { type: 'AFTER_UNWRAP', logQuery: '' }; + const completions = await getCompletions(situation, completionProvider); + + const extractedCompletions = completions.filter((completion) => completion.type === 'LABEL_NAME'); + const functionCompletions = completions.filter((completion) => completion.type === 'FUNCTION'); + + expect(extractedCompletions).toHaveLength(3); + expect(functionCompletions).toHaveLength(3); + }); }); diff --git a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/completions.ts b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/completions.ts index 65b83d8915c..2f8e0a83ca5 100644 --- a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/completions.ts +++ b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/completions.ts @@ -66,6 +66,27 @@ const DURATION_COMPLETIONS: Completion[] = ['$__interval', '$__range', '1m', '5m }) ); +const UNWRAP_FUNCTION_COMPLETIONS: Completion[] = [ + { + type: 'FUNCTION', + label: 'duration_seconds', + documentation: 'Will convert the label value in seconds from the go duration format (e.g 5m, 24s30ms).', + insertText: 'duration_seconds()', + }, + { + type: 'FUNCTION', + label: 'duration', + documentation: 'Short version of duration_seconds().', + insertText: 'duration()', + }, + { + type: 'FUNCTION', + label: 'bytes', + documentation: 'Will convert the label value to raw bytes applying the bytes unit (e.g. 5 MiB, 3k, 1G).', + insertText: 'bytes()', + }, +]; + const LINE_FILTER_COMPLETIONS = [ { operator: '|=', @@ -123,11 +144,8 @@ async function getLabelNamesForSelectorCompletions( })); } -async function getInGroupingCompletions( - otherLabels: Label[], - dataProvider: CompletionDataProvider -): Promise { - const { extractedLabelKeys } = await dataProvider.getParserAndLabelKeys(otherLabels); +async function getInGroupingCompletions(logQuery: string, dataProvider: CompletionDataProvider): Promise { + const { extractedLabelKeys } = await dataProvider.getParserAndLabelKeys(logQuery); return extractedLabelKeys.map((label) => ({ type: 'LABEL_NAME', @@ -139,16 +157,17 @@ async function getInGroupingCompletions( const PARSERS = ['json', 'logfmt', 'pattern', 'regexp', 'unpack']; -async function getAfterSelectorCompletions( - labels: Label[], +async function getParserCompletions( afterPipe: boolean, - dataProvider: CompletionDataProvider -): Promise { - const { extractedLabelKeys, hasJSON, hasLogfmt } = await dataProvider.getParserAndLabelKeys(labels); + hasJSON: boolean, + hasLogfmt: boolean, + extractedLabelKeys: string[] +) { const allParsers = new Set(PARSERS); const completions: Completion[] = []; const prefix = afterPipe ? ' ' : '| '; const hasLevelInExtractedLabels = extractedLabelKeys.some((key) => key === 'level'); + if (hasJSON) { allParsers.delete('json'); const extra = hasLevelInExtractedLabels ? '' : ' (detected)'; @@ -166,7 +185,7 @@ async function getAfterSelectorCompletions( allParsers.delete('logfmt'); const extra = hasLevelInExtractedLabels ? '' : ' (detected)'; completions.push({ - type: 'DURATION', + type: 'PARSER', label: `logfmt${extra}`, insertText: `${prefix}logfmt`, documentation: hasLevelInExtractedLabels @@ -185,9 +204,23 @@ async function getAfterSelectorCompletions( }); }); + return completions; +} + +async function getAfterSelectorCompletions( + logQuery: string, + afterPipe: boolean, + dataProvider: CompletionDataProvider +): Promise { + const { extractedLabelKeys, hasJSON, hasLogfmt } = await dataProvider.getParserAndLabelKeys(logQuery); + + const completions: Completion[] = await getParserCompletions(afterPipe, hasJSON, hasLogfmt, extractedLabelKeys); + + const prefix = afterPipe ? ' ' : '| '; + extractedLabelKeys.forEach((key) => { completions.push({ - type: 'LINE_FILTER', + type: 'PIPE_OPERATION', label: `unwrap ${key}`, insertText: `${prefix}unwrap ${key}`, }); @@ -216,7 +249,9 @@ async function getAfterSelectorCompletions( documentation: explainOperator(LokiOperationId.LabelFormat), }); - return [...getLineFilterCompletions(afterPipe), ...completions]; + const lineFilters = getLineFilterCompletions(afterPipe); + + return [...lineFilters, ...completions]; } async function getLabelValuesForMetricCompletions( @@ -233,6 +268,22 @@ async function getLabelValuesForMetricCompletions( })); } +async function getAfterUnwrapCompletions( + logQuery: string, + dataProvider: CompletionDataProvider +): Promise { + const { extractedLabelKeys } = await dataProvider.getParserAndLabelKeys(logQuery); + + const labelCompletions: Completion[] = extractedLabelKeys.map((label) => ({ + type: 'LABEL_NAME', + label, + insertText: label, + triggerOnInsert: false, + })); + + return [...labelCompletions, ...UNWRAP_FUNCTION_COMPLETIONS]; +} + export async function getCompletions( situation: Situation, dataProvider: CompletionDataProvider @@ -242,10 +293,10 @@ export async function getCompletions( case 'AT_ROOT': const historyCompletions = await getAllHistoryCompletions(dataProvider); return [...historyCompletions, ...LOG_COMPLETIONS, ...AGGREGATION_COMPLETIONS, ...FUNCTION_COMPLETIONS]; - case 'IN_DURATION': + case 'IN_RANGE': return DURATION_COMPLETIONS; case 'IN_GROUPING': - return getInGroupingCompletions(situation.otherLabels, dataProvider); + return getInGroupingCompletions(situation.logQuery, dataProvider); case 'IN_LABEL_SELECTOR_NO_LABEL_NAME': return getLabelNamesForSelectorCompletions(situation.otherLabels, dataProvider); case 'IN_LABEL_SELECTOR_WITH_LABEL_NAME': @@ -256,7 +307,9 @@ export async function getCompletions( dataProvider ); case 'AFTER_SELECTOR': - return getAfterSelectorCompletions(situation.labels, situation.afterPipe, dataProvider); + return getAfterSelectorCompletions(situation.logQuery, situation.afterPipe, dataProvider); + case 'AFTER_UNWRAP': + return getAfterUnwrapCompletions(situation.logQuery, dataProvider); case 'IN_AGGREGATION': return [...FUNCTION_COMPLETIONS, ...AGGREGATION_COMPLETIONS]; default: diff --git a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/situation.test.ts b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/situation.test.ts index 5c10d615e22..41aa1c50d0b 100644 --- a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/situation.test.ts +++ b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/situation.test.ts @@ -26,19 +26,23 @@ function assertSituation(situation: string, expectedSituation: Situation | null) } describe('situation', () => { - it('handles things', () => { + it('identifies EMPTY autocomplete situations', () => { assertSituation('^', { type: 'EMPTY', }); + }); + it('identifies EMPTY autocomplete situations', () => { assertSituation('s^', { type: 'AT_ROOT', }); + }); + it('identifies AFTER_SELECTOR autocomplete situations', () => { assertSituation('{level="info"} ^', { type: 'AFTER_SELECTOR', afterPipe: false, - labels: [{ name: 'level', value: 'info', op: '=' }], + logQuery: '{level="info"}', }); // should not trigger AFTER_SELECTOR before the selector @@ -50,19 +54,19 @@ describe('situation', () => { assertSituation('{level="info"} | json ^', { type: 'AFTER_SELECTOR', afterPipe: false, - labels: [{ name: 'level', value: 'info', op: '=' }], + logQuery: '{level="info"} | json', }); assertSituation('{level="info"} | json | ^', { type: 'AFTER_SELECTOR', afterPipe: true, - labels: [{ name: 'level', value: 'info', op: '=' }], + logQuery: '{level="info"} | json |', }); assertSituation('count_over_time({level="info"}^[10s])', { type: 'AFTER_SELECTOR', afterPipe: false, - labels: [{ name: 'level', value: 'info', op: '=' }], + logQuery: '{level="info"}', }); // should not trigger AFTER_SELECTOR before the selector @@ -72,28 +76,49 @@ describe('situation', () => { assertSituation('count_over_time({level="info"}^)', { type: 'AFTER_SELECTOR', afterPipe: false, - labels: [{ name: 'level', value: 'info', op: '=' }], + logQuery: '{level="info"}', }); + assertSituation('{level="info"} |= "a" | logfmt ^', { + type: 'AFTER_SELECTOR', + afterPipe: false, + logQuery: '{level="info"} |= "a" | logfmt', + }); + + assertSituation('sum(count_over_time({place="luna"} | logfmt |^)) by (place)', { + type: 'AFTER_SELECTOR', + afterPipe: true, + logQuery: '{place="luna"}| logfmt |', + }); + }); + + it('identifies IN_AGGREGATION autocomplete situations', () => { assertSituation('sum(^)', { type: 'IN_AGGREGATION', }); }); - it('handles label names', () => { + it('identifies IN_LABEL_SELECTOR_NO_LABEL_NAME autocomplete situations', () => { assertSituation('{^}', { type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', otherLabels: [], }); + assertSituation('sum({^})', { + type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', + otherLabels: [], + }); + }); + + it('identifies labels from queries', () => { assertSituation('sum(count_over_time({level="info"})) by (^)', { type: 'IN_GROUPING', - otherLabels: [{ name: 'level', value: 'info', op: '=' }], + logQuery: '{level="info"}', }); assertSituation('sum by (^) (count_over_time({level="info"}))', { type: 'IN_GROUPING', - otherLabels: [{ name: 'level', value: 'info', op: '=' }], + logQuery: '{level="info"}', }); assertSituation('{one="val1",two!="val2",three=~"val3",four!~"val4",^}', { @@ -124,6 +149,35 @@ describe('situation', () => { }); }); + it('identifies AFTER_UNWRAP autocomplete situations', () => { + assertSituation('sum(sum_over_time({one="val1"} | unwrap^', { + type: 'AFTER_UNWRAP', + logQuery: '{one="val1"}', + }); + + assertSituation( + 'quantile_over_time(0.99, {cluster="ops-tools1",container="ingress-nginx"} | json | __error__ = "" | unwrap ^', + { + type: 'AFTER_UNWRAP', + logQuery: '{cluster="ops-tools1",container="ingress-nginx"}| json | __error__ = ""', + } + ); + + assertSituation('sum(sum_over_time({place="luna"} | unwrap ^ [5m])) by (level)', { + type: 'AFTER_UNWRAP', + logQuery: '{place="luna"}', + }); + }); + + it.each(['count_over_time({job="mysql"}[^])', 'rate({instance="server\\1"}[^])', 'rate({}[^'])( + 'identifies IN_RANGE autocomplete situations in metric query %s', + (query: string) => { + assertSituation(query, { + type: 'IN_RANGE', + }); + } + ); + it('handles label values', () => { assertSituation('{job=^}', { type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', diff --git a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/situation.ts b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/situation.ts index e43ae1c8a2c..2eef2f782fe 100644 --- a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/situation.ts +++ b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/situation.ts @@ -19,8 +19,11 @@ import { Expr, LiteralExpr, MetricExpr, + UnwrapExpr, } from '@grafana/lezer-logql'; +import { getLogQueryFromMetricsQuery } from '../../../queryUtils'; + type Direction = 'parent' | 'firstChild' | 'lastChild' | 'nextSibling'; type NodeType = number; @@ -94,14 +97,14 @@ export type Situation = type: 'AT_ROOT'; } | { - type: 'IN_DURATION'; + type: 'IN_RANGE'; } | { type: 'IN_AGGREGATION'; } | { type: 'IN_GROUPING'; - otherLabels: Label[]; + logQuery: string; } | { type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME'; @@ -116,7 +119,11 @@ export type Situation = | { type: 'AFTER_SELECTOR'; afterPipe: boolean; - labels: Label[]; + logQuery: string; + } + | { + type: 'AFTER_UNWRAP'; + logQuery: string; }; type Resolver = { @@ -164,13 +171,21 @@ const RESOLVERS: Resolver[] = [ fun: resolveLogRangeFromError, }, { - path: [ERROR_NODE_ID, LiteralExpr, MetricExpr, VectorAggregationExpr, MetricExpr, Expr, LogQL], + path: [ERROR_NODE_ID, LiteralExpr, MetricExpr, VectorAggregationExpr], fun: () => ({ type: 'IN_AGGREGATION' }), }, { path: [ERROR_NODE_ID, PipelineStage, PipelineExpr], fun: resolvePipeError, }, + { + path: [ERROR_NODE_ID, UnwrapExpr], + fun: resolveAfterUnwrap, + }, + { + path: [UnwrapExpr], + fun: resolveAfterUnwrap, + }, ]; const LABEL_OP_MAP = new Map([ @@ -248,6 +263,13 @@ function getLabels(selectorNode: SyntaxNode, text: string): Label[] { return labels; } +function resolveAfterUnwrap(node: SyntaxNode, text: string, pos: number): Situation | null { + return { + type: 'AFTER_UNWRAP', + logQuery: getLogQueryFromMetricsQuery(text).trim(), + }; +} + function resolvePipeError(node: SyntaxNode, text: string, pos: number): Situation | null { // for example `{level="info"} |` const exprNode = walk(node, [ @@ -292,11 +314,9 @@ function resolveLabelsForGrouping(node: SyntaxNode, text: string, pos: number): return null; } - const otherLabels = getLabels(selectorNode, text); - return { type: 'IN_GROUPING', - otherLabels, + logQuery: getLogQueryFromMetricsQuery(text).trim(), }; } @@ -400,7 +420,7 @@ function resolveTopLevel(node: SyntaxNode, text: string, pos: number): Situation function resolveDurations(node: SyntaxNode, text: string, pos: number): Situation { return { - type: 'IN_DURATION', + type: 'IN_RANGE', }; } @@ -418,21 +438,20 @@ function resolveLogRangeFromError(node: SyntaxNode, text: string, pos: number): } function resolveLogOrLogRange(node: SyntaxNode, text: string, pos: number, afterPipe: boolean): Situation | null { - // here the `node` is either a LogExpr or a LogRangeExpr - // we want to handle the case where we are next to a selector + // Here the `node` is either a LogExpr or a LogRangeExpr + // We want to handle the case where we are next to a selector const selectorNode = walk(node, [['firstChild', Selector]]); - // we check that the selector is before the cursor, not after it - if (selectorNode != null && selectorNode.to <= pos) { - const labels = getLabels(selectorNode, text); - return { - type: 'AFTER_SELECTOR', - afterPipe, - labels, - }; + // Check that the selector is before the cursor, not after it + if (!selectorNode || selectorNode.to > pos) { + return null; } - return null; + return { + type: 'AFTER_SELECTOR', + afterPipe, + logQuery: getLogQueryFromMetricsQuery(text).trim(), + }; } function resolveSelector(node: SyntaxNode, text: string, pos: number): Situation | null { diff --git a/public/app/plugins/datasource/loki/queryUtils.test.ts b/public/app/plugins/datasource/loki/queryUtils.test.ts index 449d30d5ae2..4b36192b8af 100644 --- a/public/app/plugins/datasource/loki/queryUtils.test.ts +++ b/public/app/plugins/datasource/loki/queryUtils.test.ts @@ -6,6 +6,7 @@ import { isQueryWithParser, isValidQuery, parseToNodeNamesArray, + getParserFromQuery, } from './queryUtils'; import { LokiQuery, LokiQueryType } from './types'; @@ -249,3 +250,16 @@ describe('isQueryWithLabelFormat', () => { expect(isQueryWithLabelFormat('rate({job="grafana"} [5m])')).toBe(false); }); }); + +describe('getParserFromQuery', () => { + it('returns no parser', () => { + expect(getParserFromQuery('{job="grafana"}')).toBeUndefined(); + }); + + it.each(['json', 'logfmt', 'pattern', 'regexp', 'unpack'])('detects %s parser', (parser: string) => { + expect(getParserFromQuery(`{job="grafana"} | ${parser}`)).toBe(parser); + expect(getParserFromQuery(`sum(count_over_time({place="luna"} | ${parser} | unwrap counter )) by (place)`)).toBe( + parser + ); + }); +}); diff --git a/public/app/plugins/datasource/loki/queryUtils.ts b/public/app/plugins/datasource/loki/queryUtils.ts index 6b5c69777f0..b0997d9cc74 100644 --- a/public/app/plugins/datasource/loki/queryUtils.ts +++ b/public/app/plugins/datasource/loki/queryUtils.ts @@ -158,6 +158,21 @@ export function isQueryWithParser(query: string): { queryWithParser: boolean; pa return { queryWithParser: parserCount > 0, parserCount }; } +export function getParserFromQuery(query: string) { + const tree = parser.parse(query); + let logParser; + tree.iterate({ + enter: (node: SyntaxNode): false | void => { + if (node.type.id === LabelParser || node.type.id === JsonExpressionParser) { + logParser = query.substring(node.from, node.to).trim(); + return false; + } + }, + }); + + return logParser; +} + export function isQueryPipelineErrorFiltering(query: string): boolean { let isQueryPipelineErrorFiltering = false; const tree = parser.parse(query);