Prometheus: Fuzzy search for metric names in Code Mode (#85396)

* perf: limit metric names in Code Mode suggestions

* feat: configurable metric names limit

* feat: code mode autocomplete info/disclaimer

* chore: put new functionality behind new feature toggle

* refactor: avoid type assertions

* refactor: avoid explicit `any`

refactor: type guards

* refactor: type guards

* chore: add testdata results

* fix: add missing feature toggle guard

* perf: prefer array access to `Array.prototype.at`

* test: add missing config override

* test: refactor for brevity & clarity

* perf: avoid unnecessary mapping

* chore: undo testdata changes

* fix: use correct limit; perf optimizations

* refactor: avoid unnecessary `async`s

* types: simplify

* test: add missing tests

* fix: avoid hardcoding

* test: update mock path

* docs: fix typo

style: remove formatting artifact

style: remove formatting artifact

style: remove formatting artifact

* fix: event scope regression

* style: refactor for clarity

* refactor: prefer `useCallback` to in-effect handler

* refactor: simplify & broaden `filter`

* refactor: rename file to keep with conventions

* chore: mirror Prometheus package changes in app

* refactor: prefer no `@ts-ignore`

* chore: update betterer results

* docs: use type in TSDoc `@link` without `@ts-ignore`

* test: add missing provider

* test: fix jest mock path

* fix: display disclaimer in empty input case
This commit is contained in:
Nick Richmond
2024-04-04 23:38:23 +03:00
committed by GitHub
parent 0d7834c7f7
commit 559fab9dc6
32 changed files with 1292 additions and 132 deletions
+4
View File
@@ -6442,6 +6442,8 @@ exports[`no gf-form usage`] = {
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"]
],
"packages/grafana-prometheus/src/querybuilder/components/PromQueryCodeEditor.tsx:5381": [
@@ -6965,6 +6967,8 @@ exports[`no gf-form usage`] = {
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"]
],
"public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryCodeEditor.tsx:5381": [
@@ -153,6 +153,7 @@ Experimental features might be changed or removed without prior notice.
| `kubernetesSnapshots` | Routes snapshot requests from /api to the /apis endpoint |
| `cachingOptimizeSerializationMemoryUsage` | If enabled, the caching backend gradually serializes query responses for the cache, comparing against the configured `[caching]max_value_mb` value as it goes. This can can help prevent Grafana from running out of memory while attempting to cache very large query responses. |
| `prometheusPromQAIL` | Prometheus and AI/ML to assist users in creating a query |
| `prometheusCodeModeMetricNamesSearch` | Enables search for metric names in Code Mode, to improve performance when working with an enormous number of metric names |
| `alertmanagerRemoteSecondary` | Enable Grafana to sync configuration and state with a remote Alertmanager. |
| `alertmanagerRemotePrimary` | Enable Grafana to have a remote Alertmanager instance as the primary Alertmanager. |
| `alertmanagerRemoteOnly` | Disable the internal Alertmanager and only use the external one defined. |
@@ -133,6 +133,7 @@ export interface FeatureToggles {
panelTitleSearchInV1?: boolean;
managedPluginsInstall?: boolean;
prometheusPromQAIL?: boolean;
prometheusCodeModeMetricNamesSearch?: boolean;
addFieldFromCalculationStatFunctions?: boolean;
alertmanagerRemoteSecondary?: boolean;
alertmanagerRemotePrimary?: boolean;
@@ -78,6 +78,7 @@ export const Components = {
httpMethod: 'data-testid http method',
exemplarsAddButton: 'data-testid Add exemplar config button',
internalLinkSwitch: 'data-testid Internal link switch',
codeModeMetricNamesSuggestionLimit: 'data-testid code mode metric names suggestion limit',
},
queryEditor: {
// kickstart: '', see QueryBuilder queryPatterns below
@@ -101,6 +102,7 @@ export const Components = {
},
code: {
queryField: 'data-testid prometheus query field',
metricsCountInfo: 'data-testid metrics count disclaimer',
metricsBrowser: {
openButton: 'data-testid open metrics browser',
selectMetric: 'data-testid select a metric',
@@ -13,6 +13,7 @@ import { Monaco, monacoTypes, ReactMonacoEditor, useTheme2 } from '@grafana/ui';
import { Props } from './MonacoQueryFieldProps';
import { getOverrideServices } from './getOverrideServices';
import { getCompletionProvider, getSuggestOptions } from './monaco-completion-provider';
import { DataProvider } from './monaco-completion-provider/data_provider';
import { placeHolderScopedVars, validateQuery } from './monaco-completion-provider/validation';
import { language, languageConfiguration } from './promql';
@@ -143,41 +144,10 @@ const MonacoQueryField = (props: Props) => {
editor.onDidFocusEditorText(() => {
isEditorFocused.set(true);
});
// we construct a DataProvider object
const getHistory = () =>
Promise.resolve(historyRef.current.map((h) => h.query.expr).filter((expr) => expr !== undefined));
const getAllMetricNames = () => {
const { metrics, metricsMetadata } = lpRef.current;
const result = metrics.map((m) => {
const metaItem = metricsMetadata?.[m];
return {
name: m,
help: metaItem?.help ?? '',
type: metaItem?.type ?? '',
};
});
return Promise.resolve(result);
};
const getAllLabelNames = () => Promise.resolve(lpRef.current.getLabelKeys());
const getLabelValues = (labelName: string) => lpRef.current.getLabelValues(labelName);
const getSeriesValues = lpRef.current.getSeriesValues;
const getSeriesLabels = lpRef.current.getSeriesLabels;
const dataProvider = {
getHistory,
getAllMetricNames,
getAllLabelNames,
getLabelValues,
getSeriesValues,
getSeriesLabels,
};
const dataProvider = new DataProvider({
historyProvider: historyRef.current,
languageProvider: lpRef.current,
});
const completionProvider = getCompletionProvider(monaco, dataProvider);
// completion-providers in monaco are not registered directly to editor-instances,
@@ -0,0 +1,118 @@
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 type { Situation } from './situation';
const history: string[] = ['previous_metric_name_1', 'previous_metric_name_2', 'previous_metric_name_3'];
const dataProviderSettings = {
languageProvider: {
datasource: {
metricNamesAutocompleteSuggestionLimit: SUGGESTIONS_LIMIT,
},
getLabelKeys: jest.fn(),
getLabelValues: jest.fn(),
getSeriesLabels: jest.fn(),
getSeriesValues: jest.fn(),
metrics: [],
metricsMetadata: {},
},
historyProvider: history.map((expr, idx) => ({ query: { expr, refId: 'some-ref' }, ts: idx })),
} as unknown as DataProviderParams;
let dataProvider = new DataProvider(dataProviderSettings);
const metrics = {
beyondLimit: Array.from(Array(SUGGESTIONS_LIMIT + 1), (_, i) => `metric_name_${i}`),
get atLimit() {
return this.beyondLimit.slice(0, SUGGESTIONS_LIMIT - 1);
},
};
beforeEach(() => {
dataProvider = new DataProvider(dataProviderSettings);
jest.replaceProperty(config, 'featureToggles', {
prometheusCodeModeMetricNamesSearch: true,
});
});
afterEach(() => {
jest.restoreAllMocks();
});
type MetricNameSituation = Extract<Situation['type'], 'AT_ROOT' | 'EMPTY' | 'IN_FUNCTION'>;
const metricNameCompletionSituations = ['AT_ROOT', 'IN_FUNCTION', 'EMPTY'] as MetricNameSituation[];
function getSuggestionCountForSituation(situationType: MetricNameSituation, metricsCount: number): number {
const limitedMetricNamesCount = metricsCount < SUGGESTIONS_LIMIT ? metricsCount : SUGGESTIONS_LIMIT;
let suggestionsCount = limitedMetricNamesCount + FUNCTIONS.length;
if (situationType === 'EMPTY') {
suggestionsCount += history.length;
}
return suggestionsCount;
}
describe.each(metricNameCompletionSituations)('metric name completions in situation %s', (situationType) => {
it('should return completions for all metric names when the number of metric names is at or below the limit', async () => {
jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValue(metrics.atLimit);
const expectedCompletionsCount = getSuggestionCountForSituation(situationType, metrics.atLimit.length);
const situation: Situation = {
type: situationType,
};
// No text input
dataProvider.monacoSettings.setInputInRange('');
let completions = await getCompletions(situation, dataProvider);
expect(completions).toHaveLength(expectedCompletionsCount);
// With text input (use fuzzy search)
dataProvider.monacoSettings.setInputInRange('name_1');
completions = await getCompletions(situation, dataProvider);
expect(completions?.length).toBeLessThanOrEqual(expectedCompletionsCount);
});
it('should limit completions for metric names when the number of metric names is greater than 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('');
let completions = await getCompletions(situation, dataProvider);
expect(completions).toHaveLength(expectedCompletionsCount);
// With text input (use fuzzy search)
dataProvider.monacoSettings.setInputInRange('name_1');
completions = await getCompletions(situation, dataProvider);
expect(completions?.length).toBeLessThanOrEqual(expectedCompletionsCount);
});
it('should enable autocomplete suggestions update when the number of metric names is greater than the limit', async () => {
const situation: Situation = {
type: situationType,
};
// Do not cross the metrics names threshold
jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValueOnce(metrics.atLimit);
dataProvider.monacoSettings.setInputInRange('name_1');
await getCompletions(situation, dataProvider);
expect(dataProvider.monacoSettings.suggestionsIncomplete).toBe(false);
// Cross the metric names threshold, without text input
jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValueOnce(metrics.beyondLimit);
dataProvider.monacoSettings.setInputInRange('');
await getCompletions(situation, dataProvider);
expect(dataProvider.monacoSettings.suggestionsIncomplete).toBe(true);
// Cross the metric names threshold, with text input
jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValueOnce(metrics.beyondLimit);
dataProvider.monacoSettings.setInputInRange('name_1');
await getCompletions(situation, dataProvider);
expect(dataProvider.monacoSettings.suggestionsIncomplete).toBe(true);
});
});
@@ -1,6 +1,11 @@
import UFuzzy from '@leeoniya/ufuzzy';
import { config } from '@grafana/runtime';
import { escapeLabelValueInExactSelector } from '../../../language_utils';
import { FUNCTIONS } from '../../../promql';
import { DataProvider } from './data_provider';
import type { Label, Situation } from './situation';
import { NeverCaseError } from './util';
// FIXME: we should not load this from the "outside", but we cannot do that while we have the "old" query-field too
@@ -16,26 +21,31 @@ type Completion = {
triggerOnInsert?: boolean;
};
type Metric = {
name: string;
help: string;
type: string;
};
export type DataProvider = {
getHistory: () => Promise<string[]>;
getAllMetricNames: () => Promise<Metric[]>;
getAllLabelNames: () => Promise<string[]>;
getLabelValues: (labelName: string) => Promise<string[]>;
getSeriesValues: (name: string, match: string) => Promise<string[]>;
getSeriesLabels: (selector: string, otherLabels: Label[]) => Promise<string[]>;
};
const metricNamesSearchClient = new UFuzzy({ intraMode: 1 });
// we order items like: history, functions, metrics
function getAllMetricNamesCompletions(dataProvider: DataProvider): Completion[] {
let metricNames = dataProvider.getAllMetricNames();
async function getAllMetricNamesCompletions(dataProvider: DataProvider): Promise<Completion[]> {
const metrics = await dataProvider.getAllMetricNames();
return metrics.map((metric) => ({
if (
config.featureToggles.prometheusCodeModeMetricNamesSearch &&
metricNames.length > dataProvider.metricNamesSuggestionLimit
) {
const { monacoSettings } = dataProvider;
monacoSettings.enableAutocompleteSuggestionsUpdate();
if (monacoSettings.inputInRange) {
metricNames =
metricNamesSearchClient
.filter(metricNames, monacoSettings.inputInRange)
?.slice(0, dataProvider.metricNamesSuggestionLimit)
.map((idx) => metricNames[idx]) ?? [];
} else {
metricNames = metricNames.slice(0, dataProvider.metricNamesSuggestionLimit);
}
}
return dataProvider.metricNamesToMetrics(metricNames).map((metric) => ({
type: 'METRIC_NAME',
label: metric.name,
insertText: metric.name,
@@ -53,7 +63,8 @@ const FUNCTION_COMPLETIONS: Completion[] = FUNCTIONS.map((f) => ({
}));
async function getAllFunctionsAndMetricNamesCompletions(dataProvider: DataProvider): Promise<Completion[]> {
const metricNames = await getAllMetricNamesCompletions(dataProvider);
const metricNames = getAllMetricNamesCompletions(dataProvider);
return [...FUNCTION_COMPLETIONS, ...metricNames];
}
@@ -73,10 +84,10 @@ const DURATION_COMPLETIONS: Completion[] = [
insertText: text,
}));
async function getAllHistoryCompletions(dataProvider: DataProvider): Promise<Completion[]> {
function getAllHistoryCompletions(dataProvider: DataProvider): Completion[] {
// function getAllHistoryCompletions(queryHistory: PromHistoryItem[]): Completion[] {
// NOTE: the typescript types are wrong. historyItem.query.expr can be undefined
const allHistory = await dataProvider.getHistory();
const allHistory = dataProvider.getHistory();
// FIXME: find a better history-limit
return allHistory.slice(0, 10).map((expr) => ({
type: 'HISTORY',
@@ -107,7 +118,7 @@ async function getLabelNames(
): Promise<string[]> {
if (metric === undefined && otherLabels.length === 0) {
// if there is no filtering, we have to use a special endpoint
return dataProvider.getAllLabelNames();
return Promise.resolve(dataProvider.getAllLabelNames());
} else {
const selector = makeSelector(metric, otherLabels);
return await dataProvider.getSeriesLabels(selector, otherLabels);
@@ -176,19 +187,19 @@ async function getLabelValuesForMetricCompletions(
}));
}
export async function getCompletions(situation: Situation, dataProvider: DataProvider): Promise<Completion[]> {
export function getCompletions(situation: Situation, dataProvider: DataProvider): Promise<Completion[]> {
switch (situation.type) {
case 'IN_DURATION':
return DURATION_COMPLETIONS;
return Promise.resolve(DURATION_COMPLETIONS);
case 'IN_FUNCTION':
return getAllFunctionsAndMetricNamesCompletions(dataProvider);
case 'AT_ROOT': {
return getAllFunctionsAndMetricNamesCompletions(dataProvider);
}
case 'EMPTY': {
const metricNames = await getAllMetricNamesCompletions(dataProvider);
const historyCompletions = await getAllHistoryCompletions(dataProvider);
return [...historyCompletions, ...FUNCTION_COMPLETIONS, ...metricNames];
const metricNames = getAllMetricNamesCompletions(dataProvider);
const historyCompletions = getAllHistoryCompletions(dataProvider);
return Promise.resolve([...historyCompletions, ...FUNCTION_COMPLETIONS, ...metricNames]);
}
case 'IN_LABEL_SELECTOR_NO_LABEL_NAME':
return getLabelNamesForSelectorCompletions(situation.metricName, situation.otherLabels, dataProvider);
@@ -0,0 +1,116 @@
import { HistoryItem } from '@grafana/data';
import type { Monaco } from '@grafana/ui'; // used in TSDoc `@link` below
import PromQlLanguageProvider from '../../../language_provider';
import { PromQuery } from '../../../types';
export const CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT = 'codeModeSuggestionsIncomplete';
export type SuggestionsIncompleteEvent = CustomEvent<{
limit: number;
datasourceUid: string;
}>;
export function isSuggestionsIncompleteEvent(e: Event): e is SuggestionsIncompleteEvent {
return (
e.type === CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT &&
'detail' in e &&
typeof e.detail === 'object' &&
e.detail !== null &&
'limit' in e.detail &&
'datasourceUid' in e.detail
);
}
interface Metric {
name: string;
help: string;
type: string;
}
export interface DataProviderParams {
languageProvider: PromQlLanguageProvider;
historyProvider: Array<HistoryItem<PromQuery>>;
}
export class DataProvider {
readonly languageProvider: PromQlLanguageProvider;
readonly historyProvider: Array<HistoryItem<PromQuery>>;
readonly getSeriesLabels: typeof this.languageProvider.getSeriesLabels;
readonly getSeriesValues: typeof this.languageProvider.getSeriesValues;
readonly getAllLabelNames: typeof this.languageProvider.getLabelKeys;
readonly getLabelValues: typeof this.languageProvider.getLabelValues;
readonly metricNamesSuggestionLimit: number;
/**
* The text that's been typed so far within the current {@link Monaco.Range | Range}.
*
* @remarks
* This is useful with fuzzy searching items to provide as Monaco autocomplete suggestions.
*/
private inputInRange: string;
private suggestionsIncomplete: boolean;
constructor(params: DataProviderParams) {
this.languageProvider = params.languageProvider;
this.historyProvider = params.historyProvider;
this.inputInRange = '';
this.metricNamesSuggestionLimit = this.languageProvider.datasource.metricNamesAutocompleteSuggestionLimit;
this.suggestionsIncomplete = false;
this.getSeriesLabels = this.languageProvider.getSeriesLabels.bind(this.languageProvider);
this.getSeriesValues = this.languageProvider.getSeriesValues.bind(this.languageProvider);
this.getAllLabelNames = this.languageProvider.getLabelKeys.bind(this.languageProvider);
this.getLabelValues = this.languageProvider.getLabelValues.bind(this.languageProvider);
}
getHistory(): string[] {
return this.historyProvider.map((h) => h.query.expr).filter(Boolean);
}
getAllMetricNames(): string[] {
return this.languageProvider.metrics;
}
metricNamesToMetrics(metricNames: string[]): Metric[] {
const { metricsMetadata } = this.languageProvider;
const result: Metric[] = metricNames.map((m) => {
const metaItem = metricsMetadata?.[m];
return {
name: m,
help: metaItem?.help ?? '',
type: metaItem?.type ?? '',
};
});
return result;
}
private enableAutocompleteSuggestionsUpdate(): void {
this.suggestionsIncomplete = true;
dispatchEvent(
new CustomEvent(CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT, {
detail: { limit: this.metricNamesSuggestionLimit, datasourceUid: this.languageProvider.datasource.uid },
})
);
}
private setInputInRange(textInput: string): void {
this.inputInRange = textInput;
}
get monacoSettings() {
return {
/**
* Enable autocomplete suggestions update on every input change.
*
* @remarks
* If fuzzy search is used in `getCompletions` to trim down results to improve performance,
* we need to instruct Monaco to update the completions on every input change, so that the
* completions reflect the current input.
*/
enableAutocompleteSuggestionsUpdate: this.enableAutocompleteSuggestionsUpdate.bind(this),
inputInRange: this.inputInRange,
setInputInRange: this.setInputInRange.bind(this),
suggestionsIncomplete: this.suggestionsIncomplete,
};
}
}
@@ -1,6 +1,7 @@
import type { Monaco, monacoTypes } from '@grafana/ui';
import { CompletionType, DataProvider, getCompletions } from './completions';
import { CompletionType, getCompletions } from './completions';
import { DataProvider } from './data_provider';
import { getSituation } from './situation';
import { NeverCaseError } from './util';
@@ -69,6 +70,7 @@ export function getCompletionProvider(
column: position.column,
lineNumber: position.lineNumber,
};
dataProvider.monacoSettings.setInputInRange(model.getValueInRange(range));
// Check to see if the browser supports window.getSelection()
if (window.getSelection) {
@@ -82,6 +84,7 @@ export function getCompletionProvider(
const offset = model.getOffsetAt(positionClone);
const situation = getSituation(model.getValue(), offset);
const completionsPromise = situation != null ? getCompletions(situation, dataProvider) : Promise.resolve([]);
return completionsPromise.then((items) => {
// monaco by-default alphabetically orders the items.
// to stop it, we use a number-as-string sortkey,
@@ -102,7 +105,7 @@ export function getCompletionProvider(
}
: undefined,
}));
return { suggestions };
return { suggestions, incomplete: dataProvider.monacoSettings.suggestionsIncomplete };
});
};
@@ -1,11 +1,19 @@
import { render, screen } from '@testing-library/react';
import { render, screen, fireEvent } from '@testing-library/react';
import React, { SyntheticEvent } from 'react';
import { SelectableValue } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { config } from '@grafana/runtime';
import { getValueFromEventItem, PromSettings } from './PromSettings';
import { countError, getValueFromEventItem, PromSettings } from './PromSettings';
import { createDefaultConfigOptions } from './mocks';
beforeEach(() => {
jest.replaceProperty(config, 'featureToggles', {
prometheusCodeModeMetricNamesSearch: true,
});
});
describe('PromSettings', () => {
describe('getValueFromEventItem', () => {
describe('when called with undefined', () => {
@@ -61,5 +69,46 @@ describe('PromSettings', () => {
render(<PromSettings onOptionsChange={() => {}} options={options} />);
expect(screen.getByText('GET')).toBeInTheDocument();
});
it('should show a valid metric name count if codeModeMetricNamesSuggestionLimit is configured correctly', () => {
const options = defaultProps;
const { getByTestId, queryByText } = render(<PromSettings onOptionsChange={() => {}} options={options} />);
const input = getByTestId(
selectors.components.DataSource.Prometheus.configPage.codeModeMetricNamesSuggestionLimit
);
// Non-negative integer
fireEvent.change(input, { target: { value: '3000' } });
fireEvent.blur(input);
expect(queryByText(countError)).not.toBeInTheDocument();
// Non-negative integer with scientific notation
fireEvent.change(input, { target: { value: '1e5' } });
fireEvent.blur(input);
expect(queryByText(countError)).not.toBeInTheDocument();
// Non-negative integer with decimal scientific notation
fireEvent.change(input, { target: { value: '1.4e4' } });
fireEvent.blur(input);
expect(queryByText(countError)).not.toBeInTheDocument();
});
it('should show the expected error when an invalid value is provided for codeModeMetricNamesSuggestionLimit', () => {
const options = defaultProps;
const { getByTestId, queryByText } = render(<PromSettings onOptionsChange={() => {}} options={options} />);
const input = getByTestId(
selectors.components.DataSource.Prometheus.configPage.codeModeMetricNamesSuggestionLimit
);
// No negative values
fireEvent.change(input, { target: { value: '-50' } });
fireEvent.blur(input);
expect(queryByText(countError)).toBeInTheDocument();
// No negative values with scientific notation
fireEvent.change(input, { target: { value: '-5e5' } });
fireEvent.blur(input);
expect(queryByText(countError)).toBeInTheDocument();
});
});
});
@@ -8,8 +8,10 @@ import {
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { ConfigSubSection } from '@grafana/experimental';
import { config } from '@grafana/runtime';
import { InlineField, Input, Select, Switch, useTheme2 } from '@grafana/ui';
import { SUGGESTIONS_LIMIT } from '../language_provider';
import { QueryEditorMode } from '../querybuilder/shared/types';
import { defaultPrometheusQueryOverlapWindow } from '../querycache/QueryCache';
import { PromApplication, PrometheusCacheLevel, PromOptions } from '../types';
@@ -52,7 +54,10 @@ export const DURATION_REGEX = /^$|^\d+(ms|[Mwdhmsy])$/;
// multiple duration input
export const MULTIPLE_DURATION_REGEX = /(\d+)(.+)/;
export const NON_NEGATIVE_INTEGER_REGEX = /^(0|[1-9]\d*)(\.\d+)?(e\+?\d+)?$/; // non-negative integers, including scientific notation
const durationError = 'Value is not valid, you can use number with time unit specifier: y, M, w, d, h, m, s';
export const countError = 'Value is not valid, you can use non-negative integers, including scientific notation';
export const PromSettings = (props: Props) => {
const { options, onOptionsChange } = props;
@@ -78,6 +83,14 @@ export const PromSettings = (props: Props) => {
incrementalQueryOverlapWindow: '',
});
type ValidCount = {
codeModeMetricNamesSuggestionLimit: string;
};
const [validCount, updateValidCount] = useState<ValidCount>({
codeModeMetricNamesSuggestionLimit: '',
});
return (
<>
<ConfigSubSection title="Interval behaviour" className={styles.container}>
@@ -301,6 +314,49 @@ export const PromSettings = (props: Props) => {
</div>
</div>
{config.featureToggles.prometheusCodeModeMetricNamesSearch && (
<div className="gf-form-inline">
<div className="gf-form">
<InlineField
label="Metric names suggestion limit"
labelWidth={PROM_CONFIG_LABEL_WIDTH}
tooltip={
<>
The maximum number of metric names that may appear as autocomplete suggestions in the query
editor&apos;s Code mode.
</>
}
interactive={true}
disabled={options.readOnly}
>
<>
<Input
className="width-20"
value={options.jsonData.codeModeMetricNamesSuggestionLimit}
onChange={onChangeHandler('codeModeMetricNamesSuggestionLimit', options, onOptionsChange)}
spellCheck={false}
placeholder={SUGGESTIONS_LIMIT.toString()}
onBlur={(e) =>
updateValidCount({
...validCount,
codeModeMetricNamesSuggestionLimit: e.currentTarget.value,
})
}
data-testid={
selectors.components.DataSource.Prometheus.configPage.codeModeMetricNamesSuggestionLimit
}
/>
{validateInput(
validCount.codeModeMetricNamesSuggestionLimit,
NON_NEGATIVE_INTEGER_REGEX,
countError
)}
</>
</InlineField>
</div>
</div>
)}
<div className="gf-form-inline">
<div className="gf-form max-width-30">
<InlineField
@@ -42,7 +42,7 @@ import {
import { addLabelToQuery } from './add_label_to_query';
import { AnnotationQueryEditor } from './components/AnnotationQueryEditor';
import PrometheusLanguageProvider from './language_provider';
import PrometheusLanguageProvider, { SUGGESTIONS_LIMIT } from './language_provider';
import {
expandRecordingRules,
getClientCacheDurationInMinutes,
@@ -97,6 +97,7 @@ export class PrometheusDatasource
exemplarsAvailable: boolean;
cacheLevel: PrometheusCacheLevel;
cache: QueryCache<PromQuery>;
metricNamesAutocompleteSuggestionLimit: number;
constructor(
instanceSettings: DataSourceInstanceSettings<PromOptions>,
@@ -127,6 +128,8 @@ export class PrometheusDatasource
this.variables = new PrometheusVariableSupport(this, this.templateSrv);
this.exemplarsAvailable = true;
this.cacheLevel = instanceSettings.jsonData.cacheLevel ?? PrometheusCacheLevel.Low;
this.metricNamesAutocompleteSuggestionLimit =
instanceSettings.jsonData.codeModeMetricNamesSuggestionLimit ?? SUGGESTIONS_LIMIT;
this.cache = new QueryCache({
getTargetSignature: this.getPrometheusTargetSignature.bind(this),
@@ -0,0 +1,161 @@
import { render, screen, fireEvent, createEvent } from '@testing-library/react';
import { cloneDeep, defaultsDeep } from 'lodash';
import React from 'react';
import { PluginMeta, PluginType } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { config } from '@grafana/runtime';
import { CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT } from '../../components/monaco-query-field/monaco-completion-provider/data_provider';
import { PromQueryEditorProps } from '../../components/types';
import { PrometheusDatasource } from '../../datasource';
import PromQlLanguageProvider from '../../language_provider';
import { EmptyLanguageProviderMock } from '../../language_provider.mock';
import { PromQuery } from '../../types';
import { QueryEditorMode } from '../shared/types';
import { PromQueryEditorSelector } from './PromQueryEditorSelector';
beforeEach(() => {
jest.replaceProperty(config, 'featureToggles', {
prometheusCodeModeMetricNamesSearch: true,
});
});
// We need to mock this because it seems jest has problem importing monaco in tests
jest.mock('../../components/monaco-query-field/MonacoQueryFieldWrapper', () => {
return {
MonacoQueryFieldWrapper: () => {
return 'MonacoQueryFieldWrapper';
},
};
});
jest.mock('../../gcopypaste/app/core/store', () => {
return {
get() {
return undefined;
},
set() {},
getObject(key: string, defaultValue: unknown) {
return defaultValue;
},
};
});
jest.mock('@grafana/runtime', () => {
return {
...jest.requireActual('@grafana/runtime'),
reportInteraction: jest.fn(),
};
});
const defaultQuery = {
refId: 'A',
expr: 'metric{label1="foo", label2="bar"}',
};
const defaultMeta: PluginMeta = {
id: '',
name: '',
type: PluginType.datasource,
info: {
author: {
name: 'tester',
},
description: 'testing',
links: [],
logos: {
large: '',
small: '',
},
screenshots: [],
updated: '',
version: '',
},
module: '',
baseUrl: '',
};
const getDefaultDatasource = (jsonDataOverrides = {}) =>
new PrometheusDatasource(
{
id: 1,
uid: 'myDataSourceUid',
type: 'prometheus',
name: 'prom-test',
access: 'proxy',
url: '',
jsonData: jsonDataOverrides,
meta: defaultMeta,
readOnly: false,
},
undefined,
new EmptyLanguageProviderMock() as unknown as PromQlLanguageProvider
);
const defaultProps = {
datasource: getDefaultDatasource(),
query: defaultQuery,
onRunQuery: () => {},
onChange: () => {},
};
const autocompleteInfoSelector = selectors.components.DataSource.Prometheus.queryEditor.code.metricsCountInfo;
describe('PromQueryEditorSelector', () => {
it('does not show autocomplete info when the code editor first displays', async () => {
const { queryByTestId } = renderWithCodeMode();
expect(await screen.findByText('MonacoQueryFieldWrapper')).toBeInTheDocument();
expect(queryByTestId(autocompleteInfoSelector)).not.toBeInTheDocument();
});
it('shows autocomplete info when the expected event fires', async () => {
const { findByTestId } = renderWithCodeMode();
fireEvent(
window,
createEvent(
CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT,
window,
{
detail: { limit: 100, datasourceUid: 'myDataSourceUid' },
},
{ EventType: 'CustomEvent' }
)
);
expect(await screen.findByText('MonacoQueryFieldWrapper')).toBeInTheDocument();
const autocompleteInfo = await findByTestId(autocompleteInfoSelector);
expect(autocompleteInfo).toBeInTheDocument();
});
it('does not show autocomplete info when the triggering event refers to a different data source', async () => {
const { queryByTestId } = renderWithCodeMode();
fireEvent(
window,
createEvent(
CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT,
window,
{
detail: { limit: 100, datasourceUid: 'theWrongUid' },
},
{ EventType: 'CustomEvent' }
)
);
expect(await screen.findByText('MonacoQueryFieldWrapper')).toBeInTheDocument();
const autocompleteInfo = await queryByTestId(autocompleteInfoSelector);
expect(autocompleteInfo).not.toBeInTheDocument();
});
});
function renderWithCodeMode() {
return renderWithProps({ editorMode: QueryEditorMode.Code, expr: 'my_metric' });
}
function renderWithProps(overrides?: Partial<PromQuery>, componentProps: Partial<PromQueryEditorProps> = {}) {
const query = defaultsDeep(overrides ?? {}, cloneDeep(defaultQuery));
const onChange = jest.fn();
const allProps = { ...defaultProps, ...componentProps };
const stuff = render(<PromQueryEditorSelector {...allProps} query={query} onChange={onChange} />);
return { onChange, ...stuff };
}
@@ -0,0 +1,69 @@
import React, { useState, useEffect, useCallback } from 'react';
import { selectors } from '@grafana/e2e-selectors';
import { config } from '@grafana/runtime';
import { IconButton, Text, Stack } from '@grafana/ui';
import {
CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT,
isSuggestionsIncompleteEvent,
} from '../../components/monaco-query-field/monaco-completion-provider/data_provider';
import { PromQueryEditorProps } from '../../components/types';
import { QueryEditorMode } from '../shared/types';
interface Props {
datasourceUid: PromQueryEditorProps['datasource']['uid'];
editorMode: QueryEditorMode;
}
export function PromQueryCodeEditorAutocompleteInfo(props: Readonly<Props>) {
const [autocompleteLimit, setAutocompleteLimit] = useState('n');
const [autocompleteLimitExceeded, setAutocompleteLimitExceeded] = useState(false);
const handleSuggestionsIncompleteEvent = useCallback(
(e: Event) => {
if (!isSuggestionsIncompleteEvent(e)) {
return;
}
if (e.detail.datasourceUid === props.datasourceUid) {
setAutocompleteLimitExceeded(true);
setAutocompleteLimit(e.detail.limit.toString());
}
},
[props.datasourceUid]
);
useEffect(() => {
addEventListener(CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT, handleSuggestionsIncompleteEvent);
return () => {
removeEventListener(CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT, handleSuggestionsIncompleteEvent);
};
}, [handleSuggestionsIncompleteEvent]);
const showCodeModeAutocompleteDisclaimer = (): boolean => {
return (
Boolean(config.featureToggles.prometheusCodeModeMetricNamesSearch) &&
props.editorMode === QueryEditorMode.Code &&
autocompleteLimitExceeded
);
};
if (!showCodeModeAutocompleteDisclaimer()) {
return null;
}
return (
<div data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsCountInfo}>
<Stack direction="row" gap={1}>
<Text color="secondary" element="p" italic={true}>
Autocomplete suggestions limited
</Text>
<IconButton
name="info-circle"
tooltip={`The number of metric names exceeds the autocomplete limit. Only the ${autocompleteLimit}-most relevant metrics are displayed. You can adjust the threshold in the data source settings.`}
/>
</Stack>
</div>
);
}
@@ -21,6 +21,7 @@ import { changeEditorMode, getQueryWithDefaults } from '../state';
import { PromQueryBuilderContainer } from './PromQueryBuilderContainer';
import { PromQueryBuilderOptions } from './PromQueryBuilderOptions';
import { PromQueryCodeEditor } from './PromQueryCodeEditor';
import { PromQueryCodeEditorAutocompleteInfo } from './PromQueryCodeEditorAutocompleteInfo';
export const FORMAT_OPTIONS: Array<SelectableValue<PromQueryFormat>> = [
{ label: 'Time series', value: 'time_series' },
@@ -138,6 +139,7 @@ export const PromQueryEditorSelector = React.memo<Props>((props) => {
Run queries
</Button>
)}
<PromQueryCodeEditorAutocompleteInfo datasourceUid={props.datasource.uid} editorMode={editorMode} />
<div data-testid={selectors.components.DataSource.Prometheus.queryEditor.editorToggle}>
<QueryEditorModeToggle mode={editorMode} onChange={onEditorModeChange} />
</div>
+1
View File
@@ -51,6 +51,7 @@ export interface PromOptions extends DataSourceJsonData {
disableRecordingRules?: boolean;
sigV4Auth?: boolean;
oauthPassThru?: boolean;
codeModeMetricNamesSuggestionLimit?: number;
}
export type ExemplarTraceIdDestination = {
+7
View File
@@ -859,6 +859,13 @@ var (
FrontendOnly: true,
Owner: grafanaObservabilityMetricsSquad,
},
{
Name: "prometheusCodeModeMetricNamesSearch",
Description: "Enables search for metric names in Code Mode, to improve performance when working with an enormous number of metric names",
FrontendOnly: true,
Stage: FeatureStageExperimental,
Owner: grafanaObservabilityMetricsSquad,
},
{
Name: "addFieldFromCalculationStatFunctions",
Description: "Add cumulative and window functions to the add field from calculation transformation",
+1
View File
@@ -114,6 +114,7 @@ cachingOptimizeSerializationMemoryUsage,experimental,@grafana/grafana-operator-e
panelTitleSearchInV1,experimental,@grafana/backend-platform,true,false,false
managedPluginsInstall,GA,@grafana/plugins-platform-backend,false,false,false
prometheusPromQAIL,experimental,@grafana/observability-metrics,false,false,true
prometheusCodeModeMetricNamesSearch,experimental,@grafana/observability-metrics,false,false,true
addFieldFromCalculationStatFunctions,preview,@grafana/dataviz-squad,false,false,true
alertmanagerRemoteSecondary,experimental,@grafana/alerting-squad,false,false,false
alertmanagerRemotePrimary,experimental,@grafana/alerting-squad,false,false,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
114 panelTitleSearchInV1 experimental @grafana/backend-platform true false false
115 managedPluginsInstall GA @grafana/plugins-platform-backend false false false
116 prometheusPromQAIL experimental @grafana/observability-metrics false false true
117 prometheusCodeModeMetricNamesSearch experimental @grafana/observability-metrics false false true
118 addFieldFromCalculationStatFunctions preview @grafana/dataviz-squad false false true
119 alertmanagerRemoteSecondary experimental @grafana/alerting-squad false false false
120 alertmanagerRemotePrimary experimental @grafana/alerting-squad false false false
+4
View File
@@ -467,6 +467,10 @@ const (
// Prometheus and AI/ML to assist users in creating a query
FlagPrometheusPromQAIL = "prometheusPromQAIL"
// FlagPrometheusCodeModeMetricNamesSearch
// Enables search for metric names in Code Mode, to improve performance when working with an enormous number of metric names
FlagPrometheusCodeModeMetricNamesSearch = "prometheusCodeModeMetricNamesSearch"
// FlagAddFieldFromCalculationStatFunctions
// Add cumulative and window functions to the add field from calculation transformation
FlagAddFieldFromCalculationStatFunctions = "addFieldFromCalculationStatFunctions"
+13
View File
@@ -2107,6 +2107,19 @@
"hideFromAdminPage": true,
"hideFromDocs": true
}
},
{
"metadata": {
"name": "prometheusCodeModeMetricNamesSearch",
"resourceVersion": "1712241392690",
"creationTimestamp": "2024-04-04T14:36:32Z"
},
"spec": {
"description": "Enables search for metric names in Code Mode, to improve performance when working with an enormous number of metric names",
"stage": "experimental",
"codeowner": "@grafana/observability-metrics",
"frontend": true
}
}
]
}
@@ -17,6 +17,7 @@ import {
import { Props } from './MonacoQueryFieldProps';
import { getOverrideServices } from './getOverrideServices';
import { getCompletionProvider, getSuggestOptions } from './monaco-completion-provider';
import { DataProvider } from './monaco-completion-provider/data_provider';
const options: monacoTypes.editor.IStandaloneEditorConstructionOptions = {
codeLens: false,
@@ -145,41 +146,10 @@ const MonacoQueryField = (props: Props) => {
editor.onDidFocusEditorText(() => {
isEditorFocused.set(true);
});
// we construct a DataProvider object
const getHistory = () =>
Promise.resolve(historyRef.current.map((h) => h.query.expr).filter((expr) => expr !== undefined));
const getAllMetricNames = () => {
const { metrics, metricsMetadata } = lpRef.current;
const result = metrics.map((m) => {
const metaItem = metricsMetadata?.[m];
return {
name: m,
help: metaItem?.help ?? '',
type: metaItem?.type ?? '',
};
});
return Promise.resolve(result);
};
const getAllLabelNames = () => Promise.resolve(lpRef.current.getLabelKeys());
const getLabelValues = (labelName: string) => lpRef.current.getLabelValues(labelName);
const getSeriesValues = lpRef.current.getSeriesValues;
const getSeriesLabels = lpRef.current.getSeriesLabels;
const dataProvider = {
getHistory,
getAllMetricNames,
getAllLabelNames,
getLabelValues,
getSeriesValues,
getSeriesLabels,
};
const dataProvider = new DataProvider({
historyProvider: historyRef.current,
languageProvider: lpRef.current,
});
const completionProvider = getCompletionProvider(monaco, dataProvider);
// completion-providers in monaco are not registered directly to editor-instances,
@@ -0,0 +1,118 @@
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 type { Situation } from './situation';
const history: string[] = ['previous_metric_name_1', 'previous_metric_name_2', 'previous_metric_name_3'];
const dataProviderSettings = {
languageProvider: {
datasource: {
metricNamesAutocompleteSuggestionLimit: SUGGESTIONS_LIMIT,
},
getLabelKeys: jest.fn(),
getLabelValues: jest.fn(),
getSeriesLabels: jest.fn(),
getSeriesValues: jest.fn(),
metrics: [],
metricsMetadata: {},
},
historyProvider: history.map((expr, idx) => ({ query: { expr, refId: 'some-ref' }, ts: idx })),
} as unknown as DataProviderParams;
let dataProvider = new DataProvider(dataProviderSettings);
const metrics = {
beyondLimit: Array.from(Array(SUGGESTIONS_LIMIT + 1), (_, i) => `metric_name_${i}`),
get atLimit() {
return this.beyondLimit.slice(0, SUGGESTIONS_LIMIT - 1);
},
};
beforeEach(() => {
dataProvider = new DataProvider(dataProviderSettings);
jest.replaceProperty(config, 'featureToggles', {
prometheusCodeModeMetricNamesSearch: true,
});
});
afterEach(() => {
jest.restoreAllMocks();
});
type MetricNameSituation = Extract<Situation['type'], 'AT_ROOT' | 'EMPTY' | 'IN_FUNCTION'>;
const metricNameCompletionSituations = ['AT_ROOT', 'IN_FUNCTION', 'EMPTY'] as MetricNameSituation[];
function getSuggestionCountForSituation(situationType: MetricNameSituation, metricsCount: number): number {
const limitedMetricNamesCount = metricsCount < SUGGESTIONS_LIMIT ? metricsCount : SUGGESTIONS_LIMIT;
let suggestionsCount = limitedMetricNamesCount + FUNCTIONS.length;
if (situationType === 'EMPTY') {
suggestionsCount += history.length;
}
return suggestionsCount;
}
describe.each(metricNameCompletionSituations)('metric name completions in situation %s', (situationType) => {
it('should return completions for all metric names when the number of metric names is at or below the limit', async () => {
jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValue(metrics.atLimit);
const expectedCompletionsCount = getSuggestionCountForSituation(situationType, metrics.atLimit.length);
const situation: Situation = {
type: situationType,
};
// No text input
dataProvider.monacoSettings.setInputInRange('');
let completions = await getCompletions(situation, dataProvider);
expect(completions).toHaveLength(expectedCompletionsCount);
// With text input (use fuzzy search)
dataProvider.monacoSettings.setInputInRange('name_1');
completions = await getCompletions(situation, dataProvider);
expect(completions?.length).toBeLessThanOrEqual(expectedCompletionsCount);
});
it('should limit completions for metric names when the number of metric names is greater than 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('');
let completions = await getCompletions(situation, dataProvider);
expect(completions).toHaveLength(expectedCompletionsCount);
// With text input (use fuzzy search)
dataProvider.monacoSettings.setInputInRange('name_1');
completions = await getCompletions(situation, dataProvider);
expect(completions?.length).toBeLessThanOrEqual(expectedCompletionsCount);
});
it('should enable autocomplete suggestions update when the number of metric names is greater than the limit', async () => {
const situation: Situation = {
type: situationType,
};
// Do not cross the metrics names threshold
jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValueOnce(metrics.atLimit);
dataProvider.monacoSettings.setInputInRange('name_1');
await getCompletions(situation, dataProvider);
expect(dataProvider.monacoSettings.suggestionsIncomplete).toBe(false);
// Cross the metric names threshold, without text input
jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValueOnce(metrics.beyondLimit);
dataProvider.monacoSettings.setInputInRange('');
await getCompletions(situation, dataProvider);
expect(dataProvider.monacoSettings.suggestionsIncomplete).toBe(true);
// Cross the metric names threshold, with text input
jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValueOnce(metrics.beyondLimit);
dataProvider.monacoSettings.setInputInRange('name_1');
await getCompletions(situation, dataProvider);
expect(dataProvider.monacoSettings.suggestionsIncomplete).toBe(true);
});
});
@@ -1,6 +1,11 @@
import UFuzzy from '@leeoniya/ufuzzy';
import { config } from '@grafana/runtime';
import { escapeLabelValueInExactSelector } from '../../../language_utils';
import { FUNCTIONS } from '../../../promql';
import { DataProvider } from './data_provider';
import type { Situation, Label } from './situation';
import { NeverCaseError } from './util';
// FIXME: we should not load this from the "outside", but we cannot do that while we have the "old" query-field too
@@ -16,26 +21,31 @@ type Completion = {
triggerOnInsert?: boolean;
};
type Metric = {
name: string;
help: string;
type: string;
};
export type DataProvider = {
getHistory: () => Promise<string[]>;
getAllMetricNames: () => Promise<Metric[]>;
getAllLabelNames: () => Promise<string[]>;
getLabelValues: (labelName: string) => Promise<string[]>;
getSeriesValues: (name: string, match: string) => Promise<string[]>;
getSeriesLabels: (selector: string, otherLabels: Label[]) => Promise<string[]>;
};
const metricNamesSearchClient = new UFuzzy({ intraMode: 1 });
// we order items like: history, functions, metrics
function getAllMetricNamesCompletions(dataProvider: DataProvider): Completion[] {
let metricNames = dataProvider.getAllMetricNames();
async function getAllMetricNamesCompletions(dataProvider: DataProvider): Promise<Completion[]> {
const metrics = await dataProvider.getAllMetricNames();
return metrics.map((metric) => ({
if (
config.featureToggles.prometheusCodeModeMetricNamesSearch &&
metricNames.length > dataProvider.metricNamesSuggestionLimit
) {
const { monacoSettings } = dataProvider;
monacoSettings.enableAutocompleteSuggestionsUpdate();
if (monacoSettings.inputInRange) {
metricNames =
metricNamesSearchClient
.filter(metricNames, monacoSettings.inputInRange)
?.slice(0, dataProvider.metricNamesSuggestionLimit)
.map((idx) => metricNames[idx]) ?? [];
} else {
metricNames = metricNames.slice(0, dataProvider.metricNamesSuggestionLimit);
}
}
return dataProvider.metricNamesToMetrics(metricNames).map((metric) => ({
type: 'METRIC_NAME',
label: metric.name,
insertText: metric.name,
@@ -53,7 +63,7 @@ const FUNCTION_COMPLETIONS: Completion[] = FUNCTIONS.map((f) => ({
}));
async function getAllFunctionsAndMetricNamesCompletions(dataProvider: DataProvider): Promise<Completion[]> {
const metricNames = await getAllMetricNamesCompletions(dataProvider);
const metricNames = getAllMetricNamesCompletions(dataProvider);
return [...FUNCTION_COMPLETIONS, ...metricNames];
}
@@ -73,10 +83,10 @@ const DURATION_COMPLETIONS: Completion[] = [
insertText: text,
}));
async function getAllHistoryCompletions(dataProvider: DataProvider): Promise<Completion[]> {
function getAllHistoryCompletions(dataProvider: DataProvider): Completion[] {
// function getAllHistoryCompletions(queryHistory: PromHistoryItem[]): Completion[] {
// NOTE: the typescript types are wrong. historyItem.query.expr can be undefined
const allHistory = await dataProvider.getHistory();
const allHistory = dataProvider.getHistory();
// FIXME: find a better history-limit
return allHistory.slice(0, 10).map((expr) => ({
type: 'HISTORY',
@@ -107,7 +117,7 @@ async function getLabelNames(
): Promise<string[]> {
if (metric === undefined && otherLabels.length === 0) {
// if there is no filtering, we have to use a special endpoint
return dataProvider.getAllLabelNames();
return Promise.resolve(dataProvider.getAllLabelNames());
} else {
const selector = makeSelector(metric, otherLabels);
return await dataProvider.getSeriesLabels(selector, otherLabels);
@@ -175,19 +185,19 @@ async function getLabelValuesForMetricCompletions(
}));
}
export async function getCompletions(situation: Situation, dataProvider: DataProvider): Promise<Completion[]> {
export function getCompletions(situation: Situation, dataProvider: DataProvider): Promise<Completion[]> {
switch (situation.type) {
case 'IN_DURATION':
return DURATION_COMPLETIONS;
return Promise.resolve(DURATION_COMPLETIONS);
case 'IN_FUNCTION':
return getAllFunctionsAndMetricNamesCompletions(dataProvider);
case 'AT_ROOT': {
return getAllFunctionsAndMetricNamesCompletions(dataProvider);
}
case 'EMPTY': {
const metricNames = await getAllMetricNamesCompletions(dataProvider);
const historyCompletions = await getAllHistoryCompletions(dataProvider);
return [...historyCompletions, ...FUNCTION_COMPLETIONS, ...metricNames];
const metricNames = getAllMetricNamesCompletions(dataProvider);
const historyCompletions = getAllHistoryCompletions(dataProvider);
return Promise.resolve([...historyCompletions, ...FUNCTION_COMPLETIONS, ...metricNames]);
}
case 'IN_LABEL_SELECTOR_NO_LABEL_NAME':
return getLabelNamesForSelectorCompletions(situation.metricName, situation.otherLabels, dataProvider);
@@ -0,0 +1,116 @@
import { HistoryItem } from '@grafana/data';
import type { Monaco } from '@grafana/ui'; // used in TSDoc `@link` below
import PromQlLanguageProvider from '../../../language_provider';
import { PromQuery } from '../../../types';
export const CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT = 'codeModeSuggestionsIncomplete';
export type SuggestionsIncompleteEvent = CustomEvent<{
limit: number;
datasourceUid: string;
}>;
export function isSuggestionsIncompleteEvent(e: Event): e is SuggestionsIncompleteEvent {
return (
e.type === CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT &&
'detail' in e &&
typeof e.detail === 'object' &&
e.detail !== null &&
'limit' in e.detail &&
'datasourceUid' in e.detail
);
}
interface Metric {
name: string;
help: string;
type: string;
}
export interface DataProviderParams {
languageProvider: PromQlLanguageProvider;
historyProvider: Array<HistoryItem<PromQuery>>;
}
export class DataProvider {
readonly languageProvider: PromQlLanguageProvider;
readonly historyProvider: Array<HistoryItem<PromQuery>>;
readonly getSeriesLabels: typeof this.languageProvider.getSeriesLabels;
readonly getSeriesValues: typeof this.languageProvider.getSeriesValues;
readonly getAllLabelNames: typeof this.languageProvider.getLabelKeys;
readonly getLabelValues: typeof this.languageProvider.getLabelValues;
readonly metricNamesSuggestionLimit: number;
/**
* The text that's been typed so far within the current {@link Monaco.Range | Range}.
*
* @remarks
* This is useful with fuzzy searching items to provide as Monaco autocomplete suggestions.
*/
private inputInRange: string;
private suggestionsIncomplete: boolean;
constructor(params: DataProviderParams) {
this.languageProvider = params.languageProvider;
this.historyProvider = params.historyProvider;
this.inputInRange = '';
this.metricNamesSuggestionLimit = this.languageProvider.datasource.metricNamesAutocompleteSuggestionLimit;
this.suggestionsIncomplete = false;
this.getSeriesLabels = this.languageProvider.getSeriesLabels.bind(this.languageProvider);
this.getSeriesValues = this.languageProvider.getSeriesValues.bind(this.languageProvider);
this.getAllLabelNames = this.languageProvider.getLabelKeys.bind(this.languageProvider);
this.getLabelValues = this.languageProvider.getLabelValues.bind(this.languageProvider);
}
getHistory(): string[] {
return this.historyProvider.map((h) => h.query.expr).filter(Boolean);
}
getAllMetricNames(): string[] {
return this.languageProvider.metrics;
}
metricNamesToMetrics(metricNames: string[]): Metric[] {
const { metricsMetadata } = this.languageProvider;
const result: Metric[] = metricNames.map((m) => {
const metaItem = metricsMetadata?.[m];
return {
name: m,
help: metaItem?.help ?? '',
type: metaItem?.type ?? '',
};
});
return result;
}
private enableAutocompleteSuggestionsUpdate(): void {
this.suggestionsIncomplete = true;
dispatchEvent(
new CustomEvent(CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT, {
detail: { limit: this.metricNamesSuggestionLimit, datasourceUid: this.languageProvider.datasource.uid },
})
);
}
private setInputInRange(textInput: string): void {
this.inputInRange = textInput;
}
get monacoSettings() {
return {
/**
* Enable autocomplete suggestions update on every input change.
*
* @remarks
* If fuzzy search is used in `getCompletions` to trim down results to improve performance,
* we need to instruct Monaco to update the completions on every input change, so that the
* completions reflect the current input.
*/
enableAutocompleteSuggestionsUpdate: this.enableAutocompleteSuggestionsUpdate.bind(this),
inputInRange: this.inputInRange,
setInputInRange: this.setInputInRange.bind(this),
suggestionsIncomplete: this.suggestionsIncomplete,
};
}
}
@@ -1,6 +1,7 @@
import type { Monaco, monacoTypes } from '@grafana/ui';
import { getCompletions, DataProvider, CompletionType } from './completions';
import { CompletionType, getCompletions } from './completions';
import { DataProvider } from './data_provider';
import { getSituation } from './situation';
import { NeverCaseError } from './util';
@@ -69,6 +70,7 @@ export function getCompletionProvider(
column: position.column,
lineNumber: position.lineNumber,
};
dataProvider.monacoSettings.setInputInRange(model.getValueInRange(range));
// Check to see if the browser supports window.getSelection()
if (window.getSelection) {
@@ -82,6 +84,7 @@ export function getCompletionProvider(
const offset = model.getOffsetAt(positionClone);
const situation = getSituation(model.getValue(), offset);
const completionsPromise = situation != null ? getCompletions(situation, dataProvider) : Promise.resolve([]);
return completionsPromise.then((items) => {
// monaco by-default alphabetically orders the items.
// to stop it, we use a number-as-string sortkey,
@@ -102,7 +105,7 @@ export function getCompletionProvider(
}
: undefined,
}));
return { suggestions };
return { suggestions, incomplete: dataProvider.monacoSettings.suggestionsIncomplete };
});
};
@@ -1,14 +1,22 @@
import { render, screen } from '@testing-library/react';
import { render, screen, fireEvent } from '@testing-library/react';
import React, { SyntheticEvent } from 'react';
import { Provider } from 'react-redux';
import { SelectableValue } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { config } from '@grafana/runtime';
import { configureStore } from '../../../../store/configureStore';
import { getValueFromEventItem, PromSettings } from './PromSettings';
import { countError, getValueFromEventItem, PromSettings } from './PromSettings';
import { createDefaultConfigOptions } from './mocks';
beforeEach(() => {
jest.replaceProperty(config, 'featureToggles', {
prometheusCodeModeMetricNamesSearch: true,
});
});
describe('PromSettings', () => {
describe('getValueFromEventItem', () => {
describe('when called with undefined', () => {
@@ -79,5 +87,56 @@ describe('PromSettings', () => {
);
expect(screen.getByText('GET')).toBeInTheDocument();
});
it('should show a valid metric name count if codeModeMetricNamesSuggestionLimit is configured correctly', () => {
const options = defaultProps;
const store = configureStore();
const { getByTestId, queryByText } = render(
<Provider store={store}>
<PromSettings onOptionsChange={() => {}} options={options} />
</Provider>
);
const input = getByTestId(
selectors.components.DataSource.Prometheus.configPage.codeModeMetricNamesSuggestionLimit
);
// Non-negative integer
fireEvent.change(input, { target: { value: '3000' } });
fireEvent.blur(input);
expect(queryByText(countError)).not.toBeInTheDocument();
// Non-negative integer with scientific notation
fireEvent.change(input, { target: { value: '1e5' } });
fireEvent.blur(input);
expect(queryByText(countError)).not.toBeInTheDocument();
// Non-negative integer with decimal scientific notation
fireEvent.change(input, { target: { value: '1.4e4' } });
fireEvent.blur(input);
expect(queryByText(countError)).not.toBeInTheDocument();
});
it('should show the expected error when an invalid value is provided for codeModeMetricNamesSuggestionLimit', () => {
const options = defaultProps;
const store = configureStore();
const { getByTestId, queryByText } = render(
<Provider store={store}>
<PromSettings onOptionsChange={() => {}} options={options} />
</Provider>
);
const input = getByTestId(
selectors.components.DataSource.Prometheus.configPage.codeModeMetricNamesSuggestionLimit
);
// No negative values
fireEvent.change(input, { target: { value: '-50' } });
fireEvent.blur(input);
expect(queryByText(countError)).toBeInTheDocument();
// No negative values with scientific notation
fireEvent.change(input, { target: { value: '-5e5' } });
fireEvent.blur(input);
expect(queryByText(countError)).toBeInTheDocument();
});
});
});
@@ -10,10 +10,12 @@ import {
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { ConfigSubSection } from '@grafana/experimental';
import { config } from '@grafana/runtime';
import { getBackendSrv } from '@grafana/runtime/src';
import { InlineField, Input, Select, Switch, useTheme2 } from '@grafana/ui';
import { useUpdateDatasource } from '../../../../features/datasources/state';
import { SUGGESTIONS_LIMIT } from '../language_provider';
import { QueryEditorMode } from '../querybuilder/shared/types';
import { defaultPrometheusQueryOverlapWindow } from '../querycache/QueryCache';
import { PromApplication, PromBuildInfoResponse, PrometheusCacheLevel, PromOptions } from '../types';
@@ -56,7 +58,10 @@ export const DURATION_REGEX = /^$|^\d+(ms|[Mwdhmsy])$/;
// multiple duration input
export const MULTIPLE_DURATION_REGEX = /(\d+)(.+)/;
export const NON_NEGATIVE_INTEGER_REGEX = /^(0|[1-9]\d*)(\.\d+)?(e\+?\d+)?$/; // non-negative integers, including scientific notation
const durationError = 'Value is not valid, you can use number with time unit specifier: y, M, w, d, h, m, s';
export const countError = 'Value is not valid, you can use non-negative integers, including scientific notation';
/**
* Returns the closest version to what the user provided that we have in our PromFlavorVersions for the currently selected flavor
* Bugs: It will only reject versions that are a major release apart, so Mimir 2.x might get selected for Prometheus 2.8 if the user selects an incorrect flavor
@@ -169,6 +174,14 @@ export const PromSettings = (props: Props) => {
incrementalQueryOverlapWindow: '',
});
type ValidCount = {
codeModeMetricNamesSuggestionLimit: string;
};
const [validCount, updateValidCount] = useState<ValidCount>({
codeModeMetricNamesSuggestionLimit: '',
});
return (
<>
<ConfigSubSection title="Interval behaviour" className={styles.container}>
@@ -405,6 +418,49 @@ export const PromSettings = (props: Props) => {
</div>
</div>
{config.featureToggles.prometheusCodeModeMetricNamesSearch && (
<div className="gf-form-inline">
<div className="gf-form">
<InlineField
label="Metric names suggestion limit"
labelWidth={PROM_CONFIG_LABEL_WIDTH}
tooltip={
<>
The maximum number of metric names that may appear as autocomplete suggestions in the query
editor&apos;s Code mode.
</>
}
interactive={true}
disabled={options.readOnly}
>
<>
<Input
className="width-20"
value={options.jsonData.codeModeMetricNamesSuggestionLimit}
onChange={onChangeHandler('codeModeMetricNamesSuggestionLimit', options, onOptionsChange)}
spellCheck={false}
placeholder={SUGGESTIONS_LIMIT.toString()}
onBlur={(e) =>
updateValidCount({
...validCount,
codeModeMetricNamesSuggestionLimit: e.currentTarget.value,
})
}
data-testid={
selectors.components.DataSource.Prometheus.configPage.codeModeMetricNamesSuggestionLimit
}
/>
{validateInput(
validCount.codeModeMetricNamesSuggestionLimit,
NON_NEGATIVE_INTEGER_REGEX,
countError
)}
</>
</InlineField>
</div>
</div>
)}
<div className="gf-form-inline">
<div className="gf-form max-width-30">
<InlineField
@@ -42,7 +42,7 @@ import {
import { addLabelToQuery } from './add_label_to_query';
import { AnnotationQueryEditor } from './components/AnnotationQueryEditor';
import PrometheusLanguageProvider from './language_provider';
import PrometheusLanguageProvider, { SUGGESTIONS_LIMIT } from './language_provider';
import {
expandRecordingRules,
getClientCacheDurationInMinutes,
@@ -97,6 +97,7 @@ export class PrometheusDatasource
exemplarsAvailable: boolean;
cacheLevel: PrometheusCacheLevel;
cache: QueryCache<PromQuery>;
metricNamesAutocompleteSuggestionLimit: number;
constructor(
instanceSettings: DataSourceInstanceSettings<PromOptions>,
@@ -127,6 +128,8 @@ export class PrometheusDatasource
this.variables = new PrometheusVariableSupport(this, this.templateSrv);
this.exemplarsAvailable = true;
this.cacheLevel = instanceSettings.jsonData.cacheLevel ?? PrometheusCacheLevel.Low;
this.metricNamesAutocompleteSuggestionLimit =
instanceSettings.jsonData.codeModeMetricNamesSuggestionLimit ?? SUGGESTIONS_LIMIT;
this.cache = new QueryCache({
getTargetSignature: this.getPrometheusTargetSignature.bind(this),
@@ -0,0 +1,161 @@
import { render, screen, fireEvent, createEvent } from '@testing-library/react';
import { cloneDeep, defaultsDeep } from 'lodash';
import React from 'react';
import { PluginMeta, PluginType } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { config } from '@grafana/runtime';
import { CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT } from '../../components/monaco-query-field/monaco-completion-provider/data_provider';
import { PromQueryEditorProps } from '../../components/types';
import { PrometheusDatasource } from '../../datasource';
import PromQlLanguageProvider from '../../language_provider';
import { EmptyLanguageProviderMock } from '../../language_provider.mock';
import { PromQuery } from '../../types';
import { QueryEditorMode } from '../shared/types';
import { PromQueryEditorSelector } from './PromQueryEditorSelector';
beforeEach(() => {
jest.replaceProperty(config, 'featureToggles', {
prometheusCodeModeMetricNamesSearch: true,
});
});
// We need to mock this because it seems jest has problem importing monaco in tests
jest.mock('../../components/monaco-query-field/MonacoQueryFieldWrapper', () => {
return {
MonacoQueryFieldWrapper: () => {
return 'MonacoQueryFieldWrapper';
},
};
});
jest.mock('app/core/store', () => {
return {
get() {
return undefined;
},
set() {},
getObject(key: string, defaultValue: unknown) {
return defaultValue;
},
};
});
jest.mock('@grafana/runtime', () => {
return {
...jest.requireActual('@grafana/runtime'),
reportInteraction: jest.fn(),
};
});
const defaultQuery = {
refId: 'A',
expr: 'metric{label1="foo", label2="bar"}',
};
const defaultMeta: PluginMeta = {
id: '',
name: '',
type: PluginType.datasource,
info: {
author: {
name: 'tester',
},
description: 'testing',
links: [],
logos: {
large: '',
small: '',
},
screenshots: [],
updated: '',
version: '',
},
module: '',
baseUrl: '',
};
const getDefaultDatasource = (jsonDataOverrides = {}) =>
new PrometheusDatasource(
{
id: 1,
uid: 'myDataSourceUid',
type: 'prometheus',
name: 'prom-test',
access: 'proxy',
url: '',
jsonData: jsonDataOverrides,
meta: defaultMeta,
readOnly: false,
},
undefined,
new EmptyLanguageProviderMock() as unknown as PromQlLanguageProvider
);
const defaultProps = {
datasource: getDefaultDatasource(),
query: defaultQuery,
onRunQuery: () => {},
onChange: () => {},
};
const autocompleteInfoSelector = selectors.components.DataSource.Prometheus.queryEditor.code.metricsCountInfo;
describe('PromQueryEditorSelector', () => {
it('does not show autocomplete info when the code editor first displays', async () => {
const { queryByTestId } = renderWithCodeMode();
expect(await screen.findByText('MonacoQueryFieldWrapper')).toBeInTheDocument();
expect(queryByTestId(autocompleteInfoSelector)).not.toBeInTheDocument();
});
it('shows autocomplete info when the expected event fires', async () => {
const { findByTestId } = renderWithCodeMode();
fireEvent(
window,
createEvent(
CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT,
window,
{
detail: { limit: 100, datasourceUid: 'myDataSourceUid' },
},
{ EventType: 'CustomEvent' }
)
);
expect(await screen.findByText('MonacoQueryFieldWrapper')).toBeInTheDocument();
const autocompleteInfo = await findByTestId(autocompleteInfoSelector);
expect(autocompleteInfo).toBeInTheDocument();
});
it('does not show autocomplete info when the triggering event refers to a different data source', async () => {
const { queryByTestId } = renderWithCodeMode();
fireEvent(
window,
createEvent(
CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT,
window,
{
detail: { limit: 100, datasourceUid: 'theWrongUid' },
},
{ EventType: 'CustomEvent' }
)
);
expect(await screen.findByText('MonacoQueryFieldWrapper')).toBeInTheDocument();
const autocompleteInfo = await queryByTestId(autocompleteInfoSelector);
expect(autocompleteInfo).not.toBeInTheDocument();
});
});
function renderWithCodeMode() {
return renderWithProps({ editorMode: QueryEditorMode.Code, expr: 'my_metric' });
}
function renderWithProps(overrides?: Partial<PromQuery>, componentProps: Partial<PromQueryEditorProps> = {}) {
const query = defaultsDeep(overrides ?? {}, cloneDeep(defaultQuery));
const onChange = jest.fn();
const allProps = { ...defaultProps, ...componentProps };
const stuff = render(<PromQueryEditorSelector {...allProps} query={query} onChange={onChange} />);
return { onChange, ...stuff };
}
@@ -0,0 +1,69 @@
import React, { useState, useEffect, useCallback } from 'react';
import { selectors } from '@grafana/e2e-selectors';
import { config } from '@grafana/runtime';
import { IconButton, Text, Stack } from '@grafana/ui';
import {
CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT,
isSuggestionsIncompleteEvent,
} from '../../components/monaco-query-field/monaco-completion-provider/data_provider';
import { PromQueryEditorProps } from '../../components/types';
import { QueryEditorMode } from '../shared/types';
interface Props {
datasourceUid: PromQueryEditorProps['datasource']['uid'];
editorMode: QueryEditorMode;
}
export function PromQueryCodeEditorAutocompleteInfo(props: Readonly<Props>) {
const [autocompleteLimit, setAutocompleteLimit] = useState('n');
const [autocompleteLimitExceeded, setAutocompleteLimitExceeded] = useState(false);
const handleSuggestionsIncompleteEvent = useCallback(
(e: Event) => {
if (!isSuggestionsIncompleteEvent(e)) {
return;
}
if (e.detail.datasourceUid === props.datasourceUid) {
setAutocompleteLimitExceeded(true);
setAutocompleteLimit(e.detail.limit.toString());
}
},
[props.datasourceUid]
);
useEffect(() => {
addEventListener(CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT, handleSuggestionsIncompleteEvent);
return () => {
removeEventListener(CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT, handleSuggestionsIncompleteEvent);
};
}, [handleSuggestionsIncompleteEvent]);
const showCodeModeAutocompleteDisclaimer = (): boolean => {
return (
Boolean(config.featureToggles.prometheusCodeModeMetricNamesSearch) &&
props.editorMode === QueryEditorMode.Code &&
autocompleteLimitExceeded
);
};
if (!showCodeModeAutocompleteDisclaimer()) {
return null;
}
return (
<div data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsCountInfo}>
<Stack direction="row" gap={1}>
<Text color="secondary" element="p" italic={true}>
Autocomplete suggestions limited
</Text>
<IconButton
name="info-circle"
tooltip={`The number of metric names exceeds the autocomplete limit. Only the ${autocompleteLimit}-most relevant metrics are displayed. You can adjust the threshold in the data source settings.`}
/>
</Stack>
</div>
);
}
@@ -21,6 +21,7 @@ import { changeEditorMode, getQueryWithDefaults } from '../state';
import { PromQueryBuilderContainer } from './PromQueryBuilderContainer';
import { PromQueryBuilderOptions } from './PromQueryBuilderOptions';
import { PromQueryCodeEditor } from './PromQueryCodeEditor';
import { PromQueryCodeEditorAutocompleteInfo } from './PromQueryCodeEditorAutocompleteInfo';
export const FORMAT_OPTIONS: Array<SelectableValue<PromQueryFormat>> = [
{ label: 'Time series', value: 'time_series' },
@@ -138,6 +139,7 @@ export const PromQueryEditorSelector = React.memo<Props>((props) => {
Run queries
</Button>
)}
<PromQueryCodeEditorAutocompleteInfo datasourceUid={props.datasource.uid} editorMode={editorMode} />
<div data-testid={selectors.components.DataSource.Prometheus.queryEditor.editorToggle}>
<QueryEditorModeToggle mode={editorMode} onChange={onEditorModeChange} />
</div>
@@ -51,6 +51,7 @@ export interface PromOptions extends DataSourceJsonData {
disableRecordingRules?: boolean;
sigV4Auth?: boolean;
oauthPassThru?: boolean;
codeModeMetricNamesSuggestionLimit?: number;
}
export type ExemplarTraceIdDestination = {