diff --git a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.test.ts b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.test.ts index 26d425befa2..32bfcb14efc 100644 --- a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.test.ts +++ b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.test.ts @@ -3,8 +3,8 @@ import { config } from '@grafana/runtime'; import { SUGGESTIONS_LIMIT } from '../../../language_provider'; import { FUNCTIONS } from '../../../promql'; -import { getCompletions } from './completions'; -import { DataProvider, DataProviderParams } from './data_provider'; +import { filterMetricNames, getCompletions } from './completions'; +import { DataProvider, type DataProviderParams } from './data_provider'; import type { Situation } from './situation'; const history: string[] = ['previous_metric_name_1', 'previous_metric_name_2', 'previous_metric_name_3']; @@ -41,6 +41,133 @@ afterEach(() => { jest.restoreAllMocks(); }); +describe('filterMetricNames', () => { + const sampleMetrics = [ + 'http_requests_total', + 'http_requests_failed', + 'node_cpu_seconds_total', + 'node_memory_usage_bytes', + 'very_long_metric_name_with_many_underscores_and_detailed_description', + 'metric_name_1_with_extra_terms_included', + ]; + + describe('empty input', () => { + it('should return all metrics up to limit when input is empty', () => { + const result = filterMetricNames({ + metricNames: sampleMetrics, + inputText: '', + limit: 3, + }); + expect(result).toEqual(sampleMetrics.slice(0, 3)); + }); + + it('should return all metrics when input is whitespace', () => { + const result = filterMetricNames({ + metricNames: sampleMetrics, + inputText: ' ', + limit: 3, + }); + expect(result).toEqual(sampleMetrics.slice(0, 3)); + }); + }); + + describe('simple searches (≤ 4 terms)', () => { + it('should match exact strings', () => { + const result = filterMetricNames({ + metricNames: sampleMetrics, + inputText: 'http_requests_total', + limit: 10, + }); + expect(result).toContainEqual('http_requests_total'); + }); + + it('should match with single character errors', () => { + // substitution + let result = filterMetricNames({ + metricNames: sampleMetrics, + inputText: 'http_requezts_total', // 's' replaced with 'z' + limit: 10, + }); + expect(result).toContainEqual('http_requests_total'); + + // ransposition + result = filterMetricNames({ + metricNames: sampleMetrics, + inputText: 'http_reqeust_total', // 'ue' swapped + limit: 10, + }); + expect(result).toContainEqual('http_requests_total'); + + // deletion + result = filterMetricNames({ + metricNames: sampleMetrics, + inputText: 'http_reqests_total', // missing 'u' + limit: 10, + }); + expect(result).toContainEqual('http_requests_total'); + + // insertion + result = filterMetricNames({ + metricNames: sampleMetrics, + inputText: 'http_reqquests_total', // extra 'q' + limit: 10, + }); + expect(result).toContainEqual('http_requests_total'); + }); + + it('should match partial strings', () => { + const result = filterMetricNames({ + metricNames: sampleMetrics, + inputText: 'requests', // partial match + limit: 10, + }); + expect(result).toContainEqual('http_requests_total'); + expect(result).toContainEqual('http_requests_failed'); + }); + + it('should not match with multiple errors', () => { + const result = filterMetricNames({ + metricNames: sampleMetrics, + inputText: 'htp_reqests_total', // two errors: missing 't' and missing 'u' + limit: 10, + }); + expect(result).not.toContainEqual('http_requests_total'); + }); + }); + + describe('complex searches (> 4 terms)', () => { + it('should use substring matching for each term', () => { + const result = filterMetricNames({ + metricNames: sampleMetrics, + inputText: 'metric name 1 with extra terms', + limit: 10, + }); + expect(result).toContainEqual('metric_name_1_with_extra_terms_included'); + }); + + it('should return empty array when no metrics match all terms', () => { + const result = filterMetricNames({ + metricNames: sampleMetrics, + inputText: 'metric name 1 with nonexistent terms', + limit: 10, + }); + expect(result).toHaveLength(0); + }); + + it('should stop searching after limit is reached', () => { + const manyMetrics = Array.from({ length: 10 }, (_, i) => `metric_name_${i}_with_terms`); + + const result = filterMetricNames({ + metricNames: manyMetrics, + inputText: 'metric name with terms other words', // > 4 terms + limit: 3, + }); + + expect(result.length).toBeLessThanOrEqual(3); + }); + }); +}); + type MetricNameSituation = Extract; const metricNameCompletionSituations = ['AT_ROOT', 'IN_FUNCTION', 'EMPTY'] as MetricNameSituation[]; @@ -74,22 +201,22 @@ describe.each(metricNameCompletionSituations)('metric name completions in situat expect(completions?.length).toBeLessThanOrEqual(expectedCompletionsCount); }); - it('should limit completions for metric names when the number of metric names is greater than the limit', async () => { + it('should limit completions for metric names when the number exceeds the limit', async () => { const situation: Situation = { type: situationType, }; const expectedCompletionsCount = getSuggestionCountForSituation(situationType, metrics.beyondLimit.length); jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValue(metrics.beyondLimit); - // No text input - dataProvider.monacoSettings.setInputInRange(''); + // Complex query + dataProvider.monacoSettings.setInputInRange('metric name one two three four five'); let completions = await getCompletions(situation, dataProvider); - expect(completions).toHaveLength(expectedCompletionsCount); + expect(completions.length).toBeLessThanOrEqual(expectedCompletionsCount); - // With text input (use fuzzy search) - dataProvider.monacoSettings.setInputInRange('name_1'); + // Simple query with fuzzy match + dataProvider.monacoSettings.setInputInRange('metric_name_'); completions = await getCompletions(situation, dataProvider); - expect(completions?.length).toBeLessThanOrEqual(expectedCompletionsCount); + expect(completions.length).toBeLessThanOrEqual(expectedCompletionsCount); }); it('should enable autocomplete suggestions update when the number of metric names is greater than the limit', async () => { @@ -115,4 +242,35 @@ describe.each(metricNameCompletionSituations)('metric name completions in situat await getCompletions(situation, dataProvider); expect(dataProvider.monacoSettings.suggestionsIncomplete).toBe(true); }); + + it('should handle complex queries efficiently', async () => { + const situation: Situation = { + type: situationType, + }; + + const testMetrics = ['metric_name_1', 'metric_name_2', 'metric_name_1_with_extra_terms', 'unrelated_metric']; + jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValue(testMetrics); + + // Test with a complex query (> 4 terms) + dataProvider.monacoSettings.setInputInRange('metric name 1 with extra terms more'); + const completions = await getCompletions(situation, dataProvider); + + const metricCompletions = completions.filter((c) => c.type === 'METRIC_NAME'); + expect(metricCompletions.some((c) => c.label === 'metric_name_1_with_extra_terms')).toBe(true); + }); + + it('should handle multiple term queries efficiently', async () => { + const situation: Situation = { + type: situationType, + }; + + jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValue(metrics.beyondLimit); + + // Test with multiple terms + dataProvider.monacoSettings.setInputInRange('metric name 1 2 3 4 5'); + const completions = await getCompletions(situation, dataProvider); + + const expectedCompletionsCount = getSuggestionCountForSituation(situationType, metrics.beyondLimit.length); + expect(completions.length).toBeLessThanOrEqual(expectedCompletionsCount); + }); }); diff --git a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.ts b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.ts index d93338c5ea5..95c16738d55 100644 --- a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.ts +++ b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.ts @@ -22,7 +22,31 @@ type Completion = { triggerOnInsert?: boolean; }; -const metricNamesSearchClient = new UFuzzy({ intraMode: 1 }); +const metricNamesSearch = { + // see https://github.com/leeoniya/uFuzzy?tab=readme-ov-file#how-it-works for details + multiInsert: new UFuzzy({ intraMode: 0 }), + singleError: new UFuzzy({ intraMode: 1 }), +}; + +interface MetricFilterOptions { + metricNames: string[]; + inputText: string; + limit: number; +} + +export function filterMetricNames({ metricNames, inputText, limit }: MetricFilterOptions): string[] { + if (!inputText?.trim()) { + return metricNames.slice(0, limit); + } + + const terms = metricNamesSearch.multiInsert.split(inputText); // e.g. 'some_metric_name or-another' -> ['some', 'metric', 'name', 'or', 'another'] + const isComplexSearch = terms.length > 4; + const fuzzyResults = isComplexSearch + ? metricNamesSearch.multiInsert.filter(metricNames, inputText) // for complex searches, prioritize performance by using MultiInsert fuzzy search + : metricNamesSearch.singleError.filter(metricNames, inputText); // for simple searches, prioritize flexibility by using SingleError fuzzy search + + return fuzzyResults ? fuzzyResults.slice(0, limit).map((idx) => metricNames[idx]) : []; +} // we order items like: history, functions, metrics function getAllMetricNamesCompletions(dataProvider: DataProvider): Completion[] { @@ -36,11 +60,11 @@ function getAllMetricNamesCompletions(dataProvider: DataProvider): Completion[] monacoSettings.enableAutocompleteSuggestionsUpdate(); if (monacoSettings.inputInRange) { - metricNames = - metricNamesSearchClient - .filter(metricNames, monacoSettings.inputInRange) - ?.slice(0, dataProvider.metricNamesSuggestionLimit) - .map((idx) => metricNames[idx]) ?? []; + metricNames = filterMetricNames({ + metricNames, + inputText: monacoSettings.inputInRange, + limit: dataProvider.metricNamesSuggestionLimit, + }); } else { metricNames = metricNames.slice(0, dataProvider.metricNamesSuggestionLimit); } diff --git a/packages/grafana-prometheus/tsconfig.json b/packages/grafana-prometheus/tsconfig.json index 169e37aca11..bfab9da4959 100644 --- a/packages/grafana-prometheus/tsconfig.json +++ b/packages/grafana-prometheus/tsconfig.json @@ -6,7 +6,8 @@ "emitDeclarationOnly": true, "isolatedModules": true, "allowJs": true, - "rootDirs": ["."] + "rootDirs": ["."], + "resolveJsonModule": true }, "exclude": ["dist/**/*"], "extends": "@grafana/tsconfig",