mirror of
https://github.com/grafana/grafana.git
synced 2024-12-23 15:40:19 -06:00
Prometheus: Performant Code Mode autocomplete with long metric names (#96580)
* fix: autocomplete performance * refactor: simplify the complex search strategy * chore: allow json imports to aid testing * perf: prefer for loop for perf-critical path * perf: use ufuzzy in intraMode: 0 (#96584) * refactor: add clarity * refactor: simplify --------- Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
This commit is contained in:
parent
9cd2598e8c
commit
466688436e
@ -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<Situation['type'], 'AT_ROOT' | 'EMPTY' | 'IN_FUNCTION'>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -6,7 +6,8 @@
|
||||
"emitDeclarationOnly": true,
|
||||
"isolatedModules": true,
|
||||
"allowJs": true,
|
||||
"rootDirs": ["."]
|
||||
"rootDirs": ["."],
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"exclude": ["dist/**/*"],
|
||||
"extends": "@grafana/tsconfig",
|
||||
|
Loading…
Reference in New Issue
Block a user