Prometheus: Use new language provider methods in visual query builder (#106204)

* refactor language provider

* update tests

* more tests

* betterer and api endpoints

* copilot updates

* betterer

* remove default value

* prettier

* introduce new methods

* provide unit tests for labelValues

* update metadata fetch

* move all cache related stuff in caching.ts

* provide interface

* provide deprecation messages

* unit tests for new interface

* separation of concerns

* update tests

* fix unit test

* fix some types

* Revert "fix some types"

This reverts commit 7e64b93b5f.

* revert interface usage

* betterer

* use PrometheusLanguageProviderInterface in everywhere

* introduce resource clients

* unit tests

* act accordingly with the feature toggle

* some more unit tests

* add feature toggle

* Revert "add feature toggle"

This reverts commit 5c93ac324f.

* remove feature toggle

* update tests

* backward compatibility

* fix scope issues

* comment update

* stronger types

* prettier

* betterer

* use new methods in metrics browser and query field

* always return data

* Revert "always return data"

This reverts commit 38e493c189.

* Revert "Revert "always return data""

This reverts commit b5d3b5d2b0.

* handle error

* lint

* use new method in query builder

* fix metrics modal tests too

* use labelValues method while searching in combobox

* update metrics modal regex search

* lint

* fix unit test

* introduce resource clients and better refactoring

* prettier

* type fixes

* betterer

* no empty matcher for series calls

* better matchers

* add additional tests

* proper match string for series

* introduce series cache

* introduce series cache for series label values

* lint

* cache values too

* utf8 safe label values query with series endpoint

* fix unit tests

* caching for labels api client

* betterer

* fix errors
This commit is contained in:
ismail simsek
2025-06-18 19:05:16 +02:00
committed by GitHub
parent 4ba09df7dd
commit 7e5122d580
15 changed files with 294 additions and 178 deletions
+5 -1
View File
@@ -480,7 +480,11 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
[0, 0, 0, "Unexpected any. Specify a different type.", "3"]
[0, 0, 0, "Unexpected any. Specify a different type.", "3"],
[0, 0, 0, "Unexpected any. Specify a different type.", "4"],
[0, 0, 0, "Unexpected any. Specify a different type.", "5"],
[0, 0, 0, "Unexpected any. Specify a different type.", "6"],
[0, 0, 0, "Unexpected any. Specify a different type.", "7"]
],
"packages/grafana-prometheus/src/types.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
@@ -143,6 +143,7 @@ describe('PromVariableQueryEditor', () => {
metricsMetadata: {},
getLabelValues: jest.fn().mockImplementation(() => ['that']),
fetchLabelsWithMatch: jest.fn().mockImplementation(() => Promise.resolve({ those: 'those' })),
queryLabelValues: jest.fn().mockResolvedValue([]),
} as Partial<PrometheusLanguageProviderInterface>,
getTagKeys: jest
.fn()
@@ -21,4 +21,9 @@ export class EmptyLanguageProviderMock {
fetchLabels = jest.fn();
loadMetricsMetadata = jest.fn();
retrieveMetrics = jest.fn().mockReturnValue(['metric']);
queryLabelKeys = jest.fn().mockResolvedValue([]);
queryLabelValues = jest.fn().mockResolvedValue([]);
retrieveLabelKeys = jest.fn().mockReturnValue([]);
retrieveMetricsMetadata = jest.fn().mockReturnValue({});
queryMetricsMetadata = jest.fn().mockResolvedValue({});
}
@@ -71,7 +71,7 @@ async function loadGroupByLabels(
}
const expr = modeller.renderLabels(labels);
const result = await datasource.languageProvider.fetchLabelsWithMatch(timeRange, expr);
const result = await datasource.languageProvider.queryLabelKeys(timeRange, expr);
return Object.keys(result).map((x) => ({
label: x,
@@ -3,9 +3,12 @@ import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
import { DataSourceInstanceSettings, MetricFindValue } from '@grafana/data';
import { DataSourceInstanceSettings } from '@grafana/data';
import { PrometheusDatasource } from '../../datasource';
import { PrometheusLanguageProviderInterface } from '../../language_provider';
import { EmptyLanguageProviderMock } from '../../language_provider.mock';
import { getMockTimeRange } from '../../test/__mocks__/datasource';
import { PromOptions } from '../../types';
import { MetricCombobox, MetricComboboxProps } from './MetricCombobox';
@@ -34,36 +37,13 @@ describe('MetricCombobox', () => {
jsonData: { httpMethod: 'GET' },
} as unknown as DataSourceInstanceSettings<PromOptions>;
const mockDatasource = new PrometheusDatasource(instanceSettings);
const mockLanguageProvider = new EmptyLanguageProviderMock() as unknown as PrometheusLanguageProviderInterface;
const mockDatasource = new PrometheusDatasource(instanceSettings, undefined, mockLanguageProvider);
// Options returned when user first opens the combobox - returned by onGetMetrics
const initialMockValues = [{ label: 'top_metric_one' }, { label: 'top_metric_two' }, { label: 'top_metric_three' }];
const mockOnGetMetrics = jest.fn(() => Promise.resolve(initialMockValues.map((v) => ({ value: v.label }))));
// Options returned when user searches for a metric
const mockValues = [{ label: 'random_metric' }, { label: 'unique_metric' }, { label: 'more_unique_metric' }];
mockDatasource.metricFindQuery = jest.fn((query: string) => {
// return Promise.resolve([]);
// Use the label values regex to get the values inside the label_values function call
const labelValuesRegex = /^label_values\((?:(.+),\s*)?([a-zA-Z_][a-zA-Z0-9_]*)\)\s*$/;
const queryValueArray = query.match(labelValuesRegex) as RegExpMatchArray;
const queryValueRaw = queryValueArray[1];
// Remove the wrapping regex
const queryValue = queryValueRaw.substring(queryValueRaw.indexOf('".*') + 3, queryValueRaw.indexOf('.*"'));
// Run the regex that we'd pass into prometheus API against the strings in the test
return Promise.resolve(
mockValues
.filter((value) => value.label.match(queryValue))
.map((result) => {
return {
text: result.label,
};
}) as MetricFindValue[]
);
});
const mockOnChange = jest.fn();
const defaultProps: MetricComboboxProps = {
@@ -78,6 +58,7 @@ describe('MetricCombobox', () => {
datasource: mockDatasource,
labelsFilters: [],
variableEditor: false,
timeRange: getMockTimeRange(),
};
beforeEach(() => {
@@ -103,6 +84,9 @@ describe('MetricCombobox', () => {
});
it('fetches metrics for the users query', async () => {
// Mock the queryLabelValues to return the expected metric
mockDatasource.languageProvider.queryLabelValues = jest.fn().mockResolvedValue(['unique_metric']);
render(<MetricCombobox {...defaultProps} />);
const combobox = screen.getByPlaceholderText('Select metric');
@@ -113,8 +97,12 @@ describe('MetricCombobox', () => {
expect(item).toBeInTheDocument();
// This should be asserted by the above check, but double check anyway
// This is the actual argument, created by formatKeyValueStringsForLabelValuesQuery()
expect(mockDatasource.metricFindQuery).toHaveBeenCalledWith('label_values({__name__=~".*unique.*"},__name__)');
// This is the actual argument, created by formatKeyValueStrings()
expect(mockDatasource.languageProvider.queryLabelValues).toHaveBeenCalledWith(
expect.anything(),
'__name__',
'{__name__=~".*unique.*"}'
);
});
it('calls onChange with the correct value when a metric is selected', async () => {
@@ -1,11 +1,12 @@
import { useCallback, useState } from 'react';
import { SelectableValue } from '@grafana/data';
import { SelectableValue, TimeRange } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n';
import { EditorField, EditorFieldGroup, InputGroup } from '@grafana/plugin-ui';
import { Button, InlineField, InlineFieldRow, Combobox, ComboboxOption } from '@grafana/ui';
import { METRIC_LABEL } from '../../components/metrics-browser/types';
import { PrometheusDatasource } from '../../datasource';
import { regexifyLabelValuesQueryString } from '../parsingUtils';
import { QueryBuilderLabelFilter } from '../shared/types';
@@ -23,6 +24,7 @@ export interface MetricComboboxProps {
labelsFilters: QueryBuilderLabelFilter[];
onBlur?: () => void;
variableEditor?: boolean;
timeRange: TimeRange;
}
export function MetricCombobox({
@@ -32,6 +34,7 @@ export function MetricCombobox({
onGetMetrics,
labelsFilters,
variableEditor,
timeRange,
}: Readonly<MetricComboboxProps>) {
const [metricsModalOpen, setMetricsModalOpen] = useState(false);
@@ -40,17 +43,18 @@ export function MetricCombobox({
*/
const getMetricLabels = useCallback(
async (query: string) => {
const results = await datasource.metricFindQuery(formatKeyValueStringsForLabelValuesQuery(query, labelsFilters));
const match = formatKeyValueStrings(query, labelsFilters);
const results = await datasource.languageProvider.queryLabelValues(timeRange, METRIC_LABEL, match);
const resultsOptions = results.map((result) => {
return {
label: result.text,
value: result.text,
label: result,
value: result,
};
});
return resultsOptions;
},
[datasource, labelsFilters]
[datasource.languageProvider, labelsFilters, timeRange]
);
const onComboboxChange = useCallback(
@@ -130,6 +134,7 @@ export function MetricCombobox({
query={query}
onChange={onChange}
initialMetrics={loadMetricsExplorerMetrics}
timeRange={timeRange}
/>
)}
{variableEditor ? (
@@ -165,7 +170,7 @@ export const formatPrometheusLabelFiltersToString = (
): string => {
const filterArray = labelsFilters ? formatPrometheusLabelFilters(labelsFilters) : [];
return `label_values({__name__=~".*${queryString}"${filterArray ? filterArray.join('') : ''}},__name__)`;
return `{__name__=~".*${queryString}"${filterArray ? filterArray.join('') : ''}}`;
};
export const formatPrometheusLabelFilters = (labelsFilters: QueryBuilderLabelFilter[]): string[] => {
@@ -177,7 +182,7 @@ export const formatPrometheusLabelFilters = (labelsFilters: QueryBuilderLabelFil
/**
* Reformat the query string and label filters to return all valid results for current query editor state
*/
const formatKeyValueStringsForLabelValuesQuery = (query: string, labelsFilters?: QueryBuilderLabelFilter[]): string => {
const formatKeyValueStrings = (query: string, labelsFilters?: QueryBuilderLabelFilter[]): string => {
const queryString = regexifyLabelValuesQueryString(query);
return formatPrometheusLabelFiltersToString(queryString, labelsFilters);
@@ -25,16 +25,11 @@ const createMockDatasource = () => {
hasLabelsMatchAPISupport: jest.fn().mockReturnValue(true),
lookupsDisabled: false,
languageProvider: {
fetchLabels: jest.fn().mockResolvedValue({}),
getLabelKeys: jest.fn().mockReturnValue(['label1', 'label2']),
fetchLabelsWithMatch: jest.fn().mockResolvedValue({ label1: [], label2: [] }),
fetchSeries: jest.fn().mockResolvedValue([{ label1: 'value1' }]),
fetchSeriesValuesWithMatch: jest.fn().mockResolvedValue(['value1', 'value2']),
getLabelValues: jest.fn().mockResolvedValue(['value1', 'value2']),
getSeries: jest.fn().mockResolvedValue({ __name__: ['metric1', 'metric2'] }),
getSeriesValues: jest.fn().mockResolvedValue(['metric1', 'metric2']),
loadMetricsMetadata: jest.fn().mockResolvedValue({}),
metricsMetadata: { metric1: { type: 'counter', help: 'help text' } },
queryLabelKeys: jest.fn().mockResolvedValue(['label1', 'label2']),
retrieveLabelKeys: jest.fn().mockReturnValue(['label1', 'label2']),
queryLabelValues: jest.fn().mockResolvedValue(['value1', 'value2']),
queryMetricsMetadata: jest.fn().mockResolvedValue({ metric1: { type: 'counter', help: 'help text' } }),
retrieveMetricsMetadata: jest.fn().mockResolvedValue({ metric1: { type: 'counter', help: 'help text' } }),
},
};
return datasource as unknown as PrometheusDatasource;
@@ -211,9 +206,9 @@ describe('MetricsLabelsSection', () => {
await onGetLabelNamesCallback({});
// Check that fetchLabels was called
expect(datasource.languageProvider.fetchLabels).toHaveBeenCalledWith(defaultTimeRange);
expect(datasource.languageProvider.queryLabelKeys).toHaveBeenCalledWith(defaultTimeRange);
// Check that getLabelKeys was called
expect(datasource.languageProvider.getLabelKeys).toHaveBeenCalled();
expect(datasource.languageProvider.retrieveLabelKeys).toHaveBeenCalled();
});
it('should handle onGetLabelNames with metric correctly', async () => {
@@ -237,7 +232,7 @@ describe('MetricsLabelsSection', () => {
await onGetLabelNamesCallback({});
// Check that fetchLabelsWithMatch was called
expect(datasource.languageProvider.fetchLabelsWithMatch).toHaveBeenCalled();
expect(datasource.languageProvider.queryLabelKeys).toHaveBeenCalled();
});
it('should handle getLabelValuesAutocompleteSuggestions correctly', async () => {
@@ -261,7 +256,7 @@ describe('MetricsLabelsSection', () => {
await getLabelValuesCallback('val', 'label1');
// Check that fetchSeriesValuesWithMatch was called (since hasLabelsMatchAPISupport is true)
expect(datasource.languageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled();
expect(datasource.languageProvider.queryLabelValues).toHaveBeenCalled();
});
it('should handle onGetLabelValues with no metric correctly', async () => {
@@ -286,7 +281,7 @@ describe('MetricsLabelsSection', () => {
await onGetLabelValuesCallback({ label: 'label1' });
// Check that getLabelValues was called
expect(datasource.languageProvider.getLabelValues).toHaveBeenCalledWith(defaultTimeRange, 'label1');
expect(datasource.languageProvider.queryLabelValues).toHaveBeenCalledWith(defaultTimeRange, 'label1');
});
it('should handle onGetLabelValues with metric correctly', async () => {
@@ -310,7 +305,7 @@ describe('MetricsLabelsSection', () => {
await onGetLabelValuesCallback({ label: 'label1' });
// Check that fetchSeriesValuesWithMatch was called (since hasLabelsMatchAPISupport is true)
expect(datasource.languageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled();
expect(datasource.languageProvider.queryLabelValues).toHaveBeenCalled();
});
it('should handle onGetLabelValues with no label correctly', async () => {
@@ -368,7 +363,6 @@ describe('MetricsLabelsSection', () => {
it('should load metrics metadata if not present', async () => {
const onChange = jest.fn();
const datasource = createMockDatasource();
datasource.languageProvider.metricsMetadata = undefined;
render(
<MetricsLabelsSection
@@ -386,6 +380,6 @@ describe('MetricsLabelsSection', () => {
await onGetMetricsCallback();
// loadMetricsMetadata should be called
expect(datasource.languageProvider.loadMetricsMetadata).toHaveBeenCalled();
expect(datasource.languageProvider.queryMetricsMetadata).toHaveBeenCalled();
});
});
@@ -64,8 +64,8 @@ export function MetricsLabelsSection({
const onGetLabelNames = async (forLabel: Partial<QueryBuilderLabelFilter>): Promise<SelectableValue[]> => {
// If no metric we need to use a different method
if (!query.metric) {
await datasource.languageProvider.fetchLabels(timeRange);
return datasource.languageProvider.getLabelKeys().map((k) => ({ value: k }));
await datasource.languageProvider.queryLabelKeys(timeRange);
return datasource.languageProvider.retrieveLabelKeys().map((k) => ({ value: k }));
}
const labelsToConsider = query.labels.filter((x) => x !== forLabel);
@@ -73,15 +73,15 @@ export function MetricsLabelsSection({
labelsToConsider.push({ label: '__name__', op: '=', value: query.metric });
const expr = promQueryModeller.renderLabels(labelsToConsider);
let labelsIndex: Record<string, string[]> = await datasource.languageProvider.fetchLabelsWithMatch(timeRange, expr);
let labelsIndex: string[] = await datasource.languageProvider.queryLabelKeys(timeRange, expr);
// filter out already used labels
return Object.keys(labelsIndex)
return labelsIndex
.filter((labelName) => !labelsToConsider.find((filter) => filter.label === labelName))
.map((k) => ({ value: k }));
};
const getLabelValuesAutocompleteSuggestions = (
const getLabelValuesAutocompleteSuggestions = async (
queryString?: string,
labelName?: string
): Promise<SelectableValue[]> => {
@@ -102,63 +102,8 @@ export function MetricsLabelsSection({
value: datasource.interpolateString(labelObject.value),
}));
const expr = promQueryModeller.renderLabels(interpolatedLabelsToConsider);
let response: Promise<SelectableValue[]>;
if (datasource.hasLabelsMatchAPISupport()) {
response = getLabelValuesFromLabelValuesAPI(forLabel, expr);
} else {
response = getLabelValuesFromSeriesAPI(forLabel, expr);
}
return response.then((response: SelectableValue[]) => {
truncateResult(response);
return response;
});
};
/**
* Helper function to fetch and format label value results from legacy API
* @param forLabel
* @param promQLExpression
*/
const getLabelValuesFromSeriesAPI = (
forLabel: Partial<QueryBuilderLabelFilter>,
promQLExpression: string
): Promise<SelectableValue[]> => {
if (!forLabel.label) {
return Promise.resolve([]);
}
const result = datasource.languageProvider.fetchSeries(timeRange, promQLExpression);
const forLabelInterpolated = datasource.interpolateString(forLabel.label);
return result.then((result) => {
// This query returns duplicate values, scrub them out
const set = new Set<string>();
result.forEach((labelValue) => {
const labelNameString = labelValue[forLabelInterpolated];
set.add(labelNameString);
});
return Array.from(set).map((labelValues: string) => ({ label: labelValues, value: labelValues }));
});
};
/**
* Helper function to fetch label values from a promql string expression and a label
* @param forLabel
* @param promQLExpression
*/
const getLabelValuesFromLabelValuesAPI = (
forLabel: Partial<QueryBuilderLabelFilter>,
promQLExpression: string
): Promise<SelectableValue[]> => {
if (!forLabel.label) {
return Promise.resolve([]);
}
const requestId = `[${datasource.uid}][${query.metric}][${forLabel.label}][${forLabel.op}]`;
return datasource.languageProvider
.fetchSeriesValuesWithMatch(timeRange, forLabel.label, promQLExpression, requestId)
.then((response) => response.map((v) => ({ value: v, label: v })));
const values = await datasource.languageProvider.queryLabelValues(timeRange, forLabel.label, expr);
return truncateResult(values).map(toSelectableValue);
};
/**
@@ -172,7 +117,7 @@ export function MetricsLabelsSection({
}
// If no metric is selected, we can get the raw list of labels
if (!query.metric) {
return (await datasource.languageProvider.getLabelValues(timeRange, forLabel.label)).map((v) => ({ value: v }));
return (await datasource.languageProvider.queryLabelValues(timeRange, forLabel.label)).map((v) => ({ value: v }));
}
const labelsToConsider = query.labels.filter((x) => x !== forLabel);
@@ -186,12 +131,7 @@ export function MetricsLabelsSection({
}));
const expr = promQueryModeller.renderLabels(interpolatedLabelsToConsider);
if (datasource.hasLabelsMatchAPISupport()) {
return getLabelValuesFromLabelValuesAPI(forLabel, expr);
} else {
return getLabelValuesFromSeriesAPI(forLabel, expr);
}
return (await datasource.languageProvider.queryLabelValues(timeRange, forLabel.label, expr)).map(toSelectableValue);
};
const onGetMetrics = useCallback(() => {
@@ -209,6 +149,7 @@ export function MetricsLabelsSection({
metricLookupDisabled={datasource.lookupsDisabled}
onBlur={onBlur ? onBlur : () => {}}
variableEditor={variableEditor}
timeRange={timeRange}
/>
<LabelFilters
debounceDuration={getDebounceTimeInMilliseconds(datasource.cacheLevel)}
@@ -228,6 +169,7 @@ export function MetricsLabelsSection({
* exists.
* @param datasource
* @param query
* @param timeRange
*/
async function getMetrics(
datasource: PrometheusDatasource,
@@ -236,26 +178,19 @@ async function getMetrics(
): Promise<Array<{ value: string; description?: string }>> {
// Makes sure we loaded the metadata for metrics. Usually this is done in the start() method of the provider but we
// don't use it with the visual builder and there is no need to run all the start() setup anyway.
if (!datasource.languageProvider.metricsMetadata) {
await datasource.languageProvider.loadMetricsMetadata();
}
// Error handling for when metrics metadata returns as undefined
if (!datasource.languageProvider.metricsMetadata) {
datasource.languageProvider.metricsMetadata = {};
const metadata = datasource.languageProvider.retrieveMetricsMetadata();
if (Object.keys(metadata).length === 0) {
await datasource.languageProvider.queryMetricsMetadata();
}
let metrics: string[];
if (query.labels.length > 0) {
const expr = promQueryModeller.renderLabels(query.labels);
metrics = (await datasource.languageProvider.getSeriesValues(timeRange, '__name__', expr)) ?? [];
} else {
metrics = (await datasource.languageProvider.getLabelValues(timeRange, '__name__')) ?? [];
}
const expr = promQueryModeller.renderLabels(query.labels);
metrics =
(await datasource.languageProvider.queryLabelValues(timeRange, '__name__', expr === '' ? undefined : expr)) ?? [];
return metrics.map((m) => ({
value: m,
description: getMetadataString(m, datasource.languageProvider.metricsMetadata!),
description: getMetadataString(m, datasource.languageProvider.retrieveMetricsMetadata()),
}));
}
@@ -266,3 +201,10 @@ export function getMetadataString(metric: string, metadata: PromMetricsMetadata)
const { type, help } = metadata[metric];
return `${type.toUpperCase()}: ${help}`;
}
function toSelectableValue(lv: string) {
return {
label: lv,
value: lv,
};
}
@@ -89,7 +89,9 @@ describe('PromQueryBuilder', () => {
it('tries to load metrics without labels', async () => {
const { languageProvider, container } = setup();
await openMetricSelect(container);
await waitFor(() => expect(languageProvider.getLabelValues).toHaveBeenCalledWith(expect.anything(), '__name__'));
await waitFor(() =>
expect(languageProvider.queryLabelValues).toHaveBeenCalledWith(expect.anything(), '__name__', undefined)
);
});
it('tries to load metrics with labels', async () => {
@@ -99,7 +101,7 @@ describe('PromQueryBuilder', () => {
});
await openMetricSelect(container);
await waitFor(() =>
expect(languageProvider.getSeriesValues).toHaveBeenCalledWith(
expect(languageProvider.queryLabelValues).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
'{label_name="label_value"}'
@@ -119,10 +121,7 @@ describe('PromQueryBuilder', () => {
const { languageProvider } = setup();
await openLabelNameSelect();
await waitFor(() =>
expect(languageProvider.fetchLabelsWithMatch).toHaveBeenCalledWith(
expect.anything(),
'{__name__="random_metric"}'
)
expect(languageProvider.queryLabelKeys).toHaveBeenCalledWith(expect.anything(), '{__name__="random_metric"}')
);
});
@@ -143,7 +142,7 @@ describe('PromQueryBuilder', () => {
});
await openLabelNameSelect(1);
await waitFor(() =>
expect(languageProvider.fetchLabelsWithMatch).toHaveBeenCalledWith(
expect(languageProvider.queryLabelKeys).toHaveBeenCalledWith(
expect.anything(),
'{label_name="label_value", __name__="random_metric"}'
)
@@ -157,7 +156,7 @@ describe('PromQueryBuilder', () => {
metric: '',
});
await openLabelNameSelect();
await waitFor(() => expect(languageProvider.fetchLabels).toBeCalled());
await waitFor(() => expect(languageProvider.queryLabelKeys).toBeCalled());
});
it('shows hints for histogram metrics', async () => {
@@ -298,10 +297,7 @@ describe('PromQueryBuilder', () => {
});
await openLabelNameSelect();
await waitFor(() =>
expect(languageProvider.fetchLabelsWithMatch).toHaveBeenCalledWith(
expect.anything(),
'{__name__="random_metric"}'
)
expect(languageProvider.queryLabelKeys).toHaveBeenCalledWith(expect.anything(), '{__name__="random_metric"}')
);
});
@@ -328,7 +324,7 @@ describe('PromQueryBuilder', () => {
);
await openLabelNameSelect(1);
await waitFor(() =>
expect(languageProvider.fetchLabelsWithMatch).toHaveBeenCalledWith(
expect(languageProvider.queryLabelKeys).toHaveBeenCalledWith(
expect.anything(),
'{label_name="label_value", __name__="random_metric"}'
)
@@ -7,6 +7,7 @@ import { DataSourceInstanceSettings, DataSourcePluginMeta } from '@grafana/data'
import { PrometheusDatasource } from '../../../datasource';
import { PrometheusLanguageProviderInterface } from '../../../language_provider';
import { EmptyLanguageProviderMock } from '../../../language_provider.mock';
import { getMockTimeRange } from '../../../test/__mocks__/datasource';
import { PromOptions } from '../../../types';
import { PromVisualQuery } from '../../types';
@@ -245,17 +246,17 @@ const listOfMetrics: string[] = [
function createDatasource(withLabels?: boolean) {
const languageProvider = new EmptyLanguageProviderMock() as unknown as PrometheusLanguageProviderInterface;
// display different results if their are labels selected in the PromVisualQuery
// display different results if their labels are selected in the PromVisualQuery
if (withLabels) {
languageProvider.metricsMetadata = {
languageProvider.retrieveMetricsMetadata = jest.fn().mockReturnValue({
'with-labels': {
type: 'with-labels-type',
help: 'with-labels-help',
},
};
});
} else {
// all metrics
languageProvider.metricsMetadata = {
languageProvider.retrieveMetricsMetadata = jest.fn().mockReturnValue({
'all-metrics': {
type: 'all-metrics-type',
help: 'all-metrics-help',
@@ -273,7 +274,7 @@ function createDatasource(withLabels?: boolean) {
help: 'a native histogram',
},
// missing metadata for other metrics is tested for, see below
};
});
}
const datasource = new PrometheusDatasource(
@@ -296,6 +297,7 @@ function createProps(query: PromVisualQuery, datasource: PrometheusDatasource, m
onClose: jest.fn(),
query: query,
initialMetrics: metrics,
timeRange: getMockTimeRange(),
};
}
@@ -307,5 +309,5 @@ function setup(query: PromVisualQuery, metrics: string[], withlabels?: boolean)
// render the modal only
const { container } = render(<MetricsModal {...props} />);
return container;
return { container, datasource };
}
@@ -20,6 +20,9 @@ import {
} from '@grafana/ui';
import { getDebounceTimeInMilliseconds } from '../../../caching';
import { METRIC_LABEL } from '../../../components/metrics-browser/types';
import { regexifyLabelValuesQueryString } from '../../parsingUtils';
import { formatPrometheusLabelFilters } from '../MetricCombobox';
import { AdditionalSettings } from './AdditionalSettings';
import { FeedbackLink } from './FeedbackLink';
@@ -29,7 +32,6 @@ import {
calculatePageList,
calculateResultsPerPage,
displayedMetrics,
getBackendSearchMetrics,
placeholders,
promTypes,
setMetrics,
@@ -47,7 +49,7 @@ import { PromFilterOption } from './types';
import { debouncedFuzzySearch } from './uFuzzy';
export const MetricsModal = (props: MetricsModalProps) => {
const { datasource, isOpen, onClose, onChange, query, initialMetrics } = props;
const { datasource, isOpen, onClose, onChange, query, initialMetrics, timeRange } = props;
const [state, dispatch] = useReducer(stateSlice.reducer, initialState(query));
@@ -99,17 +101,25 @@ export const MetricsModal = (props: MetricsModalProps) => {
debounce(async (metricText: string) => {
dispatch(setIsLoading(true));
const metrics = await getBackendSearchMetrics(metricText, query.labels, datasource);
const queryString = regexifyLabelValuesQueryString(metricText);
const filterArray = query.labels ? formatPrometheusLabelFilters(query.labels) : [];
const match = `{__name__=~".*${queryString}"${filterArray ? filterArray.join('') : ''}}`;
const results = await datasource.languageProvider.queryLabelValues(timeRange, METRIC_LABEL, match);
const resultsOptions = results.map((result) => ({
value: result,
}));
dispatch(
filterMetricsBackend({
metrics: metrics,
filteredMetricCount: metrics.length,
metrics: resultsOptions,
filteredMetricCount: resultsOptions.length,
isLoading: false,
})
);
}, getDebounceTimeInMilliseconds(datasource.cacheLevel)),
[datasource, query]
[datasource.cacheLevel, datasource.languageProvider, query.labels, timeRange]
);
function fuzzyNameDispatch(haystackData: string[][]) {
@@ -1,3 +1,5 @@
import { TimeRange } from '@grafana/data';
import { PrometheusDatasource } from '../../../../datasource';
import { PromVisualQuery } from '../../../types';
@@ -16,6 +18,7 @@ export interface MetricsModalProps {
onClose: () => void;
onChange: (query: PromVisualQuery) => void;
initialMetrics: string[] | (() => Promise<string[]>);
timeRange: TimeRange;
}
export interface AdditionalSettingsProps {
@@ -11,7 +11,6 @@ import { PromVisualQuery } from '../../../types';
import { HaystackDictionary, MetricData, MetricsData, PromFilterOption } from '../types';
import { MetricsModalMetadata, MetricsModalState, setFilteredMetricCount } from './state';
export async function setMetrics(
datasource: PrometheusDatasource,
query: PromVisualQuery,
@@ -20,7 +19,7 @@ export async function setMetrics(
// metadata is set in the metric select now
// use this to disable metadata search and display
let hasMetadata = true;
const metadata = datasource.languageProvider.metricsMetadata;
const metadata = datasource.languageProvider.retrieveMetricsMetadata();
if (metadata && Object.keys(metadata).length === 0) {
hasMetadata = false;
}
@@ -61,9 +60,9 @@ export async function setMetrics(
* @returns A MetricData object.
*/
function buildMetricData(metric: string, datasource: PrometheusDatasource): MetricData {
let type = getMetadataType(metric, datasource.languageProvider.metricsMetadata!);
let type = getMetadataType(metric, datasource.languageProvider.retrieveMetricsMetadata());
const description = getMetadataHelp(metric, datasource.languageProvider.metricsMetadata!);
const description = getMetadataHelp(metric, datasource.languageProvider.retrieveMetricsMetadata());
['histogram', 'summary'].forEach((t) => {
if (description?.toLowerCase().includes(t) && type !== t) {
@@ -132,6 +132,157 @@ describe('LabelsApiClient', () => {
);
});
});
describe('LabelsCache', () => {
let cache: any; // Using any to access private members for testing
beforeEach(() => {
jest.useFakeTimers();
cache = (client as any)._cache;
});
afterEach(() => {
jest.useRealTimers();
});
describe('cache key generation', () => {
it('should generate different cache keys for keys and values', () => {
const keyKey = cache.getCacheKey(mockTimeRange, '{job="test"}', '1000', 'key');
const valueKey = cache.getCacheKey(mockTimeRange, '{job="test"}', '1000', 'value');
expect(keyKey).not.toEqual(valueKey);
});
it('should use cache level from constructor for time range snapping', () => {
const highLevelCache = new LabelsApiClient(mockRequest, {
cacheLevel: PrometheusCacheLevel.High,
getAdjustedInterval: mockGetAdjustedInterval,
getTimeRangeParams: mockGetTimeRangeParams,
interpolateString: mockInterpolateString,
} as unknown as PrometheusDatasource);
const lowLevelCache = new LabelsApiClient(mockRequest, {
cacheLevel: PrometheusCacheLevel.Low,
getAdjustedInterval: mockGetAdjustedInterval,
getTimeRangeParams: mockGetTimeRangeParams,
interpolateString: mockInterpolateString,
} as unknown as PrometheusDatasource);
const highKey = (highLevelCache as any)._cache.getCacheKey(mockTimeRange, '{job="test"}', '1000', 'key');
const lowKey = (lowLevelCache as any)._cache.getCacheKey(mockTimeRange, '{job="test"}', '1000', 'key');
expect(highKey).not.toEqual(lowKey);
});
});
describe('cache size management', () => {
beforeEach(() => {
// Start with a clean cache for each test
cache._cache = {};
cache._accessTimestamps = {};
});
it('should remove oldest entries when max entries limit is reached', () => {
// Override MAX_CACHE_ENTRIES for testing
Object.defineProperty(cache, 'MAX_CACHE_ENTRIES', { value: 5 });
// Add entries up to the limit
cache.setLabelKeys(mockTimeRange, 'match1', '1000', ['key1']);
jest.advanceTimersByTime(1000);
cache.setLabelKeys(mockTimeRange, 'match2', '1000', ['key2']);
jest.advanceTimersByTime(1000);
cache.setLabelKeys(mockTimeRange, 'match3', '1000', ['key3']);
jest.advanceTimersByTime(1000);
cache.setLabelKeys(mockTimeRange, 'match4', '1000', ['key4']);
jest.advanceTimersByTime(1000);
cache.setLabelKeys(mockTimeRange, 'match5', '1000', ['key5']);
// Access first entry to make it more recently used
cache.getLabelKeys(mockTimeRange, 'match1', '1000');
jest.advanceTimersByTime(1000);
// Add sixth entry - this should trigger cache cleaning
cache.setLabelKeys(mockTimeRange, 'match6', '1000', ['key6']);
// Verify cache state - should have removed one entry (match2)
expect(Object.keys(cache._cache).length).toBe(5);
// Second entry should be removed (was least recently used)
expect(cache.getLabelKeys(mockTimeRange, 'match2', '1000')).toBeUndefined();
// First entry should exist (was accessed recently)
expect(cache.getLabelKeys(mockTimeRange, 'match1', '1000')).toEqual(['key1']);
// Third entry should exist
expect(cache.getLabelKeys(mockTimeRange, 'match3', '1000')).toEqual(['key3']);
// Fourth entry should exist
expect(cache.getLabelKeys(mockTimeRange, 'match4', '1000')).toEqual(['key4']);
// Fifth entry should exist
expect(cache.getLabelKeys(mockTimeRange, 'match5', '1000')).toEqual(['key5']);
// Sixth entry should exist (newest)
expect(cache.getLabelKeys(mockTimeRange, 'match6', '1000')).toEqual(['key6']);
});
it('should remove oldest entries when max size limit is reached', () => {
// Override MAX_CACHE_SIZE_BYTES for testing - set to small value to trigger cleanup
Object.defineProperty(cache, 'MAX_CACHE_SIZE_BYTES', { value: 10 }); // Very small size to force cleanup
// Create entries that will exceed the size limit
const largeArray = Array(5).fill('large_value');
// Add first large entry
cache.setLabelKeys(mockTimeRange, 'match1', '1000', largeArray);
// Verify initial size
expect(Object.keys(cache._cache).length).toBe(1);
expect(cache.getCacheSizeInBytes()).toBeGreaterThan(10);
// Add second large entry - should trigger size-based cleanup
cache.setLabelKeys(mockTimeRange, 'match2', '1000', largeArray);
// Verify cache state - should only have the newest entry
expect(Object.keys(cache._cache).length).toBe(1);
expect(cache.getLabelKeys(mockTimeRange, 'match1', '1000')).toBeUndefined();
expect(cache.getLabelKeys(mockTimeRange, 'match2', '1000')).toEqual(largeArray);
// Add third entry to verify the cleanup continues to work
cache.setLabelKeys(mockTimeRange, 'match3', '1000', largeArray);
expect(Object.keys(cache._cache).length).toBe(1);
expect(cache.getLabelKeys(mockTimeRange, 'match2', '1000')).toBeUndefined();
expect(cache.getLabelKeys(mockTimeRange, 'match3', '1000')).toEqual(largeArray);
});
it('should update access time when getting cached values', () => {
// Add an entry
cache.setLabelKeys(mockTimeRange, 'match1', '1000', ['key1']);
const cacheKey = cache.getCacheKey(mockTimeRange, 'match1', '1000', 'key');
const initialTimestamp = cache._accessTimestamps[cacheKey];
// Advance time
jest.advanceTimersByTime(1000);
// Access the entry
cache.getLabelKeys(mockTimeRange, 'match1', '1000');
const updatedTimestamp = cache._accessTimestamps[cacheKey];
// Verify timestamp was updated
expect(updatedTimestamp).toBeGreaterThan(initialTimestamp);
});
});
describe('label values caching', () => {
it('should cache and retrieve label values', () => {
const values = ['value1', 'value2'];
cache.setLabelValues(mockTimeRange, '{job="test"}', '1000', values);
const cachedValues = cache.getLabelValues(mockTimeRange, '{job="test"}', '1000');
expect(cachedValues).toEqual(values);
});
it('should return undefined for non-existent label values', () => {
const result = cache.getLabelValues(mockTimeRange, '{job="nonexistent"}', '1000');
expect(result).toBeUndefined();
});
});
});
});
describe('SeriesApiClient', () => {
@@ -293,7 +444,7 @@ describe('SeriesApiClient', () => {
beforeEach(() => {
jest.useFakeTimers();
cache = (client as any)._seriesCache;
cache = (client as any)._cache;
});
afterEach(() => {
@@ -320,8 +471,8 @@ describe('SeriesApiClient', () => {
getTimeRangeParams: mockGetTimeRangeParams,
} as unknown as PrometheusDatasource);
const highKey = (highLevelCache as any)._seriesCache.getCacheKey(mockTimeRange, '{job="test"}', '1000', 'key');
const lowKey = (lowLevelCache as any)._seriesCache.getCacheKey(mockTimeRange, '{job="test"}', '1000', 'key');
const highKey = (highLevelCache as any)._cache.getCacheKey(mockTimeRange, '{job="test"}', '1000', 'key');
const lowKey = (lowLevelCache as any)._cache.getCacheKey(mockTimeRange, '{job="test"}', '1000', 'key');
expect(highKey).not.toEqual(lowKey);
});
@@ -77,6 +77,8 @@ export abstract class BaseResourceClient {
}
export class LabelsApiClient extends BaseResourceClient implements ResourceApiClient {
private _cache: ResourceClientsCache = new ResourceClientsCache(this.datasource.cacheLevel);
public histogramMetrics: string[] = [];
public metrics: string[] = [];
public labelKeys: string[] = [];
@@ -90,6 +92,7 @@ export class LabelsApiClient extends BaseResourceClient implements ResourceApiCl
public queryMetrics = async (timeRange: TimeRange): Promise<{ metrics: string[]; histogramMetrics: string[] }> => {
this.metrics = await this.queryLabelValues(timeRange, METRIC_LABEL);
this.histogramMetrics = processHistogramMetrics(this.metrics);
this._cache.setLabelValues(timeRange, MATCH_ALL_LABELS, DEFAULT_SERIES_LIMIT, this.metrics);
return { metrics: this.metrics, histogramMetrics: this.histogramMetrics };
};
@@ -110,10 +113,16 @@ export class LabelsApiClient extends BaseResourceClient implements ResourceApiCl
let url = '/api/v1/labels';
const timeParams = getRangeSnapInterval(this.datasource.cacheLevel, timeRange);
const searchParams = { limit, ...timeParams, ...(match ? { 'match[]': match } : {}) };
const effectiveMatch = match ?? '';
const maybeCachedKeys = this._cache.getLabelKeys(timeRange, effectiveMatch, limit);
if (maybeCachedKeys) {
return maybeCachedKeys;
}
const res = await this.requestLabels(url, searchParams, getDefaultCacheHeaders(this.datasource.cacheLevel));
if (Array.isArray(res)) {
this.labelKeys = res.slice().sort();
this._cache.setLabelKeys(timeRange, effectiveMatch, limit, this.labelKeys);
return this.labelKeys.slice();
}
@@ -139,14 +148,21 @@ export class LabelsApiClient extends BaseResourceClient implements ResourceApiCl
const searchParams = { limit, ...timeParams, ...(match ? { 'match[]': match } : {}) };
const interpolatedName = this.datasource.interpolateString(labelKey);
const interpolatedAndEscapedName = escapeForUtf8Support(removeQuotesIfExist(interpolatedName));
const effectiveMatch = `${match ?? ''}-${interpolatedAndEscapedName}`;
const maybeCachedValues = this._cache.getLabelValues(timeRange, effectiveMatch, limit);
if (maybeCachedValues) {
return maybeCachedValues;
}
const url = `/api/v1/label/${interpolatedAndEscapedName}/values`;
const value = await this.requestLabels(url, searchParams, getDefaultCacheHeaders(this.datasource.cacheLevel));
this._cache.setLabelValues(timeRange, effectiveMatch, limit, value ?? []);
return value ?? [];
};
}
export class SeriesApiClient extends BaseResourceClient implements ResourceApiClient {
private _seriesCache: SeriesCache = new SeriesCache(this.datasource.cacheLevel);
private _cache: ResourceClientsCache = new ResourceClientsCache(this.datasource.cacheLevel);
public histogramMetrics: string[] = [];
public metrics: string[] = [];
@@ -163,8 +179,8 @@ export class SeriesApiClient extends BaseResourceClient implements ResourceApiCl
this.metrics = metrics;
this.histogramMetrics = processHistogramMetrics(this.metrics);
this.labelKeys = labelKeys;
this._seriesCache.setLabelValues(timeRange, MATCH_ALL_LABELS, DEFAULT_SERIES_LIMIT, metrics);
this._seriesCache.setLabelKeys(timeRange, MATCH_ALL_LABELS, DEFAULT_SERIES_LIMIT, labelKeys);
this._cache.setLabelValues(timeRange, MATCH_ALL_LABELS, DEFAULT_SERIES_LIMIT, metrics);
this._cache.setLabelKeys(timeRange, MATCH_ALL_LABELS, DEFAULT_SERIES_LIMIT, labelKeys);
return { metrics: this.metrics, histogramMetrics: this.histogramMetrics };
};
@@ -174,14 +190,14 @@ export class SeriesApiClient extends BaseResourceClient implements ResourceApiCl
limit: string = DEFAULT_SERIES_LIMIT
): Promise<string[]> => {
const effectiveMatch = !match || match === EMPTY_MATCHER ? MATCH_ALL_LABELS : match;
const maybeCachedKeys = this._seriesCache.getLabelKeys(timeRange, effectiveMatch, limit);
const maybeCachedKeys = this._cache.getLabelKeys(timeRange, effectiveMatch, limit);
if (maybeCachedKeys) {
return maybeCachedKeys;
}
const series = await this.querySeries(timeRange, effectiveMatch, limit);
const { labelKeys } = processSeries(series);
this._seriesCache.setLabelKeys(timeRange, effectiveMatch, limit, labelKeys);
this._cache.setLabelKeys(timeRange, effectiveMatch, limit, labelKeys);
return labelKeys;
};
@@ -196,19 +212,19 @@ export class SeriesApiClient extends BaseResourceClient implements ResourceApiCl
!match || match === EMPTY_MATCHER
? `{${utf8SafeLabelKey}!=""}`
: match.slice(0, match.length - 1).concat(`,${utf8SafeLabelKey}!=""}`);
const maybeCachedValues = this._seriesCache.getLabelValues(timeRange, effectiveMatch, limit);
const maybeCachedValues = this._cache.getLabelValues(timeRange, effectiveMatch, limit);
if (maybeCachedValues) {
return maybeCachedValues;
}
const series = await this.querySeries(timeRange, effectiveMatch, limit);
const { labelValues } = processSeries(series, labelKey);
this._seriesCache.setLabelValues(timeRange, effectiveMatch, limit, labelValues);
this._cache.setLabelValues(timeRange, effectiveMatch, limit, labelValues);
return labelValues;
};
}
class SeriesCache {
class ResourceClientsCache {
private readonly MAX_CACHE_ENTRIES = 1000; // Maximum number of cache entries
private readonly MAX_CACHE_SIZE_BYTES = 50 * 1024 * 1024; // 50MB max cache size