diff --git a/.betterer.results b/.betterer.results index 535e7e9cb34..699a5dfade9 100644 --- a/.betterer.results +++ b/.betterer.results @@ -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"], diff --git a/packages/grafana-prometheus/src/components/VariableQueryEditor.test.tsx b/packages/grafana-prometheus/src/components/VariableQueryEditor.test.tsx index ef295e65b85..88b12b8cf13 100644 --- a/packages/grafana-prometheus/src/components/VariableQueryEditor.test.tsx +++ b/packages/grafana-prometheus/src/components/VariableQueryEditor.test.tsx @@ -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, getTagKeys: jest .fn() diff --git a/packages/grafana-prometheus/src/language_provider.mock.ts b/packages/grafana-prometheus/src/language_provider.mock.ts index 8c331ce01a0..e66c7b4de92 100644 --- a/packages/grafana-prometheus/src/language_provider.mock.ts +++ b/packages/grafana-prometheus/src/language_provider.mock.ts @@ -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({}); } diff --git a/packages/grafana-prometheus/src/querybuilder/components/LabelParamEditor.tsx b/packages/grafana-prometheus/src/querybuilder/components/LabelParamEditor.tsx index 7e9fb658cd4..3e72025ac07 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/LabelParamEditor.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/LabelParamEditor.tsx @@ -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, diff --git a/packages/grafana-prometheus/src/querybuilder/components/MetricCombobox.test.tsx b/packages/grafana-prometheus/src/querybuilder/components/MetricCombobox.test.tsx index 906393e1014..8c49e0f437f 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/MetricCombobox.test.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/MetricCombobox.test.tsx @@ -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; - 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(); 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 () => { diff --git a/packages/grafana-prometheus/src/querybuilder/components/MetricCombobox.tsx b/packages/grafana-prometheus/src/querybuilder/components/MetricCombobox.tsx index c7fb0a7dcf8..df20f9de09a 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/MetricCombobox.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/MetricCombobox.tsx @@ -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) { 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); diff --git a/packages/grafana-prometheus/src/querybuilder/components/MetricsLabelsSection.test.tsx b/packages/grafana-prometheus/src/querybuilder/components/MetricsLabelsSection.test.tsx index 3b80e9ef901..b21dc473723 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/MetricsLabelsSection.test.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/MetricsLabelsSection.test.tsx @@ -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( { await onGetMetricsCallback(); // loadMetricsMetadata should be called - expect(datasource.languageProvider.loadMetricsMetadata).toHaveBeenCalled(); + expect(datasource.languageProvider.queryMetricsMetadata).toHaveBeenCalled(); }); }); diff --git a/packages/grafana-prometheus/src/querybuilder/components/MetricsLabelsSection.tsx b/packages/grafana-prometheus/src/querybuilder/components/MetricsLabelsSection.tsx index bcf6428aec8..a19ee523c45 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/MetricsLabelsSection.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/MetricsLabelsSection.tsx @@ -64,8 +64,8 @@ export function MetricsLabelsSection({ const onGetLabelNames = async (forLabel: Partial): Promise => { // 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 = 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 => { @@ -102,63 +102,8 @@ export function MetricsLabelsSection({ value: datasource.interpolateString(labelObject.value), })); const expr = promQueryModeller.renderLabels(interpolatedLabelsToConsider); - let response: Promise; - 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, - promQLExpression: string - ): Promise => { - 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(); - 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, - promQLExpression: string - ): Promise => { - 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} /> > { // 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, + }; +} diff --git a/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilder.test.tsx b/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilder.test.tsx index e9ca17d1cba..0701300a576 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilder.test.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilder.test.tsx @@ -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"}' ) diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.test.tsx b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.test.tsx index e28488c9777..4a1470fb82d 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.test.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.test.tsx @@ -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(); - return container; + return { container, datasource }; } diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.tsx b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.tsx index da19c46ddc5..36ed0ce4bf8 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.tsx @@ -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[][]) { diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/shared/types.ts b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/shared/types.ts index 0cd0ee76797..255131670a7 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/shared/types.ts +++ b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/shared/types.ts @@ -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); + timeRange: TimeRange; } export interface AdditionalSettingsProps { diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/state/helpers.ts b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/state/helpers.ts index 31ff7dba463..66fa83e2624 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/state/helpers.ts +++ b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/state/helpers.ts @@ -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) { diff --git a/packages/grafana-prometheus/src/resource_clients.test.ts b/packages/grafana-prometheus/src/resource_clients.test.ts index 0ab24bd9ff9..4604a77e652 100644 --- a/packages/grafana-prometheus/src/resource_clients.test.ts +++ b/packages/grafana-prometheus/src/resource_clients.test.ts @@ -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); }); diff --git a/packages/grafana-prometheus/src/resource_clients.ts b/packages/grafana-prometheus/src/resource_clients.ts index 6335ca03d55..b0cd76c7cb8 100644 --- a/packages/grafana-prometheus/src/resource_clients.ts +++ b/packages/grafana-prometheus/src/resource_clients.ts @@ -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 => { 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