From c7e338342a530c9edc8ce2fe87128978e0360959 Mon Sep 17 00:00:00 2001 From: ismail simsek Date: Wed, 18 Jun 2025 15:31:49 +0200 Subject: [PATCH] Prometheus: Introduce resource clients in language provider (#105818) * refactor language provider * update tests * more tests * betterer and api endpoints * copilot updates * betterer * remove default value * prettier * introduce resource clients and better refactoring * prettier * type fixes * betterer * no empty matcher for series calls * better matchers * addressing the review feedback --- .betterer.results | 11 +- .../grafana-prometheus/src/caching.test.ts | 149 +++ packages/grafana-prometheus/src/caching.ts | 101 ++ .../components/VariableQueryEditor.test.tsx | 1 - packages/grafana-prometheus/src/datasource.ts | 8 +- .../src/language_provider.test.ts | 1168 +++++++++-------- .../src/language_provider.ts | 557 ++++++-- .../components/MetricsLabelsSection.tsx | 10 +- .../components/metrics-modal/state/helpers.ts | 10 +- .../src/resource_clients.test.ts | 474 +++++++ .../src/resource_clients.ts | 239 ++++ 11 files changed, 2059 insertions(+), 669 deletions(-) create mode 100644 packages/grafana-prometheus/src/caching.test.ts create mode 100644 packages/grafana-prometheus/src/caching.ts create mode 100644 packages/grafana-prometheus/src/resource_clients.test.ts create mode 100644 packages/grafana-prometheus/src/resource_clients.ts diff --git a/.betterer.results b/.betterer.results index 94ece6c91c3..4f50207e279 100644 --- a/.betterer.results +++ b/.betterer.results @@ -435,10 +435,19 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "3"], [0, 0, 0, "Unexpected any. Specify a different type.", "4"] ], + "packages/grafana-prometheus/src/language_provider.test.ts:5381": [ + [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"] + ], "packages/grafana-prometheus/src/language_provider.ts:5381": [ [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.", "2"], + [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"] ], "packages/grafana-prometheus/src/language_utils.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] diff --git a/packages/grafana-prometheus/src/caching.test.ts b/packages/grafana-prometheus/src/caching.test.ts new file mode 100644 index 00000000000..ca03ecb6ab5 --- /dev/null +++ b/packages/grafana-prometheus/src/caching.test.ts @@ -0,0 +1,149 @@ +import { + getCacheDurationInMinutes, + getDaysToCacheMetadata, + getDebounceTimeInMilliseconds, + buildCacheHeaders, + getDefaultCacheHeaders, +} from './caching'; +import { PrometheusCacheLevel } from './types'; + +describe('caching', () => { + describe('getDebounceTimeInMilliseconds', () => { + it('should return 600ms for Medium cache level', () => { + expect(getDebounceTimeInMilliseconds(PrometheusCacheLevel.Medium)).toBe(600); + }); + + it('should return 1200ms for High cache level', () => { + expect(getDebounceTimeInMilliseconds(PrometheusCacheLevel.High)).toBe(1200); + }); + + it('should return 350ms for Low cache level', () => { + expect(getDebounceTimeInMilliseconds(PrometheusCacheLevel.Low)).toBe(350); + }); + + it('should return 350ms for None cache level', () => { + expect(getDebounceTimeInMilliseconds(PrometheusCacheLevel.None)).toBe(350); + }); + + it('should return default value (350ms) for unknown cache level', () => { + expect(getDebounceTimeInMilliseconds('invalid' as PrometheusCacheLevel)).toBe(350); + }); + }); + + describe('getDaysToCacheMetadata', () => { + it('should return 7 days for Medium cache level', () => { + expect(getDaysToCacheMetadata(PrometheusCacheLevel.Medium)).toBe(7); + }); + + it('should return 30 days for High cache level', () => { + expect(getDaysToCacheMetadata(PrometheusCacheLevel.High)).toBe(30); + }); + + it('should return 1 day for Low cache level', () => { + expect(getDaysToCacheMetadata(PrometheusCacheLevel.Low)).toBe(1); + }); + + it('should return 1 day for None cache level', () => { + expect(getDaysToCacheMetadata(PrometheusCacheLevel.None)).toBe(1); + }); + + it('should return default value (1 day) for unknown cache level', () => { + expect(getDaysToCacheMetadata('invalid' as PrometheusCacheLevel)).toBe(1); + }); + }); + + describe('getCacheDurationInMinutes', () => { + it('should return 10 minutes for Medium cache level', () => { + expect(getCacheDurationInMinutes(PrometheusCacheLevel.Medium)).toBe(10); + }); + + it('should return 60 minutes for High cache level', () => { + expect(getCacheDurationInMinutes(PrometheusCacheLevel.High)).toBe(60); + }); + + it('should return 1 minute for Low cache level', () => { + expect(getCacheDurationInMinutes(PrometheusCacheLevel.Low)).toBe(1); + }); + + it('should return 1 minute for None cache level', () => { + expect(getCacheDurationInMinutes(PrometheusCacheLevel.None)).toBe(1); + }); + + it('should return default value (1 minute) for unknown cache level', () => { + expect(getCacheDurationInMinutes('invalid' as PrometheusCacheLevel)).toBe(1); + }); + }); + + describe('buildCacheHeaders', () => { + it('should build cache headers with provided duration in seconds', () => { + const result = buildCacheHeaders(300); + expect(result).toEqual({ + headers: { + 'X-Grafana-Cache': 'private, max-age=300', + }, + }); + }); + + it('should handle zero duration', () => { + const result = buildCacheHeaders(0); + expect(result).toEqual({ + headers: { + 'X-Grafana-Cache': 'private, max-age=0', + }, + }); + }); + + it('should handle large duration values', () => { + const oneDayInSeconds = 86400; + const result = buildCacheHeaders(oneDayInSeconds); + expect(result).toEqual({ + headers: { + 'X-Grafana-Cache': 'private, max-age=86400', + }, + }); + }); + }); + + describe('getDefaultCacheHeaders', () => { + it('should return cache headers for Medium cache level', () => { + const result = getDefaultCacheHeaders(PrometheusCacheLevel.Medium); + expect(result).toEqual({ + headers: { + 'X-Grafana-Cache': 'private, max-age=600', // 10 minutes in seconds + }, + }); + }); + + it('should return cache headers for High cache level', () => { + const result = getDefaultCacheHeaders(PrometheusCacheLevel.High); + expect(result).toEqual({ + headers: { + 'X-Grafana-Cache': 'private, max-age=3600', // 60 minutes in seconds + }, + }); + }); + + it('should return cache headers for Low cache level', () => { + const result = getDefaultCacheHeaders(PrometheusCacheLevel.Low); + expect(result).toEqual({ + headers: { + 'X-Grafana-Cache': 'private, max-age=60', // 1 minute in seconds + }, + }); + }); + + it('should return undefined for None cache level', () => { + const result = getDefaultCacheHeaders(PrometheusCacheLevel.None); + expect(result).toBeUndefined(); + }); + + it('should handle unknown cache level as default (1 minute)', () => { + const result = getDefaultCacheHeaders('invalid' as PrometheusCacheLevel); + expect(result).toEqual({ + headers: { + 'X-Grafana-Cache': 'private, max-age=60', // 1 minute in seconds + }, + }); + }); + }); +}); diff --git a/packages/grafana-prometheus/src/caching.ts b/packages/grafana-prometheus/src/caching.ts new file mode 100644 index 00000000000..6b1f39d0139 --- /dev/null +++ b/packages/grafana-prometheus/src/caching.ts @@ -0,0 +1,101 @@ +import { PrometheusCacheLevel } from './types'; + +/** + * Returns the debounce time in milliseconds based on the cache level. + * Used to control the frequency of API requests. + * + * @param {PrometheusCacheLevel} cacheLevel - The cache level (None, Low, Medium, High) + * @returns {number} Debounce time in milliseconds: + * - Medium: 600ms + * - High: 1200ms + * - Default (None/Low): 350ms + */ +export const getDebounceTimeInMilliseconds = (cacheLevel: PrometheusCacheLevel): number => { + switch (cacheLevel) { + case PrometheusCacheLevel.Medium: + return 600; + case PrometheusCacheLevel.High: + return 1200; + default: + return 350; + } +}; + +/** + * Returns the number of days to cache metadata based on the cache level. + * Used for caching Prometheus metric metadata. + * + * @param {PrometheusCacheLevel} cacheLevel - The cache level (None, Low, Medium, High) + * @returns {number} Number of days to cache: + * - Medium: 7 days + * - High: 30 days + * - Default (None/Low): 1 day + */ +export const getDaysToCacheMetadata = (cacheLevel: PrometheusCacheLevel): number => { + switch (cacheLevel) { + case PrometheusCacheLevel.Medium: + return 7; + case PrometheusCacheLevel.High: + return 30; + default: + return 1; + } +}; + +/** + * Returns the cache duration in minutes based on the cache level. + * Used for general API response caching. + * + * @param {PrometheusCacheLevel} cacheLevel - The cache level (None, Low, Medium, High) + * @returns {number} Cache duration in minutes: + * - Medium: 10 minutes + * - High: 60 minutes + * - Default (None/Low): 1 minute + */ +export function getCacheDurationInMinutes(cacheLevel: PrometheusCacheLevel) { + switch (cacheLevel) { + case PrometheusCacheLevel.Medium: + return 10; + case PrometheusCacheLevel.High: + return 60; + default: + return 1; + } +} + +/** + * Builds cache headers for Prometheus API requests. + * Creates a standard cache control header with private scope and max-age directive. + * + * @param {number} durationInSeconds - Cache duration in seconds + * @returns {object} Object containing headers with cache control directives: + * - X-Grafana-Cache: private, max-age= + * @example + * // Returns { headers: { 'X-Grafana-Cache': 'private, max-age=300' } } + * buildCacheHeaders(300) + */ +export const buildCacheHeaders = (durationInSeconds: number) => { + return { + headers: { + 'X-Grafana-Cache': `private, max-age=${durationInSeconds}`, + }, + }; +}; + +/** + * Gets appropriate cache headers based on the configured cache level. + * Converts cache duration from minutes to seconds and builds the headers. + * Returns undefined if caching is disabled (None level). + * + * @param {PrometheusCacheLevel} cacheLevel - Cache level (None, Low, Medium, High) + * @returns {object|undefined} Cache headers object or undefined if caching is disabled + * @example + * // For Medium level, returns { headers: { 'X-Grafana-Cache': 'private, max-age=600' } } + * getDefaultCacheHeaders(PrometheusCacheLevel.Medium) + */ +export const getDefaultCacheHeaders = (cacheLevel: PrometheusCacheLevel) => { + if (cacheLevel !== PrometheusCacheLevel.None) { + return buildCacheHeaders(getCacheDurationInMinutes(cacheLevel) * 60); + } + return; +}; diff --git a/packages/grafana-prometheus/src/components/VariableQueryEditor.test.tsx b/packages/grafana-prometheus/src/components/VariableQueryEditor.test.tsx index 65ca6fccfeb..7ff37c8657f 100644 --- a/packages/grafana-prometheus/src/components/VariableQueryEditor.test.tsx +++ b/packages/grafana-prometheus/src/components/VariableQueryEditor.test.tsx @@ -138,7 +138,6 @@ describe('PromVariableQueryEditor', () => { hasLabelsMatchAPISupport: () => true, languageProvider: { start: () => Promise.resolve([]), - syntax: () => {}, getLabelKeys: () => [], metrics: [], metricsMetadata: {}, diff --git a/packages/grafana-prometheus/src/datasource.ts b/packages/grafana-prometheus/src/datasource.ts index e1d6f98c087..7e4702415c8 100644 --- a/packages/grafana-prometheus/src/datasource.ts +++ b/packages/grafana-prometheus/src/datasource.ts @@ -44,7 +44,7 @@ import { addLabelToQuery } from './add_label_to_query'; import { PrometheusAnnotationSupport } from './annotations'; import { SUGGESTIONS_LIMIT } from './constants'; import { prometheusRegularEscape, prometheusSpecialRegexEscape } from './escaping'; -import PrometheusLanguageProvider from './language_provider'; +import PrometheusLanguageProvider, { exportToAbstractQuery, importFromAbstractQuery } from './language_provider'; import { expandRecordingRules, getClientCacheDurationInMinutes, @@ -127,7 +127,6 @@ export class PrometheusDatasource this.exemplarTraceIdDestinations = instanceSettings.jsonData.exemplarTraceIdDestinations; this.hasIncrementalQuery = instanceSettings.jsonData.incrementalQuerying ?? false; this.ruleMappings = {}; - this.languageProvider = languageProvider ?? new PrometheusLanguageProvider(this); this.lookupsDisabled = instanceSettings.jsonData.disableMetricsLookup ?? false; this.customQueryParameters = new URLSearchParams(instanceSettings.jsonData.customQueryParameters); this.datasourceConfigurationPrometheusFlavor = instanceSettings.jsonData.prometheusType; @@ -148,6 +147,7 @@ export class PrometheusDatasource }); this.annotations = PrometheusAnnotationSupport(this); + this.languageProvider = languageProvider ?? new PrometheusLanguageProvider(this); } init = async () => { @@ -284,11 +284,11 @@ export class PrometheusDatasource } async importFromAbstractQueries(abstractQueries: AbstractQuery[]): Promise { - return abstractQueries.map((abstractQuery) => this.languageProvider.importFromAbstractQuery(abstractQuery)); + return abstractQueries.map((abstractQuery) => importFromAbstractQuery(abstractQuery)); } async exportToAbstractQueries(queries: PromQuery[]): Promise { - return queries.map((query) => this.languageProvider.exportToAbstractQuery(query)); + return queries.map((query) => exportToAbstractQuery(query)); } // Use this for tab completion features, wont publish response to other components diff --git a/packages/grafana-prometheus/src/language_provider.test.ts b/packages/grafana-prometheus/src/language_provider.test.ts index fa0694d73f8..b45489a25dc 100644 --- a/packages/grafana-prometheus/src/language_provider.test.ts +++ b/packages/grafana-prometheus/src/language_provider.test.ts @@ -1,11 +1,26 @@ // Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/language_provider.test.ts import { AbstractLabelOperator, dateTime, TimeRange } from '@grafana/data'; +jest.mock('./language_utils', () => ({ + ...jest.requireActual('./language_utils'), + processHistogramMetrics: (metrics: string[]) => metrics, + getPrometheusTime: jest.requireActual('./language_utils').getPrometheusTime, + getRangeSnapInterval: jest.requireActual('./language_utils').getRangeSnapInterval, +})); + +import { getCacheDurationInMinutes } from './caching'; import { DEFAULT_SERIES_LIMIT } from './components/metrics-browser/types'; import { Label } from './components/monaco-query-field/monaco-completion-provider/situation'; import { PrometheusDatasource } from './datasource'; -import LanguageProvider, { removeQuotesIfExist } from './language_provider'; -import { getClientCacheDurationInMinutes, getPrometheusTime, getRangeSnapInterval } from './language_utils'; +import { + exportToAbstractQuery, + importFromAbstractQuery, + removeQuotesIfExist, + PrometheusLanguageProviderInterface, + PrometheusLanguageProvider, + populateMatchParamsFromQueries, +} from './language_provider'; +import { getPrometheusTime, getRangeSnapInterval } from './language_utils'; import { PrometheusCacheLevel, PromQuery } from './types'; const now = new Date(1681300293392).getTime(); @@ -45,365 +60,312 @@ const getMockQuantizedTimeRangeParams = (override?: Partial): TimeRan ...override, }); -describe('Language completion provider', () => { +// Common test helper to verify request parameters +const verifyRequestParams = ( + requestSpy: jest.SpyInstance, + expectedUrl: string, + expectedParams: unknown, + expectedOptions?: unknown +) => { + expect(requestSpy).toHaveBeenCalled(); + expect(requestSpy).toHaveBeenCalledWith(expectedUrl, expect.objectContaining(expectedParams), expectedOptions); +}; + +describe('Prometheus Language Provider', () => { const defaultDatasource: PrometheusDatasource = { metadataRequest: () => ({ data: { data: [] } }), getTimeRangeParams: getTimeRangeParams, interpolateString: (string: string) => string, hasLabelsMatchAPISupport: () => false, - getQuantizedTimeRangeParams: () => - getRangeSnapInterval(PrometheusCacheLevel.None, getMockQuantizedTimeRangeParams()), getDaysToCacheMetadata: () => 1, getAdjustedInterval: () => getRangeSnapInterval(PrometheusCacheLevel.None, getMockQuantizedTimeRangeParams()), cacheLevel: PrometheusCacheLevel.None, + getIntervalVars: () => ({}), + getRangeScopedVars: () => ({}), } as unknown as PrometheusDatasource; - describe('cleanText', () => { - const cleanText = new LanguageProvider(defaultDatasource).cleanText; - it('does not remove metric or label keys', () => { - expect(cleanText('foo')).toBe('foo'); - expect(cleanText('foo_bar')).toBe('foo_bar'); - }); + describe('Series and label fetching', () => { + const timeRange = getMockTimeRange(); - it('keeps trailing space but removes leading', () => { - expect(cleanText('foo ')).toBe('foo '); - expect(cleanText(' foo')).toBe('foo'); - }); + describe('getSeries', () => { + it('should use fetchDefaultSeries for empty selector', async () => { + const languageProvider = new PrometheusLanguageProvider(defaultDatasource); + const fetchDefaultSeriesSpy = jest.spyOn(languageProvider, 'fetchDefaultSeries'); + fetchDefaultSeriesSpy.mockResolvedValue({ job: ['job1', 'job2'], instance: ['instance1', 'instance2'] }); - it('removes label syntax', () => { - expect(cleanText('foo="bar')).toBe('bar'); - expect(cleanText('foo!="bar')).toBe('bar'); - expect(cleanText('foo=~"bar')).toBe('bar'); - expect(cleanText('foo!~"bar')).toBe('bar'); - expect(cleanText('{bar')).toBe('bar'); - }); + const result = await languageProvider.getSeries(timeRange, '{}'); - it('removes previous operators', () => { - expect(cleanText('foo + bar')).toBe('bar'); - expect(cleanText('foo+bar')).toBe('bar'); - expect(cleanText('foo - bar')).toBe('bar'); - expect(cleanText('foo * bar')).toBe('bar'); - expect(cleanText('foo / bar')).toBe('bar'); - expect(cleanText('foo % bar')).toBe('bar'); - expect(cleanText('foo ^ bar')).toBe('bar'); - expect(cleanText('foo and bar')).toBe('bar'); - expect(cleanText('foo or bar')).toBe('bar'); - expect(cleanText('foo unless bar')).toBe('bar'); - expect(cleanText('foo == bar')).toBe('bar'); - expect(cleanText('foo != bar')).toBe('bar'); - expect(cleanText('foo > bar')).toBe('bar'); - expect(cleanText('foo < bar')).toBe('bar'); - expect(cleanText('foo >= bar')).toBe('bar'); - expect(cleanText('foo <= bar')).toBe('bar'); - expect(cleanText('memory')).toBe('memory'); - }); + expect(fetchDefaultSeriesSpy).toHaveBeenCalledWith(timeRange); + expect(result).toEqual({ job: ['job1', 'job2'], instance: ['instance1', 'instance2'] }); + }); - it('removes aggregation syntax', () => { - expect(cleanText('(bar')).toBe('bar'); - expect(cleanText('(foo,bar')).toBe('bar'); - expect(cleanText('(foo, bar')).toBe('bar'); - }); + it('should use fetchSeriesLabels for non-empty selector', async () => { + const languageProvider = new PrometheusLanguageProvider(defaultDatasource); + const fetchSeriesLabelsSpy = jest.spyOn(languageProvider, 'fetchSeriesLabels'); + fetchSeriesLabelsSpy.mockResolvedValue({ job: ['job1', 'job2'], instance: ['instance1', 'instance2'] }); + + const result = await languageProvider.getSeries(timeRange, '{job="grafana"}'); + + expect(fetchSeriesLabelsSpy).toHaveBeenCalledWith(timeRange, '{job="grafana"}', undefined, 'none'); + expect(result).toEqual({ job: ['job1', 'job2'], instance: ['instance1', 'instance2'] }); + }); + + it('should include name label when withName is true', async () => { + const languageProvider = new PrometheusLanguageProvider(defaultDatasource); + const fetchSeriesLabelsSpy = jest.spyOn(languageProvider, 'fetchSeriesLabels'); + fetchSeriesLabelsSpy.mockResolvedValue({ __name__: ['metric1', 'metric2'], job: ['job1'] }); - it('removes range syntax', () => { - expect(cleanText('[1m')).toBe('1m'); + const result = await languageProvider.getSeries(timeRange, '{job="grafana"}', true); + + expect(fetchSeriesLabelsSpy).toHaveBeenCalledWith(timeRange, '{job="grafana"}', true, 'none'); + expect(result).toHaveProperty('__name__'); + expect(result.__name__).toEqual(['metric1', 'metric2']); + }); + + it('should handle errors gracefully', async () => { + jest.spyOn(console, 'error').mockImplementation(); + const languageProvider = new PrometheusLanguageProvider(defaultDatasource); + jest.spyOn(languageProvider, 'fetchSeriesLabels').mockRejectedValue(new Error('Network error')); + + const result = await languageProvider.getSeries(timeRange, '{job="grafana"}'); + + expect(result).toEqual({}); + }); }); - }); - describe('getSeriesLabels', () => { - const timeRange = getMockTimeRange(); + describe('getSeriesLabels', () => { + it('should call labels endpoint when API support is available', () => { + const languageProvider = new PrometheusLanguageProvider({ + ...defaultDatasource, + hasLabelsMatchAPISupport: () => true, + } as PrometheusDatasource); + const getSeriesLabels = languageProvider.getSeriesLabels; + const requestSpy = jest.spyOn(languageProvider, 'request'); - it('should call labels endpoint', () => { - const languageProvider = new LanguageProvider({ - ...defaultDatasource, - hasLabelsMatchAPISupport: () => true, - } as PrometheusDatasource); - const getSeriesLabels = languageProvider.getSeriesLabels; - const requestSpy = jest.spyOn(languageProvider, 'request'); - - const labelName = 'job'; - const labelValue = 'grafana'; - getSeriesLabels(timeRange, `{${labelName}="${labelValue}"}`, [ - { - name: labelName, - value: labelValue, - op: '=', - }, - ] as Label[]); - expect(requestSpy).toHaveBeenCalled(); - expect(requestSpy).toHaveBeenCalledWith( - `/api/v1/labels`, - [], - { + const labelName = 'job'; + const labelValue = 'grafana'; + getSeriesLabels(timeRange, `{${labelName}="${labelValue}"}`, [ + { + name: labelName, + value: labelValue, + op: '=', + }, + ] as Label[]); + + verifyRequestParams(requestSpy, '/api/v1/labels', { end: toPrometheusTimeString, 'match[]': '{job="grafana"}', start: fromPrometheusTimeString, - }, - undefined - ); - }); + }); + }); - it('should call series endpoint', () => { - const languageProvider = new LanguageProvider({ - ...defaultDatasource, - getAdjustedInterval: (timeRange: TimeRange) => - getRangeSnapInterval(PrometheusCacheLevel.None, getMockQuantizedTimeRangeParams()), - } as PrometheusDatasource); - const getSeriesLabels = languageProvider.getSeriesLabels; - const requestSpy = jest.spyOn(languageProvider, 'request'); - - const labelName = 'job'; - const labelValue = 'grafana'; - getSeriesLabels(timeRange, `{${labelName}="${labelValue}"}`, [ - { - name: labelName, - value: labelValue, - op: '=', - }, - ] as Label[]); - expect(requestSpy).toHaveBeenCalled(); - expect(requestSpy).toHaveBeenCalledWith( - '/api/v1/series', - [], - { + it('should call series endpoint when API support is not available', () => { + const languageProvider = new PrometheusLanguageProvider({ + ...defaultDatasource, + getAdjustedInterval: (_: TimeRange) => + getRangeSnapInterval(PrometheusCacheLevel.None, getMockQuantizedTimeRangeParams()), + } as PrometheusDatasource); + const getSeriesLabels = languageProvider.getSeriesLabels; + const requestSpy = jest.spyOn(languageProvider, 'request'); + + const labelName = 'job'; + const labelValue = 'grafana'; + getSeriesLabels(timeRange, `{${labelName}="${labelValue}"}`, [ + { + name: labelName, + value: labelValue, + op: '=', + }, + ] as Label[]); + + verifyRequestParams(requestSpy, '/api/v1/series', { end: toPrometheusTimeString, 'match[]': '{job="grafana"}', start: fromPrometheusTimeString, - }, - undefined - ); - }); + }); + }); - it('should call labels endpoint with quantized start', () => { - const timeSnapMinutes = getClientCacheDurationInMinutes(PrometheusCacheLevel.Low); - const languageProvider = new LanguageProvider({ - ...defaultDatasource, - hasLabelsMatchAPISupport: () => true, - cacheLevel: PrometheusCacheLevel.Low, - getAdjustedInterval: (timeRange: TimeRange) => - getRangeSnapInterval(PrometheusCacheLevel.Low, getMockQuantizedTimeRangeParams()), - getCacheDurationInMinutes: () => timeSnapMinutes, - } as PrometheusDatasource); - const getSeriesLabels = languageProvider.getSeriesLabels; - const requestSpy = jest.spyOn(languageProvider, 'request'); - - const labelName = 'job'; - const labelValue = 'grafana'; - getSeriesLabels(timeRange, `{${labelName}="${labelValue}"}`, [ - { - name: labelName, - value: labelValue, - op: '=', - }, - ] as Label[]); - expect(requestSpy).toHaveBeenCalled(); - expect(requestSpy).toHaveBeenCalledWith( - `/api/v1/labels`, - [], - { - end: ( - dateTime(fromPrometheusTime * 1000) - .add(timeSnapMinutes, 'minute') - .startOf('minute') - .valueOf() / 1000 - ).toString(), - 'match[]': '{job="grafana"}', - start: ( - dateTime(toPrometheusTime * 1000) - .startOf('minute') - .valueOf() / 1000 - ).toString(), - }, - { headers: { 'X-Grafana-Cache': `private, max-age=${timeSnapMinutes * 60}` } } - ); + it('should call labels endpoint with quantized time parameters when cache level is set', () => { + const timeSnapMinutes = getCacheDurationInMinutes(PrometheusCacheLevel.Low); + const languageProvider = new PrometheusLanguageProvider({ + ...defaultDatasource, + hasLabelsMatchAPISupport: () => true, + cacheLevel: PrometheusCacheLevel.Low, + getAdjustedInterval: (_: TimeRange) => + getRangeSnapInterval(PrometheusCacheLevel.Low, getMockQuantizedTimeRangeParams()), + } as PrometheusDatasource); + const getSeriesLabels = languageProvider.getSeriesLabels; + const requestSpy = jest.spyOn(languageProvider, 'request'); + + const labelName = 'job'; + const labelValue = 'grafana'; + getSeriesLabels(timeRange, `{${labelName}="${labelValue}"}`, [ + { + name: labelName, + value: labelValue, + op: '=', + }, + ] as Label[]); + + expect(requestSpy).toHaveBeenCalled(); + expect(requestSpy).toHaveBeenCalledWith( + '/api/v1/labels', + { + end: ( + dateTime(fromPrometheusTime * 1000) + .add(timeSnapMinutes, 'minute') + .startOf('minute') + .valueOf() / 1000 + ).toString(), + 'match[]': '{job="grafana"}', + start: ( + dateTime(toPrometheusTime * 1000) + .startOf('minute') + .valueOf() / 1000 + ).toString(), + }, + { headers: { 'X-Grafana-Cache': `private, max-age=${timeSnapMinutes * 60}` } } + ); + }); }); - }); - describe('getSeriesValues', () => { - const timeRange = getMockTimeRange(); + describe('getSeriesValues', () => { + it('should call series endpoint when labels match API is not supported', () => { + const languageProvider = new PrometheusLanguageProvider({ + ...defaultDatasource, + } as PrometheusDatasource); + const getSeriesValues = languageProvider.getSeriesValues; + const requestSpy = jest.spyOn(languageProvider, 'request'); - it('should call old series endpoint and should use match[] parameter', () => { - const languageProvider = new LanguageProvider({ - ...defaultDatasource, - } as PrometheusDatasource); - const getSeriesValues = languageProvider.getSeriesValues; - const requestSpy = jest.spyOn(languageProvider, 'request'); - getSeriesValues(timeRange, 'job', '{job="grafana"}'); - expect(requestSpy).toHaveBeenCalled(); - expect(requestSpy).toHaveBeenCalledWith( - '/api/v1/series', - [], - { + getSeriesValues(timeRange, 'job', '{job="grafana"}'); + + verifyRequestParams(requestSpy, '/api/v1/series', { end: toPrometheusTimeString, 'match[]': '{job="grafana"}', start: fromPrometheusTimeString, - }, - undefined - ); - }); + }); + }); - it('should call new series endpoint and should use match[] parameter', () => { - const languageProvider = new LanguageProvider({ - ...defaultDatasource, - hasLabelsMatchAPISupport: () => true, - } as PrometheusDatasource); - const getSeriesValues = languageProvider.getSeriesValues; - const requestSpy = jest.spyOn(languageProvider, 'request'); - const labelName = 'job'; - const labelValue = 'grafana'; - getSeriesValues(timeRange, labelName, `{${labelName}="${labelValue}"}`); - expect(requestSpy).toHaveBeenCalled(); - expect(requestSpy).toHaveBeenCalledWith( - `/api/v1/label/${labelName}/values`, - [], - { + it('should call label values endpoint when labels match API is supported', () => { + const languageProvider = new PrometheusLanguageProvider({ + ...defaultDatasource, + hasLabelsMatchAPISupport: () => true, + } as PrometheusDatasource); + const getSeriesValues = languageProvider.getSeriesValues; + const requestSpy = jest.spyOn(languageProvider, 'request'); + + const labelName = 'job'; + const labelValue = 'grafana'; + getSeriesValues(timeRange, labelName, `{${labelName}="${labelValue}"}`); + + verifyRequestParams(requestSpy, `/api/v1/label/${labelName}/values`, { end: toPrometheusTimeString, 'match[]': `{${labelName}="${labelValue}"}`, start: fromPrometheusTimeString, - }, - undefined - ); - }); + }); + }); - it('should call old series endpoint and should use match[] parameter and interpolate the template variables', () => { - const languageProvider = new LanguageProvider({ - ...defaultDatasource, - interpolateString: (string: string) => string.replace(/\$/, 'interpolated-'), - } as PrometheusDatasource); - const getSeriesValues = languageProvider.getSeriesValues; - const requestSpy = jest.spyOn(languageProvider, 'request'); - getSeriesValues(timeRange, 'job', '{instance="$instance", job="grafana"}'); - expect(requestSpy).toHaveBeenCalled(); - expect(requestSpy).toHaveBeenCalledWith( - '/api/v1/series', - [], - { + it('should properly interpolate template variables in queries', () => { + const languageProvider = new PrometheusLanguageProvider({ + ...defaultDatasource, + interpolateString: (string: string) => string.replace(/\$/g, 'interpolated-'), + } as PrometheusDatasource); + const getSeriesValues = languageProvider.getSeriesValues; + const requestSpy = jest.spyOn(languageProvider, 'request'); + + getSeriesValues(timeRange, 'job', '{instance="$instance", job="grafana"}'); + + verifyRequestParams(requestSpy, '/api/v1/series', { end: toPrometheusTimeString, 'match[]': '{instance="interpolated-instance", job="grafana"}', start: fromPrometheusTimeString, - }, - undefined - ); + }); + }); }); - }); - describe('fetchSeries', () => { - it('should use match[] parameter', async () => { - const languageProvider = new LanguageProvider(defaultDatasource); - const timeRange = getMockTimeRange(); - await languageProvider.start(timeRange); - const requestSpy = jest.spyOn(languageProvider, 'request'); - await languageProvider.fetchSeries(timeRange, '{job="grafana"}'); - expect(requestSpy).toHaveBeenCalled(); - expect(requestSpy).toHaveBeenCalledWith( - '/api/v1/series', - {}, - { + describe('fetchSeries', () => { + it('should use match[] parameter in request', async () => { + const languageProvider = new PrometheusLanguageProvider(defaultDatasource); + await languageProvider.start(timeRange); + const requestSpy = jest.spyOn(languageProvider, 'request'); + + await languageProvider.fetchSeries(timeRange, '{job="grafana"}'); + + verifyRequestParams(requestSpy, '/api/v1/series', { end: toPrometheusTimeString, 'match[]': '{job="grafana"}', start: fromPrometheusTimeString, - }, - undefined - ); + }); + }); }); - }); - describe('fetchSeriesLabels', () => { - it('should interpolate variable in series', () => { - const languageProvider = new LanguageProvider({ - ...defaultDatasource, - interpolateString: (string: string) => string.replace(/\$/, 'interpolated-'), - } as PrometheusDatasource); - const fetchSeriesLabels = languageProvider.fetchSeriesLabels; - const requestSpy = jest.spyOn(languageProvider, 'request'); - fetchSeriesLabels(getMockTimeRange(), '$metric'); - expect(requestSpy).toHaveBeenCalled(); - expect(requestSpy).toHaveBeenCalledWith( - '/api/v1/series', - [], - { + describe('fetchSeriesLabels', () => { + it('should interpolate variables in series queries', () => { + const languageProvider = new PrometheusLanguageProvider({ + ...defaultDatasource, + interpolateString: (string: string) => string.replace(/\$/g, 'interpolated-'), + } as PrometheusDatasource); + const fetchSeriesLabels = languageProvider.fetchSeriesLabels; + const requestSpy = jest.spyOn(languageProvider, 'request'); + + fetchSeriesLabels(getMockTimeRange(), '$metric'); + + verifyRequestParams(requestSpy, '/api/v1/series', { end: toPrometheusTimeString, 'match[]': 'interpolated-metric', start: fromPrometheusTimeString, limit: DEFAULT_SERIES_LIMIT, - }, - undefined - ); - }); + }); + }); - it("should not use default limit parameter when 'none' is passed to fetchSeriesLabels", () => { - const languageProvider = new LanguageProvider({ - ...defaultDatasource, - } as PrometheusDatasource); - const fetchSeriesLabels = languageProvider.fetchSeriesLabels; - const requestSpy = jest.spyOn(languageProvider, 'request'); - fetchSeriesLabels(getMockTimeRange(), 'metric-with-limit', undefined, 'none'); - expect(requestSpy).toHaveBeenCalled(); - expect(requestSpy).toHaveBeenCalledWith( - '/api/v1/series', - [], - { - end: toPrometheusTimeString, - 'match[]': 'metric-with-limit', - start: fromPrometheusTimeString, - }, - undefined - ); - }); + it('should not include limit parameter when "none" is specified', () => { + const languageProvider = new PrometheusLanguageProvider({ + ...defaultDatasource, + } as PrometheusDatasource); + const fetchSeriesLabels = languageProvider.fetchSeriesLabels; + const requestSpy = jest.spyOn(languageProvider, 'request'); - it("should not have a limit paranter if 'none' is passed to function", () => { - const languageProvider = new LanguageProvider({ - ...defaultDatasource, - // interpolateString: (string: string) => string.replace(/\$/, 'interpolated-'), - } as PrometheusDatasource); - const fetchSeriesLabels = languageProvider.fetchSeriesLabels; - const requestSpy = jest.spyOn(languageProvider, 'request'); - fetchSeriesLabels(getMockTimeRange(), 'metric-without-limit', false, 'none'); - expect(requestSpy).toHaveBeenCalled(); - expect(requestSpy).toHaveBeenCalledWith( - '/api/v1/series', - [], - { + fetchSeriesLabels(getMockTimeRange(), 'metric-with-limit', undefined, 'none'); + + verifyRequestParams(requestSpy, '/api/v1/series', { end: toPrometheusTimeString, - 'match[]': 'metric-without-limit', + 'match[]': 'metric-with-limit', start: fromPrometheusTimeString, - }, - undefined - ); + }); + }); }); }); - describe('fetchLabels', () => { + describe('fetchLabels API', () => { const tr = getMockTimeRange(); + const getParams = (requestSpy: ReturnType) => { - // Following is equal to `URLSearchParams().toString()` - return requestSpy.mock.calls[0][2]?.toString() ?? 'undefined'; + return requestSpy.mock.calls[0][1]?.toString() ?? 'undefined'; }; - describe('with POST', () => { - let languageProvider: LanguageProvider; + describe('with POST method', () => { + let languageProvider: PrometheusLanguageProviderInterface; + beforeEach(() => { - languageProvider = new LanguageProvider({ + languageProvider = new PrometheusLanguageProvider({ ...defaultDatasource, httpMethod: 'POST', } as PrometheusDatasource); }); - it('should send query metrics to the POST request', async () => { - const mockQueries: PromQuery[] = [ - { - refId: 'C', - expr: 'go_gc_pauses_seconds_bucket', - }, - ]; + it('should send single metric to request', async () => { + const mockQueries: PromQuery[] = [{ refId: 'C', expr: 'go_gc_pauses_seconds_bucket' }]; const fetchLabel = languageProvider.fetchLabels; const requestSpy = jest.spyOn(languageProvider, 'request'); + await fetchLabel(tr, mockQueries); + expect(requestSpy).toHaveBeenCalled(); const params = getParams(requestSpy); expect(params).toMatch(encodeURI('match[]=go_gc_pauses_seconds_bucket')); }); - it('should send metrics from complex query to the POST request', async () => { + it('should extract metrics from complex PromQL expressions', async () => { const mockQueries: PromQuery[] = [ { refId: 'C', @@ -412,353 +374,286 @@ describe('Language completion provider', () => { ]; const fetchLabel = languageProvider.fetchLabels; const requestSpy = jest.spyOn(languageProvider, 'request'); + await fetchLabel(tr, mockQueries); + expect(requestSpy).toHaveBeenCalled(); const params = getParams(requestSpy); expect(params).toMatch(encodeURI('match[]=go_gc_pauses_seconds_bucket')); }); - it('should send metrics from multiple queries to the POST request', async () => { + it('should combine metrics from multiple queries', async () => { const mockQueries: PromQuery[] = [ - { - refId: 'B', - expr: 'process_cpu_seconds_total', - }, - { - refId: 'C', - expr: 'go_gc_pauses_seconds_bucket', - }, + { refId: 'B', expr: 'process_cpu_seconds_total' }, + { refId: 'C', expr: 'go_gc_pauses_seconds_bucket' }, ]; const fetchLabel = languageProvider.fetchLabels; const requestSpy = jest.spyOn(languageProvider, 'request'); + await fetchLabel(tr, mockQueries); + expect(requestSpy).toHaveBeenCalled(); const params = getParams(requestSpy); expect(params).toMatch(encodeURI('match[]=process_cpu_seconds_total&match[]=go_gc_pauses_seconds_bucket')); }); - it('should send metrics from a query contains multiple metrics to the POST request', async () => { + it('should extract multiple metrics from binary operations', async () => { const mockQueries: PromQuery[] = [ - { - refId: 'B', - expr: 'process_cpu_seconds_total + go_gc_pauses_seconds_bucket', - }, + { refId: 'B', expr: 'process_cpu_seconds_total + go_gc_pauses_seconds_bucket' }, ]; const fetchLabel = languageProvider.fetchLabels; const requestSpy = jest.spyOn(languageProvider, 'request'); + await fetchLabel(tr, mockQueries); + expect(requestSpy).toHaveBeenCalled(); const params = getParams(requestSpy); expect(params).toMatch(encodeURI('match[]=process_cpu_seconds_total&match[]=go_gc_pauses_seconds_bucket')); }); - it('should send metrics from a query contains multiple metrics and queries to the POST request', async () => { - const mockQueries: PromQuery[] = [ - { - refId: 'A', - expr: 'histogram_quantile(0.95, sum(rate(process_max_fds[$__rate_interval])) by (le)) + go_gc_heap_frees_by_size_bytes_bucket', - }, - { - refId: 'B', - expr: 'process_cpu_seconds_total + go_gc_pauses_seconds_bucket', - }, - ]; - const fetchLabel = languageProvider.fetchLabels; - const requestSpy = jest.spyOn(languageProvider, 'request'); - await fetchLabel(tr, mockQueries); - expect(requestSpy).toHaveBeenCalled(); - const params = getParams(requestSpy); - expect(params).toMatch( - encodeURI( - 'match[]=process_max_fds&match[]=go_gc_heap_frees_by_size_bytes_bucket&match[]=process_cpu_seconds_total&match[]=go_gc_pauses_seconds_bucket' - ) - ); - }); - - it('should set `labelKeys` on language provider', async () => { - const mockQueries: PromQuery[] = [ - { - refId: 'C', - expr: 'go_gc_pauses_seconds_bucket', - }, - ]; + it('should set and return labelKeys from API response', async () => { + const mockQueries: PromQuery[] = [{ refId: 'C', expr: 'go_gc_pauses_seconds_bucket' }]; const fetchLabel = languageProvider.fetchLabels; const requestSpy = jest.spyOn(languageProvider, 'request').mockResolvedValue(['foo', 'bar']); - await fetchLabel(tr, mockQueries); - expect(requestSpy).toHaveBeenCalled(); - expect(languageProvider.labelKeys).toEqual(['bar', 'foo']); - }); - it('should return labelKeys from request', async () => { - const mockQueries: PromQuery[] = [ - { - refId: 'C', - expr: 'go_gc_pauses_seconds_bucket', - }, - ]; - const fetchLabel = languageProvider.fetchLabels; - const requestSpy = jest.spyOn(languageProvider, 'request').mockResolvedValue(['foo', 'bar']); const keys = await fetchLabel(tr, mockQueries); + expect(requestSpy).toHaveBeenCalled(); + expect(languageProvider.labelKeys).toEqual(['bar', 'foo']); // Sorted order expect(keys).toEqual(['bar', 'foo']); }); }); - describe('with GET', () => { - let languageProvider: LanguageProvider; + describe('with GET method', () => { + let languageProvider: PrometheusLanguageProviderInterface; + beforeEach(() => { - languageProvider = new LanguageProvider({ + languageProvider = new PrometheusLanguageProvider({ ...defaultDatasource, httpMethod: 'GET', } as PrometheusDatasource); }); - it('should send query metrics to the GET request', async () => { - const mockQueries: PromQuery[] = [ - { - refId: 'C', - expr: 'go_gc_pauses_seconds_bucket', - }, - ]; + it('should send query metrics in URL for GET requests', async () => { + const mockQueries: PromQuery[] = [{ refId: 'C', expr: 'go_gc_pauses_seconds_bucket' }]; const fetchLabel = languageProvider.fetchLabels; const requestSpy = jest.spyOn(languageProvider, 'request'); - await fetchLabel(tr, mockQueries); - expect(requestSpy).toHaveBeenCalled(); - expect(requestSpy.mock.calls[0][0]).toMatch(encodeURI('match[]=go_gc_pauses_seconds_bucket')); - }); - it('should send metrics from complex query to the GET request', async () => { - const mockQueries: PromQuery[] = [ - { - refId: 'C', - expr: 'histogram_quantile(0.95, sum(rate(go_gc_pauses_seconds_bucket[$__rate_interval])) by (le))', - }, - ]; - const fetchLabel = languageProvider.fetchLabels; - const requestSpy = jest.spyOn(languageProvider, 'request'); await fetchLabel(tr, mockQueries); + expect(requestSpy).toHaveBeenCalled(); expect(requestSpy.mock.calls[0][0]).toMatch(encodeURI('match[]=go_gc_pauses_seconds_bucket')); }); - it('should send metrics from multiple queries to the GET request', async () => { - const mockQueries: PromQuery[] = [ - { - refId: 'B', - expr: 'process_cpu_seconds_total', - }, - { - refId: 'C', - expr: 'go_gc_pauses_seconds_bucket', - }, - ]; + it('should handle empty queries correctly', async () => { + const mockQueries: PromQuery[] = [{ refId: 'A', expr: '' }]; const fetchLabel = languageProvider.fetchLabels; const requestSpy = jest.spyOn(languageProvider, 'request'); + await fetchLabel(tr, mockQueries); + expect(requestSpy).toHaveBeenCalled(); - expect(requestSpy.mock.calls[0][0]).toMatch( - encodeURI('match[]=process_cpu_seconds_total&match[]=go_gc_pauses_seconds_bucket') - ); + expect(requestSpy.mock.calls[0][0].indexOf('match[]')).toEqual(-1); }); + }); + }); - it('should send metrics from a query contains multiple metrics to the GET request', async () => { - const mockQueries: PromQuery[] = [ - { - refId: 'B', - expr: 'process_cpu_seconds_total + go_gc_pauses_seconds_bucket', - }, - ]; - const fetchLabel = languageProvider.fetchLabels; + describe('Label value handling', () => { + describe('fetchLabelValues', () => { + it('should interpolate variables in labels', () => { + const languageProvider = new PrometheusLanguageProvider({ + ...defaultDatasource, + interpolateString: (string: string) => string.replace(/\$/g, 'interpolated_'), + } as PrometheusDatasource); + const fetchLabelValues = languageProvider.fetchLabelValues; const requestSpy = jest.spyOn(languageProvider, 'request'); - await fetchLabel(tr, mockQueries); - expect(requestSpy).toHaveBeenCalled(); - expect(requestSpy.mock.calls[0][0]).toMatch( - encodeURI('match[]=process_cpu_seconds_total&match[]=go_gc_pauses_seconds_bucket') - ); + + fetchLabelValues(getMockTimeRange(), '$job'); + + verifyRequestParams(requestSpy, '/api/v1/label/interpolated_job/values', { + end: toPrometheusTimeString, + start: fromPrometheusTimeString, + }); }); - it('should send metrics from a query contains multiple metrics and queries to the GET request', async () => { - const mockQueries: PromQuery[] = [ - { - refId: 'A', - expr: 'histogram_quantile(0.95, sum(rate(process_max_fds[$__rate_interval])) by (le)) + go_gc_heap_frees_by_size_bytes_bucket', - }, - { - refId: 'B', - expr: 'process_cpu_seconds_total + go_gc_pauses_seconds_bucket', - }, - ]; - const fetchLabel = languageProvider.fetchLabels; + it('should properly encode UTF-8 labels', () => { + const languageProvider = new PrometheusLanguageProvider({ + ...defaultDatasource, + interpolateString: (string: string) => string.replace(/\$/g, 'http.status:sum'), + } as PrometheusDatasource); + const fetchLabelValues = languageProvider.fetchLabelValues; const requestSpy = jest.spyOn(languageProvider, 'request'); - await fetchLabel(tr, mockQueries); - expect(requestSpy).toHaveBeenCalled(); - expect(requestSpy.mock.calls[0][0]).toMatch( - encodeURI( - 'match[]=process_max_fds&match[]=go_gc_heap_frees_by_size_bytes_bucket&match[]=process_cpu_seconds_total&match[]=go_gc_pauses_seconds_bucket' - ) - ); + + fetchLabelValues(getMockTimeRange(), '"http.status:sum"'); + + verifyRequestParams(requestSpy, '/api/v1/label/U__http_2e_status:sum/values', { + end: toPrometheusTimeString, + start: fromPrometheusTimeString, + }); }); - it('should dont send match[] parameter if there is no metric', async () => { - const mockQueries: PromQuery[] = [ - { - refId: 'A', - expr: '', - }, - ]; - const fetchLabel = languageProvider.fetchLabels; + it('should handle special characters safely in label values', () => { + const languageProvider = new PrometheusLanguageProvider({ + ...defaultDatasource, + interpolateString: (string: string) => string.replace(/\$/g, 'value with spaces & special chars'), + } as PrometheusDatasource); + const fetchLabelValues = languageProvider.fetchLabelValues; const requestSpy = jest.spyOn(languageProvider, 'request'); - await fetchLabel(tr, mockQueries); + + fetchLabelValues(getMockTimeRange(), '$job'); + expect(requestSpy).toHaveBeenCalled(); - expect(requestSpy.mock.calls[0][0].indexOf('match[]')).toEqual(-1); + expect(requestSpy.mock.calls[0][0]).not.toContain(' '); + expect(requestSpy.mock.calls[0][0]).not.toContain('&'); }); }); - }); - describe('fetchLabelValues', () => { - it('should interpolate variable in series', () => { - const languageProvider = new LanguageProvider({ - ...defaultDatasource, - interpolateString: (string: string) => string.replace(/\$/g, 'interpolated_'), - } as PrometheusDatasource); - const fetchLabelValues = languageProvider.fetchLabelValues; - const requestSpy = jest.spyOn(languageProvider, 'request'); - fetchLabelValues(getMockTimeRange(), '$job'); - expect(requestSpy).toHaveBeenCalled(); - expect(requestSpy).toHaveBeenCalledWith( - '/api/v1/label/interpolated_job/values', - [], - { + describe('fetchSeriesValuesWithMatch', () => { + it('should handle UTF-8 encoding for special label names', () => { + const languageProvider = new PrometheusLanguageProvider({ + ...defaultDatasource, + interpolateString: (string: string) => string.replace(/\$/g, 'http.status:sum'), + } as PrometheusDatasource); + const fetchSeriesValuesWithMatch = languageProvider.fetchSeriesValuesWithMatch; + const requestSpy = jest.spyOn(languageProvider, 'request'); + + fetchSeriesValuesWithMatch(getMockTimeRange(), '"http.status:sum"', '{__name__="a_utf8_http_requests_total"}'); + + verifyRequestParams(requestSpy, '/api/v1/label/U__http_2e_status:sum/values', { end: toPrometheusTimeString, start: fromPrometheusTimeString, - }, - undefined - ); - }); + 'match[]': '{__name__="a_utf8_http_requests_total"}', + }); + }); - it('should fetch with encoded utf8 label', () => { - const languageProvider = new LanguageProvider({ - ...defaultDatasource, - interpolateString: (string: string) => string.replace(/\$/g, 'http.status:sum'), - } as PrometheusDatasource); - const fetchLabelValues = languageProvider.fetchLabelValues; - const requestSpy = jest.spyOn(languageProvider, 'request'); - fetchLabelValues(getMockTimeRange(), '"http.status:sum"'); - expect(requestSpy).toHaveBeenCalled(); - expect(requestSpy).toHaveBeenCalledWith( - '/api/v1/label/U__http_2e_status:sum/values', - [], - { + it('should not encode standard Prometheus label names', () => { + const languageProvider = new PrometheusLanguageProvider({ + ...defaultDatasource, + } as PrometheusDatasource); + const fetchSeriesValuesWithMatch = languageProvider.fetchSeriesValuesWithMatch; + const requestSpy = jest.spyOn(languageProvider, 'request'); + + fetchSeriesValuesWithMatch(getMockTimeRange(), '"http_status_sum"', '{__name__="a_utf8_http_requests_total"}'); + + verifyRequestParams(requestSpy, '/api/v1/label/http_status_sum/values', { end: toPrometheusTimeString, start: fromPrometheusTimeString, - }, - undefined - ); + 'match[]': '{__name__="a_utf8_http_requests_total"}', + }); + }); }); }); - describe('fetchSeriesValuesWithMatch', () => { - it('should fetch with encoded utf8 label', () => { - const languageProvider = new LanguageProvider({ + describe('fetchSuggestions', () => { + it('should send POST request with correct parameters', async () => { + const timeRange = getMockTimeRange(); + const mockQueries: PromQuery[] = [{ refId: 'A', expr: 'metric1' }]; + + const languageProvider = new PrometheusLanguageProvider({ ...defaultDatasource, - interpolateString: (string: string) => string.replace(/\$/g, 'http.status:sum'), - } as PrometheusDatasource); - const fetchSeriesValuesWithMatch = languageProvider.fetchSeriesValuesWithMatch; - const requestSpy = jest.spyOn(languageProvider, 'request'); - fetchSeriesValuesWithMatch(getMockTimeRange(), '"http.status:sum"', '{__name__="a_utf8_http_requests_total"}'); - expect(requestSpy).toHaveBeenCalled(); - expect(requestSpy).toHaveBeenCalledWith( - '/api/v1/label/U__http_2e_status:sum/values', - [], - { - end: toPrometheusTimeString, - start: fromPrometheusTimeString, - 'match[]': '{__name__="a_utf8_http_requests_total"}', - }, - undefined + interpolateString: (string: string) => `interpolated_${string}`, + getIntervalVars: () => ({ __interval: '1m' }), + getRangeScopedVars: () => ({ __range: { text: '1h', value: '1h' } }), + } as unknown as PrometheusDatasource); + + const requestSpy = jest.spyOn(languageProvider, 'request').mockResolvedValue(['suggestion1', 'suggestion2']); + + // Simplifying the test by not passing complex scope objects that require more type definitions + const result = await languageProvider.fetchSuggestions( + timeRange, + mockQueries, + undefined, // omitting scopes parameter + [{ key: 'instance', operator: '=', value: 'localhost' }], + 'metric', + 100 ); + + expect(requestSpy).toHaveBeenCalled(); + expect(requestSpy.mock.calls[0][0]).toBe('/suggestions'); + + // Check method and content type + expect(requestSpy.mock.calls[0][2]).toMatchObject({ + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }); + + // Check query parameters + expect(requestSpy.mock.calls[0][1]).toMatchObject({ + labelName: 'metric', + limit: 100, + queries: ['interpolated_metric1'], + }); + + expect(result).toEqual(['suggestion1', 'suggestion2']); }); - it('should fetch without encoding for standard prometheus labels', () => { - const languageProvider = new LanguageProvider({ - ...defaultDatasource, - } as PrometheusDatasource); - const fetchSeriesValuesWithMatch = languageProvider.fetchSeriesValuesWithMatch; - const requestSpy = jest.spyOn(languageProvider, 'request'); - fetchSeriesValuesWithMatch(getMockTimeRange(), '"http_status_sum"', '{__name__="a_utf8_http_requests_total"}'); + it('should use default time range if not provided', async () => { + const languageProvider = new PrometheusLanguageProvider(defaultDatasource); + const requestSpy = jest.spyOn(languageProvider, 'request').mockResolvedValue(['result']); + + await languageProvider.fetchSuggestions(undefined, [], [], [], 'test'); + expect(requestSpy).toHaveBeenCalled(); - expect(requestSpy).toHaveBeenCalledWith( - '/api/v1/label/http_status_sum/values', - [], - { - end: toPrometheusTimeString, - start: fromPrometheusTimeString, - 'match[]': '{__name__="a_utf8_http_requests_total"}', - }, - undefined - ); + // Default time range should be used + expect(requestSpy.mock.calls[0][1]).toHaveProperty('start'); + expect(requestSpy.mock.calls[0][1]).toHaveProperty('end'); }); - }); - describe('disabled metrics lookup', () => { - it('issues metadata requests when lookup is not disabled', async () => { - const datasource: PrometheusDatasource = { - ...defaultDatasource, - metadataRequest: jest.fn(() => ({ data: { data: ['foo', 'bar'] as string[] } })), - lookupsDisabled: false, - } as unknown as PrometheusDatasource; - const mockedMetadataRequest = jest.mocked(datasource.metadataRequest); - const instance = new LanguageProvider(datasource); + it('should handle empty response gracefully', async () => { + const languageProvider = new PrometheusLanguageProvider(defaultDatasource); + jest.spyOn(languageProvider, 'request').mockResolvedValue(null); + + const result = await languageProvider.fetchSuggestions(getMockTimeRange(), [], [], [], 'test'); - expect(mockedMetadataRequest.mock.calls.length).toBe(0); - await instance.start(); - expect(mockedMetadataRequest.mock.calls.length).toBeGreaterThan(0); + expect(result).toEqual([]); }); - it('doesnt blow up if metadata or fetchLabels rejects', async () => { - jest.spyOn(console, 'error').mockImplementation(); - const datasource: PrometheusDatasource = { + it('should include cache headers when cacheLevel is set', async () => { + const timeSnapMinutes = getCacheDurationInMinutes(PrometheusCacheLevel.Medium); + const languageProvider = new PrometheusLanguageProvider({ ...defaultDatasource, - metadataRequest: jest.fn(() => Promise.reject('rejected')), - lookupsDisabled: false, - } as unknown as PrometheusDatasource; - const mockedMetadataRequest = jest.mocked(datasource.metadataRequest); - const instance = new LanguageProvider(datasource); - - expect(mockedMetadataRequest.mock.calls.length).toBe(0); - const result = await instance.start(); - expect(result[0]).toBeUndefined(); - expect(result[1]).toEqual([]); - expect(mockedMetadataRequest.mock.calls.length).toBe(3); + cacheLevel: PrometheusCacheLevel.Medium, + } as PrometheusDatasource); + + const requestSpy = jest.spyOn(languageProvider, 'request').mockResolvedValue(['result']); + + await languageProvider.fetchSuggestions(getMockTimeRange(), [], [], [], 'test'); + + expect(requestSpy).toHaveBeenCalled(); + expect(requestSpy.mock.calls[0][2]?.headers).toHaveProperty('X-Grafana-Cache'); + expect(requestSpy.mock.calls[0][2]?.headers?.['X-Grafana-Cache']).toContain( + `private, max-age=${timeSnapMinutes * 60}` + ); }); }); +}); - describe('Query imports', () => { - it('returns empty queries', async () => { - const instance = new LanguageProvider(defaultDatasource); - const result = await instance.importFromAbstractQuery({ refId: 'bar', labelMatchers: [] }); +describe('Query transformation', () => { + describe('importFromAbstractQuery', () => { + it('should handle empty queries', async () => { + const result = importFromAbstractQuery({ refId: 'bar', labelMatchers: [] }); expect(result).toEqual({ refId: 'bar', expr: '', range: true }); }); + }); - describe('exporting to abstract query', () => { - it('exports labels with metric name', async () => { - const instance = new LanguageProvider(defaultDatasource); - const abstractQuery = instance.exportToAbstractQuery({ - refId: 'bar', - expr: 'metric_name{label1="value1", label2!="value2", label3=~"value3", label4!~"value4"}', - instant: true, - range: false, - }); - expect(abstractQuery).toMatchObject({ - refId: 'bar', - labelMatchers: [ - { name: 'label1', operator: AbstractLabelOperator.Equal, value: 'value1' }, - { name: 'label2', operator: AbstractLabelOperator.NotEqual, value: 'value2' }, - { name: 'label3', operator: AbstractLabelOperator.EqualRegEx, value: 'value3' }, - { name: 'label4', operator: AbstractLabelOperator.NotEqualRegEx, value: 'value4' }, - { name: '__name__', operator: AbstractLabelOperator.Equal, value: 'metric_name' }, - ], - }); + describe('exportToAbstractQuery', () => { + it('should extract labels and metric name from PromQL', async () => { + const abstractQuery = exportToAbstractQuery({ + refId: 'bar', + expr: 'metric_name{label1="value1", label2!="value2", label3=~"value3", label4!~"value4"}', + instant: true, + range: false, + }); + + expect(abstractQuery).toMatchObject({ + refId: 'bar', + labelMatchers: [ + { name: 'label1', operator: AbstractLabelOperator.Equal, value: 'value1' }, + { name: 'label2', operator: AbstractLabelOperator.NotEqual, value: 'value2' }, + { name: 'label3', operator: AbstractLabelOperator.EqualRegEx, value: 'value3' }, + { name: 'label4', operator: AbstractLabelOperator.NotEqualRegEx, value: 'value4' }, + { name: '__name__', operator: AbstractLabelOperator.Equal, value: 'metric_name' }, + ], }); }); }); @@ -819,3 +714,194 @@ describe('removeQuotesIfExist', () => { expect(result).toBe('12345'); }); }); + +describe('PrometheusLanguageProvider with feature toggle', () => { + const defaultDatasource: PrometheusDatasource = { + metadataRequest: () => ({ data: { data: [] } }), + getTimeRangeParams: getTimeRangeParams, + interpolateString: (string: string) => string, + hasLabelsMatchAPISupport: () => false, + getDaysToCacheMetadata: () => 1, + getAdjustedInterval: () => getRangeSnapInterval(PrometheusCacheLevel.None, getMockQuantizedTimeRangeParams()), + cacheLevel: PrometheusCacheLevel.None, + getIntervalVars: () => ({}), + getRangeScopedVars: () => ({}), + } as unknown as PrometheusDatasource; + + describe('constructor', () => { + it('should initialize with SeriesApiClient when labels match API is not supported', () => { + const provider = new PrometheusLanguageProvider(defaultDatasource); + expect(provider).toBeInstanceOf(PrometheusLanguageProvider); + }); + + it('should initialize with LabelsApiClient when labels match API is supported', () => { + const datasourceWithLabelsAPI = { + ...defaultDatasource, + hasLabelsMatchAPISupport: () => true, + } as unknown as PrometheusDatasource; + const provider = new PrometheusLanguageProvider(datasourceWithLabelsAPI); + expect(provider).toBeInstanceOf(PrometheusLanguageProvider); + }); + }); + + describe('start', () => { + it('should not start when lookups are disabled', async () => { + const datasourceWithLookupsDisabled = { + ...defaultDatasource, + lookupsDisabled: true, + } as unknown as PrometheusDatasource; + const provider = new PrometheusLanguageProvider(datasourceWithLookupsDisabled); + const result = await provider.start(); + expect(result).toEqual([]); + }); + + it('should use resource client and metricsMetadata is available', async () => { + const provider = new PrometheusLanguageProvider(defaultDatasource); + const mockMetadata = { metric1: { type: 'counter', help: 'help text' } }; + + // Mock the resource client's start method + const resourceClientStartSpy = jest.spyOn(provider['resourceClient'], 'start'); + const queryMetadataSpy = jest.spyOn(provider as any, '_queryMetadata').mockResolvedValue(mockMetadata); + + await provider.start(); + + expect(resourceClientStartSpy).toHaveBeenCalled(); + expect(queryMetadataSpy).toHaveBeenCalled(); + expect(provider.retrieveMetricsMetadata()).toEqual(mockMetadata); + expect(provider.metricsMetadata).toEqual(mockMetadata); // Check backward compatibility + }); + }); + + describe('queryMetricsMetadata', () => { + it('should fetch and store metadata', async () => { + const provider = new PrometheusLanguageProvider(defaultDatasource); + const mockMetadata = { metric1: { type: 'counter', help: 'help text' } }; + const queryMetadataSpy = jest.spyOn(provider as any, '_queryMetadata').mockResolvedValue(mockMetadata); + + const result = await provider.queryMetricsMetadata(); + + expect(queryMetadataSpy).toHaveBeenCalled(); + expect(result).toEqual(mockMetadata); + expect(provider.retrieveMetricsMetadata()).toEqual(mockMetadata); + }); + + it('should handle undefined metadata response', async () => { + const provider = new PrometheusLanguageProvider(defaultDatasource); + const queryMetadataSpy = jest.spyOn(provider as any, '_queryMetadata').mockResolvedValue(undefined); + + const result = await provider.queryMetricsMetadata(); + + expect(queryMetadataSpy).toHaveBeenCalled(); + expect(result).toEqual({}); + expect(provider.retrieveMetricsMetadata()).toEqual({}); + }); + + it('should handle endpoint errors and set empty metadata', async () => { + const provider = new PrometheusLanguageProvider(defaultDatasource); + const queryMetadataSpy = jest + .spyOn(provider as any, '_queryMetadata') + .mockRejectedValue(new Error('Endpoint not found')); + + const result = await provider.queryMetricsMetadata(); + + expect(queryMetadataSpy).toHaveBeenCalled(); + expect(result).toEqual({}); + expect(provider.retrieveMetricsMetadata()).toEqual({}); + }); + }); + + describe('queryLabelKeys and queryLabelValues', () => { + const timeRange = getMockTimeRange(); + + it('should delegate to resource client queryLabelKeys', async () => { + const provider = new PrometheusLanguageProvider(defaultDatasource); + const resourceClientSpy = jest + .spyOn(provider['resourceClient'], 'queryLabelKeys') + .mockResolvedValue(['label1', 'label2']); + + const result = await provider.queryLabelKeys(timeRange, '{job="grafana"}'); + + expect(resourceClientSpy).toHaveBeenCalledWith(timeRange, '{job="grafana"}', undefined); + expect(result).toEqual(['label1', 'label2']); + }); + + it('should delegate to resource client queryLabelValues', async () => { + const provider = new PrometheusLanguageProvider(defaultDatasource); + const resourceClientSpy = jest + .spyOn(provider['resourceClient'], 'queryLabelValues') + .mockResolvedValue(['value1', 'value2']); + + const result = await provider.queryLabelValues(timeRange, 'job', '{job="grafana"}'); + + expect(resourceClientSpy).toHaveBeenCalledWith(timeRange, 'job', '{job="grafana"}', undefined); + expect(result).toEqual(['value1', 'value2']); + }); + }); + + describe('retrieveMethods', () => { + it('should delegate to resource client for metrics and labels', () => { + const provider = new PrometheusLanguageProvider(defaultDatasource); + const mockResourceClient = { + histogramMetrics: ['histogram1', 'histogram2'], + metrics: ['metric1', 'metric2'], + labelKeys: ['label1', 'label2'], + }; + + // Mock the resource client properties + Object.defineProperty(provider, '_resourceClient', { + value: mockResourceClient, + writable: true, + }); + + expect(provider.retrieveHistogramMetrics()).toEqual(['histogram1', 'histogram2']); + expect(provider.retrieveMetrics()).toEqual(['metric1', 'metric2']); + expect(provider.retrieveLabelKeys()).toEqual(['label1', 'label2']); + }); + }); + + describe('populateMatchParamsFromQueries', () => { + it('should add match params from queries', () => { + const initialParams = new URLSearchParams(); + const queries: PromQuery[] = [ + { expr: 'metric1', refId: '1' }, + { expr: 'metric2', refId: '2' }, + ]; + + const result = populateMatchParamsFromQueries(initialParams, queries); + + const matches = Array.from(result.getAll('match[]')); + expect(matches).toContain('metric1'); + expect(matches).toContain('metric2'); + }); + + it('should handle binary queries', () => { + const initialParams = new URLSearchParams(); + const queries: PromQuery[] = [{ expr: 'binary{label="val"} + second{}', refId: '1' }]; + + const result = populateMatchParamsFromQueries(initialParams, queries); + + const matches = Array.from(result.getAll('match[]')); + expect(matches).toContain('binary'); + expect(matches).toContain('second'); + }); + + it('should handle undefined queries', () => { + const initialParams = new URLSearchParams({ param: 'value' }); + + const result = populateMatchParamsFromQueries(initialParams, undefined); + + expect(result.toString()).toBe('param=value'); + }); + + it('should handle UTF8 metrics', () => { + // Using the mocked isValidLegacyName function from jest.mock setup + const initialParams = new URLSearchParams(); + const queries: PromQuery[] = [{ expr: '{"utf8.metric", label="value"}', refId: '1' }]; + + const result = populateMatchParamsFromQueries(initialParams, queries); + + const matches = Array.from(result.getAll('match[]')); + expect(matches).toContain('{"utf8.metric"}'); + }); + }); +}); diff --git a/packages/grafana-prometheus/src/language_provider.ts b/packages/grafana-prometheus/src/language_provider.ts index a949e475073..e6f34923067 100644 --- a/packages/grafana-prometheus/src/language_provider.ts +++ b/packages/grafana-prometheus/src/language_provider.ts @@ -16,6 +16,7 @@ import { } from '@grafana/data'; import { BackendSrvRequest } from '@grafana/runtime'; +import { buildCacheHeaders, getDaysToCacheMetadata, getDefaultCacheHeaders } from './caching'; import { REMOVE_SERIES_LIMIT, DEFAULT_SERIES_LIMIT } from './components/metrics-browser/types'; import { Label } from './components/monaco-query-field/monaco-completion-provider/situation'; import { PrometheusDatasource } from './datasource'; @@ -28,61 +29,154 @@ import { } from './language_utils'; import PromqlSyntax from './promql'; import { buildVisualQueryFromString } from './querybuilder/parsing'; -import { PrometheusCacheLevel, PromMetricsMetadata, PromQuery } from './types'; +import { LabelsApiClient, ResourceApiClient, SeriesApiClient } from './resource_clients'; +import { PromMetricsMetadata, PromQuery } from './types'; import { escapeForUtf8Support, isValidLegacyName } from './utf8_support'; const DEFAULT_KEYS = ['job', 'instance']; const EMPTY_SELECTOR = '{}'; -type UrlParamsType = { - start?: string; - end?: string; - 'match[]'?: string; - limit?: string; +/** + * Prometheus API endpoints for fetching resoruces + */ +const API_V1 = { + METADATA: '/api/v1/metadata', + SERIES: '/api/v1/series', + LABELS: '/api/v1/labels', + LABELS_VALUES: (labelKey: string) => `/api/v1/label/${labelKey}/values`, }; -const buildCacheHeaders = (durationInSeconds: number) => { - return { - headers: { - 'X-Grafana-Cache': `private, max-age=${durationInSeconds}`, - }, - }; -}; +export interface PrometheusBaseLanguageProvider { + datasource: PrometheusDatasource; -export function getMetadataString(metric: string, metadata: PromMetricsMetadata): string | undefined { - if (!metadata[metric]) { - return undefined; - } - const { type, help } = metadata[metric]; - return `${type.toUpperCase()}: ${help}`; -} + /** + * When no timeRange provided, we will use the default time range (now/now-6h) + * @param timeRange + */ + start: (timeRange?: TimeRange) => Promise; -export function getMetadataHelp(metric: string, metadata: PromMetricsMetadata): string | undefined { - if (!metadata[metric]) { - return undefined; - } - return metadata[metric].help; -} + request: (url: string, params?: any, options?: Partial) => Promise; -export function getMetadataType(metric: string, metadata: PromMetricsMetadata): string | undefined { - if (!metadata[metric]) { - return undefined; - } - return metadata[metric].type; + fetchSuggestions: ( + timeRange?: TimeRange, + queries?: PromQuery[], + scopes?: Scope[], + adhocFilters?: AdHocVariableFilter[], + labelName?: string, + limit?: number, + requestId?: string + ) => Promise; } -const PREFIX_DELIMITER_REGEX = - /(="|!="|=~"|!~"|\{|\[|\(|\+|-|\/|\*|%|\^|\band\b|\bor\b|\bunless\b|==|>=|!=|<=|>|<|=|~|,)/; - -const secondsInDay = 86400; -export default class PromQlLanguageProvider extends LanguageProvider { +/** + * @deprecated This interface is deprecated and will be removed. + */ +export interface PrometheusLegacyLanguageProvider { + /** + * @deprecated Use retrieveHistogramMetrics() method instead + */ histogramMetrics: string[]; + /** + * @deprecated Use retrieveMetrics() method instead + */ metrics: string[]; + /** + * @deprecated Use retrieveMetricsMetadata() method instead + */ metricsMetadata?: PromMetricsMetadata; + /** + * @deprecated Use retrieveLabelKeys() method instead + */ + labelKeys: string[]; + + /** + * @deprecated Use queryMetricsMetadata() method instead. + */ + loadMetricsMetadata: () => void; + /** + * @deprecated Use retrieveMetricsMetadata() method instead + */ + getLabelKeys: () => string[]; + /** + * @deprecated If you need labelKeys or labelValues please use queryLabelKeys() or queryLabelValues() functions + */ + getSeries: (timeRange: TimeRange, selector: string, withName?: boolean) => Promise>; + /** + * @deprecated Use queryLabelValues() method insteadIt'll determine the right endpoint based on the datasource settings + */ + fetchLabelValues: (range: TimeRange, key: string, limit?: string) => Promise; + /** + * @deprecated Use queryLabelValues() method insteadIt'll determine the right endpoint based on the datasource settings + */ + getLabelValues: (range: TimeRange, key: string) => Promise; + /** + * @deprecated If you need labelKeys or labelValues please use queryLabelKeys() or queryLabelValues() functions + */ + fetchLabels: (timeRange: TimeRange, queries?: PromQuery[], limit?: string) => Promise; + /** + * @deprecated Use queryLabelValues() method insteadIt'll determine the right endpoint based on the datasource settings + */ + getSeriesValues: (timeRange: TimeRange, labelName: string, selector: string) => Promise; + /** + * @deprecated Use queryLabelValues() method insteadIt'll determine the right endpoint based on the datasource settings + */ + fetchSeriesValuesWithMatch: ( + timeRange: TimeRange, + name: string, + match?: string, + requestId?: string, + withLimit?: string + ) => Promise; + /** + * @deprecated Use queryLabelKeys() method instead. It'll determine the right endpoint based on the datasource settings + */ + getSeriesLabels: (timeRange: TimeRange, selector: string, otherLabels: Label[]) => Promise; + /** + * @deprecated Use queryLabelKeys() method instead. It'll determine the right endpoint based on the datasource settings + */ + fetchLabelsWithMatch: ( + timeRange: TimeRange, + name: string, + withName?: boolean, + withLimit?: string + ) => Promise>; + /** + * @deprecated Use queryLabelKeys() method instead. It'll determine the right endpoint based on the datasource settings + */ + fetchSeriesLabels: ( + timeRange: TimeRange, + name: string, + withName?: boolean, + withLimit?: string + ) => Promise>; + /** + * @deprecated Use queryLabelKeys() method instead. It'll determine the right endpoint based on the datasource settings + */ + fetchSeriesLabelsMatch: (timeRange: TimeRange, name: string, withLimit?: string) => Promise>; + /** + * @deprecated If you need labelKeys or labelValues please use queryLabelKeys() or queryLabelValues() functions + */ + fetchSeries: (timeRange: TimeRange, match: string) => Promise>>; + /** + * @deprecated If you need labelKeys or labelValues please use queryLabelKeys() or queryLabelValues() functions + */ + fetchDefaultSeries: (timeRange: TimeRange) => Promise<{}>; +} + +/** + * Old implementation of prometheus language provider. + * @deprecated Use PrometheusLanguageProviderInterface and PrometheusLanguageProvider class instead. + */ +export default class PromQlLanguageProvider extends LanguageProvider implements PrometheusLegacyLanguageProvider { declare startTask: Promise; + declare labelFetchTs: number; + datasource: PrometheusDatasource; + + histogramMetrics: string[]; + metrics: string[]; + metricsMetadata?: PromMetricsMetadata; labelKeys: string[] = []; - declare labelFetchTs: number; constructor(datasource: PrometheusDatasource, initialValues?: Partial) { super(); @@ -94,25 +188,7 @@ export default class PromQlLanguageProvider extends LanguageProvider { Object.assign(this, initialValues); } - getDefaultCacheHeaders() { - if (this.datasource.cacheLevel !== PrometheusCacheLevel.None) { - return buildCacheHeaders(this.datasource.getCacheDurationInMinutes() * 60); - } - return; - } - - // Strip syntax chars so that typeahead suggestions can work on clean inputs - cleanText(s: string) { - const parts = s.split(PREFIX_DELIMITER_REGEX); - const last = parts.pop()!; - return last.trimStart().replace(/"$/, '').replace(/^"/, ''); - } - - get syntax() { - return PromqlSyntax; - } - - request = async (url: string, defaultValue: any, params = {}, options?: Partial) => { + request = async (url: string, params = {}, options?: Partial) => { try { const res = await this.datasource.metadataRequest(url, params, options); return res.data.data; @@ -122,9 +198,12 @@ export default class PromQlLanguageProvider extends LanguageProvider { } } - return defaultValue; + return undefined; }; + /** + * Overridden by PrometheusLanguageProvider + */ start = async (timeRange: TimeRange = getDefaultTimeRange()): Promise => { if (this.datasource.lookupsDisabled) { return []; @@ -136,11 +215,11 @@ export default class PromQlLanguageProvider extends LanguageProvider { }; async loadMetricsMetadata() { - const headers = buildCacheHeaders(this.datasource.getDaysToCacheMetadata() * secondsInDay); + const secondsInDay = 86400; + const headers = buildCacheHeaders(getDaysToCacheMetadata(this.datasource.cacheLevel) * secondsInDay); this.metricsMetadata = fixSummariesMetadata( await this.request( - '/api/v1/metadata', - {}, + API_V1.METADATA, {}, { showErrorAlert: false, @@ -154,32 +233,6 @@ export default class PromQlLanguageProvider extends LanguageProvider { return this.labelKeys; } - importFromAbstractQuery(labelBasedQuery: AbstractQuery): PromQuery { - return toPromLikeQuery(labelBasedQuery); - } - - exportToAbstractQuery(query: PromQuery): AbstractQuery { - const promQuery = query.expr; - if (!promQuery || promQuery.length === 0) { - return { refId: query.refId, labelMatchers: [] }; - } - const tokens = Prism.tokenize(promQuery, PromqlSyntax); - const labelMatchers: AbstractLabelMatcher[] = extractLabelMatchers(tokens); - const nameLabelValue = getNameLabelValue(promQuery, tokens); - if (nameLabelValue && nameLabelValue.length > 0) { - labelMatchers.push({ - name: '__name__', - operator: AbstractLabelOperator.Equal, - value: nameLabelValue, - }); - } - - return { - refId: query.refId, - labelMatchers, - }; - } - async getSeries(timeRange: TimeRange, selector: string, withName?: boolean): Promise> { if (this.datasource.lookupsDisabled) { return {}; @@ -201,8 +254,11 @@ export default class PromQlLanguageProvider extends LanguageProvider { const params = { ...this.datasource.getAdjustedInterval(range), ...(limit ? { limit } : {}) }; const interpolatedName = this.datasource.interpolateString(key); const interpolatedAndEscapedName = escapeForUtf8Support(removeQuotesIfExist(interpolatedName)); - const url = `/api/v1/label/${interpolatedAndEscapedName}/values`; - const value = await this.request(url, [], params, this.getDefaultCacheHeaders()); + const value = await this.request( + API_V1.LABELS_VALUES(interpolatedAndEscapedName), + params, + getDefaultCacheHeaders(this.datasource.cacheLevel) + ); return value ?? []; }; @@ -214,7 +270,7 @@ export default class PromQlLanguageProvider extends LanguageProvider { * Fetches all label keys */ fetchLabels = async (timeRange: TimeRange, queries?: PromQuery[], limit?: string): Promise => { - let url = '/api/v1/labels'; + let url = API_V1.LABELS; const timeParams = this.datasource.getAdjustedInterval(timeRange); this.labelFetchTs = Date.now().valueOf(); @@ -236,7 +292,7 @@ export default class PromQlLanguageProvider extends LanguageProvider { url += `?${searchParams.toString()}`; } - const res = await this.request(url, [], searchParams, this.getDefaultCacheHeaders()); + const res = await this.request(url, searchParams, getDefaultCacheHeaders(this.datasource.cacheLevel)); if (Array.isArray(res)) { this.labelKeys = res.slice().sort(); return [...this.labelKeys]; @@ -277,7 +333,7 @@ export default class PromQlLanguageProvider extends LanguageProvider { ...(withLimit ? { limit: withLimit } : {}), }; let requestOptions: Partial | undefined = { - ...this.getDefaultCacheHeaders(), + ...getDefaultCacheHeaders(this.datasource.cacheLevel), ...(requestId && { requestId }), }; @@ -287,12 +343,7 @@ export default class PromQlLanguageProvider extends LanguageProvider { const interpolatedAndEscapedName = escapeForUtf8Support(removeQuotesIfExist(interpolatedName ?? '')); - const value = await this.request( - `/api/v1/label/${interpolatedAndEscapedName}/values`, - [], - urlParams, - requestOptions - ); + const value = await this.request(API_V1.LABELS_VALUES(interpolatedAndEscapedName), urlParams, requestOptions); return value ?? []; }; @@ -348,18 +399,13 @@ export default class PromQlLanguageProvider extends LanguageProvider { ): Promise> => { const interpolatedName = this.datasource.interpolateString(name); const range = this.datasource.getAdjustedInterval(timeRange); - let urlParams: UrlParamsType = { + let urlParams = { ...range, 'match[]': interpolatedName, + ...(withLimit !== 'none' ? { limit: withLimit ?? DEFAULT_SERIES_LIMIT } : {}), }; - if (withLimit !== 'none') { - urlParams = { ...urlParams, limit: withLimit ?? DEFAULT_SERIES_LIMIT }; - } - - const url = `/api/v1/series`; - - const data = await this.request(url, [], urlParams, this.getDefaultCacheHeaders()); + const data = await this.request(API_V1.SERIES, urlParams, getDefaultCacheHeaders(this.datasource.cacheLevel)); const { values } = processLabels(data, withName); return values; }; @@ -380,9 +426,12 @@ export default class PromQlLanguageProvider extends LanguageProvider { 'match[]': interpolatedName, ...(withLimit ? { limit: withLimit } : {}), }; - const url = `/api/v1/labels`; - const data: string[] = await this.request(url, [], urlParams, this.getDefaultCacheHeaders()); + const data: string[] = await this.request( + API_V1.LABELS, + urlParams, + getDefaultCacheHeaders(this.datasource.cacheLevel) + ); // Convert string array to Record return data.reduce((ac, a) => ({ ...ac, [a]: '' }), {}); }; @@ -391,10 +440,9 @@ export default class PromQlLanguageProvider extends LanguageProvider { * Fetch series for a selector. Use this for raw results. Use fetchSeriesLabels() to get labels. */ fetchSeries = async (timeRange: TimeRange, match: string): Promise>> => { - const url = '/api/v1/series'; const range = this.datasource.getTimeRangeParams(timeRange); const params = { ...range, 'match[]': match }; - return await this.request(url, {}, params, this.getDefaultCacheHeaders()); + return await this.request(API_V1.SERIES, params, getDefaultCacheHeaders(this.datasource.cacheLevel)); }; /** @@ -427,7 +475,6 @@ export default class PromQlLanguageProvider extends LanguageProvider { const timeParams = this.datasource.getAdjustedInterval(timeRange); const value = await this.request( url, - [], { labelName, queries: queries?.map((q) => @@ -453,7 +500,7 @@ export default class PromQlLanguageProvider extends LanguageProvider { { ...(requestId && { requestId }), headers: { - ...this.getDefaultCacheHeaders()?.headers, + ...getDefaultCacheHeaders(this.datasource.cacheLevel)?.headers, 'Content-Type': 'application/json', }, method: 'POST', @@ -464,27 +511,297 @@ export default class PromQlLanguageProvider extends LanguageProvider { }; } -function getNameLabelValue(promQuery: string, tokens: Array): string { - let nameLabelValue = ''; +export interface PrometheusLanguageProviderInterface + extends PrometheusBaseLanguageProvider, + PrometheusLegacyLanguageProvider { + retrieveMetricsMetadata: () => PromMetricsMetadata; + retrieveHistogramMetrics: () => string[]; + retrieveMetrics: () => string[]; + retrieveLabelKeys: () => string[]; + + queryMetricsMetadata: () => Promise; + queryLabelKeys: (timeRange: TimeRange, match?: string, limit?: string) => Promise; + queryLabelValues: (timeRange: TimeRange, labelKey: string, match?: string, limit?: string) => Promise; +} - for (const token of tokens) { - if (typeof token === 'string') { - nameLabelValue = token; - break; +/** + * Modern implementation of the Prometheus language provider that abstracts API endpoint selection. + * + * Features: + * - Automatically selects the most efficient API endpoint based on Prometheus version and configuration + * - Supports both labels and series endpoints for backward compatibility + * - Handles match[] parameters for filtering time series data + * - Implements automatic request limiting (default: 40_000 series) + * - Provides unified interface for both modern and legacy Prometheus versions + * + * @see LabelsApiClient For modern Prometheus versions using the labels API + * @see SeriesApiClient For legacy Prometheus versions using the series API + */ +export class PrometheusLanguageProvider extends PromQlLanguageProvider implements PrometheusLanguageProviderInterface { + private _metricsMetadata?: PromMetricsMetadata; + private _resourceClient?: ResourceApiClient; + + constructor(datasource: PrometheusDatasource) { + super(datasource); + } + + /** + * Lazily initializes and returns the appropriate resource client based on Prometheus version. + * + * The client selection logic: + * - For Prometheus v2.6+ with labels API: Uses LabelsApiClient for efficient label-based queries + * - For older versions: Falls back to SeriesApiClient for backward compatibility + * + * The client instance is cached after first initialization to avoid repeated creation. + * + * @returns {ResourceApiClient} An instance of either LabelsApiClient or SeriesApiClient + */ + private get resourceClient(): ResourceApiClient { + if (!this._resourceClient) { + this._resourceClient = this.datasource.hasLabelsMatchAPISupport() + ? new LabelsApiClient(this.request, this.datasource) + : new SeriesApiClient(this.request, this.datasource); } + + return this._resourceClient; } - return nameLabelValue; + + /** + * Same start logic but it uses resource clients. Backward compatibility it calls _backwardCompatibleStart. + * Some places still relies on deprecated fields. Until we replace them we need _backwardCompatibleStart method + */ + start = async (timeRange: TimeRange = getDefaultTimeRange()): Promise => { + if (this.datasource.lookupsDisabled) { + return []; + } + await Promise.all([this.resourceClient.start(timeRange), this.queryMetricsMetadata()]); + return this._backwardCompatibleStart(); + }; + + /** + * This private method exists to make sure the old class will be functional until we remove it. + * When we remove old class (PromQlLanguageProvider) we should remove this method too. + */ + private _backwardCompatibleStart = async () => { + this.metricsMetadata = this.retrieveMetricsMetadata(); + this.metrics = this.retrieveMetrics(); + this.histogramMetrics = this.retrieveHistogramMetrics(); + this.labelKeys = this.retrieveLabelKeys(); + return []; + }; + + /** + * Fetches metadata for metrics from Prometheus. + * Sets cache headers based on the configured metadata cache duration. + * + * @returns {Promise} Promise that resolves when metadata has been fetched + */ + private _queryMetadata = async () => { + const secondsInDay = 86400; + const headers = buildCacheHeaders(getDaysToCacheMetadata(this.datasource.cacheLevel) * secondsInDay); + const metadata = await this.request( + API_V1.METADATA, + {}, + { + showErrorAlert: false, + ...headers, + } + ); + return fixSummariesMetadata(metadata); + }; + + /** + * Retrieves the cached Prometheus metrics metadata. + * This metadata includes type information (counter, gauge, etc.) and help text for metrics. + * + * @returns {PromMetricsMetadata} Cached metadata or empty object if not yet fetched + */ + public retrieveMetricsMetadata = (): PromMetricsMetadata => { + return this._metricsMetadata ?? {}; + }; + + /** + * Retrieves the list of histogram metrics from the current resource client. + * Histogram metrics are identified by the '_bucket' suffix and are used for percentile calculations. + * + * @returns {string[]} Array of histogram metric names + */ + public retrieveHistogramMetrics = (): string[] => { + return this.resourceClient?.histogramMetrics; + }; + + /** + * Retrieves the complete list of available metrics from the current resource client. + * This includes all metric names regardless of their type (counter, gauge, histogram). + * + * @returns {string[]} Array of all metric names + */ + public retrieveMetrics = (): string[] => { + return this.resourceClient?.metrics; + }; + + /** + * Retrieves the list of available label keys from the current resource client. + * Label keys are the names of labels that can be used to filter and group metrics. + * + * @returns {string[]} Array of label key names + */ + public retrieveLabelKeys = (): string[] => { + return this.resourceClient?.labelKeys; + }; + + /** + * Fetches fresh metrics metadata from Prometheus and updates the cache. + * This includes querying for metric types, help text, and unit information. + * If the fetch fails, the cache is set to an empty object to prevent stale data. + * + * @returns {Promise} Promise that resolves to the fetched metadata + */ + public queryMetricsMetadata = async (): Promise => { + try { + this._metricsMetadata = (await this._queryMetadata()) ?? {}; + } catch (error) { + this._metricsMetadata = {}; + } + return this._metricsMetadata; + }; + + /** + * Fetches all available label keys that match the specified criteria. + * + * This method queries Prometheus for label keys within the specified time range. + * The results can be filtered using the match parameter and limited in size. + * Uses either the labels API (Prometheus v2.6+) or series API based on version. + * + * @param {TimeRange} timeRange - Time range to search for label keys + * @param {string} [match] - Optional PromQL selector to filter label keys (e.g., '{job="grafana"}') + * @param {string} [limit] - Optional maximum number of label keys to return + * @returns {Promise} Array of matching label key names, sorted alphabetically + */ + public queryLabelKeys = async (timeRange: TimeRange, match?: string, limit?: string): Promise => { + return await this.resourceClient.queryLabelKeys(timeRange, match, limit); + }; + + /** + * Fetches all values for a specific label key that match the specified criteria. + * + * This method queries Prometheus for label values within the specified time range. + * Results can be filtered using the match parameter to find values in specific contexts. + * Supports both modern (labels API) and legacy (series API) Prometheus versions. + * + * The method automatically handles UTF-8 encoded label keys by properly escaping them + * before making API requests. This means you can safely pass label keys containing + * special characters like dots, colons, or Unicode characters (e.g., 'http.status:code', + * 'μs', 'response.time'). + * + * @param {TimeRange} timeRange - Time range to search for label values + * @param {string} labelKey - The label key to fetch values for (e.g., 'job', 'instance', 'http.status:code') + * @param {string} [match] - Optional PromQL selector to filter values (e.g., '{job="grafana"}') + * @param {string} [limit] - Optional maximum number of values to return + * @returns {Promise} Array of matching label values, sorted alphabetically + * @example + * // Fetch all values for the 'job' label + * const values = await queryLabelValues(timeRange, 'job'); + * // Fetch 'instance' values only for jobs matching 'grafana' + * const instances = await queryLabelValues(timeRange, 'instance', '{job="grafana"}'); + * // Fetch values for a label key with special characters + * const statusCodes = await queryLabelValues(timeRange, 'http.status:code'); + */ + public queryLabelValues = async ( + timeRange: TimeRange, + labelKey: string, + match?: string, + limit?: string + ): Promise => { + return await this.resourceClient.queryLabelValues(timeRange, labelKey, match, limit); + }; } +export const importFromAbstractQuery = (labelBasedQuery: AbstractQuery): PromQuery => { + return toPromLikeQuery(labelBasedQuery); +}; + +export const exportToAbstractQuery = (query: PromQuery): AbstractQuery => { + const promQuery = query.expr; + if (!promQuery || promQuery.length === 0) { + return { refId: query.refId, labelMatchers: [] }; + } + const tokens = Prism.tokenize(promQuery, PromqlSyntax); + const labelMatchers: AbstractLabelMatcher[] = extractLabelMatchers(tokens); + const nameLabelValue = getNameLabelValue(promQuery, tokens); + if (nameLabelValue && nameLabelValue.length > 0) { + labelMatchers.push({ + name: '__name__', + operator: AbstractLabelOperator.Equal, + value: nameLabelValue, + }); + } + + return { + refId: query.refId, + labelMatchers, + }; +}; + +/** + * Checks if an error is a cancelled request error. + * Used to avoid logging cancelled request errors. + * + * @param {unknown} error - Error to check + * @returns {boolean} True if the error is a cancelled request error + */ function isCancelledError(error: unknown): error is { cancelled: boolean; } { return typeof error === 'object' && error !== null && 'cancelled' in error && error.cancelled === true; } -// For utf8 labels we use quotes around the label -// While requesting the label values we must remove the quotes +/** + * Removes quotes from a string if they exist. + * Used to handle utf8 label keys in Prometheus queries. + * + * @param {string} input - Input string that may have surrounding quotes + * @returns {string} String with surrounding quotes removed if they existed + */ export function removeQuotesIfExist(input: string): string { const match = input.match(/^"(.*)"$/); // extract the content inside the quotes return match?.[1] ?? input; } + +function getNameLabelValue(promQuery: string, tokens: Array): string { + let nameLabelValue = ''; + + for (const token of tokens) { + if (typeof token === 'string') { + nameLabelValue = token; + break; + } + } + return nameLabelValue; +} + +/** + * Extracts metrics from queries and populates match parameters. + * This is used to filter time series data based on existing queries. + * Handles UTF8 metrics by properly escaping them. + * + * @param {URLSearchParams} initialParams - Initial URL parameters + * @param {PromQuery[]} queries - Array of Prometheus queries + * @returns {URLSearchParams} URL parameters with match[] parameters added + */ +export const populateMatchParamsFromQueries = ( + initialParams: URLSearchParams, + queries?: PromQuery[] +): URLSearchParams => { + return (queries ?? []).reduce((params, query) => { + const visualQuery = buildVisualQueryFromString(query.expr); + const isUtf8Metric = !isValidLegacyName(visualQuery.query.metric); + params.append('match[]', isUtf8Metric ? `{"${visualQuery.query.metric}"}` : visualQuery.query.metric); + if (visualQuery.query.binaryQueries) { + visualQuery.query.binaryQueries.forEach((bq) => { + params.append('match[]', isUtf8Metric ? `{"${bq.query.metric}"}` : bq.query.metric); + }); + } + return params; + }, initialParams); +}; diff --git a/packages/grafana-prometheus/src/querybuilder/components/MetricsLabelsSection.tsx b/packages/grafana-prometheus/src/querybuilder/components/MetricsLabelsSection.tsx index 814f3de502e..e3cda329060 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/MetricsLabelsSection.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/MetricsLabelsSection.tsx @@ -4,8 +4,8 @@ import { useCallback } from 'react'; import { SelectableValue, TimeRange } from '@grafana/data'; import { PrometheusDatasource } from '../../datasource'; -import { getMetadataString } from '../../language_provider'; import { truncateResult } from '../../language_utils'; +import { PromMetricsMetadata } from '../../types'; import { regexifyLabelValuesQueryString } from '../parsingUtils'; import { promQueryModeller } from '../shared/modeller_instance'; import { QueryBuilderLabelFilter } from '../shared/types'; @@ -257,3 +257,11 @@ async function getMetrics( description: getMetadataString(m, datasource.languageProvider.metricsMetadata!), })); } + +export function getMetadataString(metric: string, metadata: PromMetricsMetadata): string | undefined { + if (!metadata[metric]) { + return; + } + const { type, help } = metadata[metric]; + return `${type.toUpperCase()}: ${help}`; +} 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 452085555a0..31ff7dba463 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 @@ -4,7 +4,7 @@ import { AnyAction } from '@reduxjs/toolkit'; import { reportInteraction } from '@grafana/runtime'; import { PrometheusDatasource } from '../../../../datasource'; -import { getMetadataHelp, getMetadataType } from '../../../../language_provider'; +import { PromMetricsMetadata } from '../../../../types'; import { regexifyLabelValuesQueryString } from '../../../parsingUtils'; import { QueryBuilderLabelFilter } from '../../../shared/types'; import { PromVisualQuery } from '../../../types'; @@ -86,6 +86,14 @@ function buildMetricData(metric: string, datasource: PrometheusDatasource): Metr return metricData; } +export function getMetadataHelp(metric: string, metadata: PromMetricsMetadata): string | undefined { + return metadata[metric]?.help; +} + +export function getMetadataType(metric: string, metadata: PromMetricsMetadata): string | undefined { + return metadata[metric]?.type; +} + /** * The filtered and paginated metrics displayed in the modal * */ diff --git a/packages/grafana-prometheus/src/resource_clients.test.ts b/packages/grafana-prometheus/src/resource_clients.test.ts new file mode 100644 index 00000000000..e0af51ff04e --- /dev/null +++ b/packages/grafana-prometheus/src/resource_clients.test.ts @@ -0,0 +1,474 @@ +import { dateTime, TimeRange } from '@grafana/data'; + +import { PrometheusDatasource } from './datasource'; +import { BaseResourceClient, LabelsApiClient, processSeries, SeriesApiClient } from './resource_clients'; +import { PrometheusCacheLevel } from './types'; + +const mockTimeRange: TimeRange = { + from: dateTime(1681300292392), + to: dateTime(1681300293392), + raw: { + from: 'now-1s', + to: 'now', + }, +}; + +const mockRequest = jest.fn().mockResolvedValue([]); +const mockGetAdjustedInterval = jest.fn().mockReturnValue({ + start: '1681300260', + end: '1681300320', +}); +const mockGetTimeRangeParams = jest.fn().mockReturnValue({ + start: '1681300260', + end: '1681300320', +}); +const mockInterpolateString = jest.fn((str) => str); +const defaultCacheHeaders = { headers: { 'X-Grafana-Cache': 'private, max-age=60' } }; + +describe('LabelsApiClient', () => { + let client: LabelsApiClient; + + beforeEach(() => { + jest.clearAllMocks(); + client = new LabelsApiClient(mockRequest, { + cacheLevel: PrometheusCacheLevel.Low, + getAdjustedInterval: mockGetAdjustedInterval, + getTimeRangeParams: mockGetTimeRangeParams, + interpolateString: mockInterpolateString, + } as unknown as PrometheusDatasource); + }); + + describe('start', () => { + it('should initialize metrics and label keys', async () => { + mockRequest.mockResolvedValueOnce(['metric1', 'metric2']).mockResolvedValueOnce(['label1', 'label2']); + + await client.start(mockTimeRange); + + expect(client.metrics).toEqual(['metric1', 'metric2']); + expect(client.labelKeys).toEqual(['label1', 'label2']); + }); + }); + + describe('queryMetrics', () => { + it('should fetch metrics and process histogram metrics', async () => { + mockRequest.mockResolvedValueOnce(['metric1_bucket', 'metric2_sum', 'metric3_count']); + + const result = await client.queryMetrics(mockTimeRange); + + expect(result.metrics).toEqual(['metric1_bucket', 'metric2_sum', 'metric3_count']); + expect(result.histogramMetrics).toEqual(['metric1_bucket']); + }); + }); + + describe('queryLabelKeys', () => { + it('should fetch and sort label keys', async () => { + mockRequest.mockResolvedValueOnce(['label2', 'label1', 'label3']); + + const result = await client.queryLabelKeys(mockTimeRange); + + expect(result).toEqual(['label1', 'label2', 'label3']); + expect(mockRequest).toHaveBeenCalledWith( + '/api/v1/labels', + { + limit: '40000', + start: expect.any(String), + end: expect.any(String), + }, + defaultCacheHeaders + ); + }); + + it('should include match parameter when provided', async () => { + mockRequest.mockResolvedValueOnce(['label1', 'label2']); + + await client.queryLabelKeys(mockTimeRange, '{job="grafana"}'); + + expect(mockRequest).toHaveBeenCalledWith( + '/api/v1/labels', + { + 'match[]': '{job="grafana"}', + limit: '40000', + start: expect.any(String), + end: expect.any(String), + }, + defaultCacheHeaders + ); + }); + }); + + describe('queryLabelValues', () => { + it('should fetch label values with proper encoding', async () => { + mockRequest.mockResolvedValueOnce(['value1', 'value2']); + mockInterpolateString.mockImplementationOnce((str) => str); + + const result = await client.queryLabelValues(mockTimeRange, 'job'); + + expect(result).toEqual(['value1', 'value2']); + expect(mockRequest).toHaveBeenCalledWith( + '/api/v1/label/job/values', + { + start: expect.any(String), + end: expect.any(String), + limit: '40000', + }, + defaultCacheHeaders + ); + }); + + it('should handle UTF-8 label names', async () => { + mockRequest.mockResolvedValueOnce(['value1', 'value2']); + mockInterpolateString.mockImplementationOnce((str) => 'http.status:sum'); + + await client.queryLabelValues(mockTimeRange, '"http.status:sum"'); + + expect(mockRequest).toHaveBeenCalledWith( + '/api/v1/label/U__http_2e_status:sum/values', + { + start: expect.any(String), + end: expect.any(String), + limit: '40000', + }, + defaultCacheHeaders + ); + }); + }); +}); + +describe('SeriesApiClient', () => { + let client: SeriesApiClient; + + beforeEach(() => { + jest.clearAllMocks(); + client = new SeriesApiClient(mockRequest, { + cacheLevel: PrometheusCacheLevel.Low, + getAdjustedInterval: mockGetAdjustedInterval, + getTimeRangeParams: mockGetTimeRangeParams, + interpolateString: mockInterpolateString, + } as unknown as PrometheusDatasource); + }); + + describe('start', () => { + it('should initialize metrics and histogram metrics', async () => { + mockRequest.mockResolvedValueOnce([{ __name__: 'metric1_bucket' }, { __name__: 'metric2_sum' }]); + + await client.start(mockTimeRange); + + expect(client.metrics).toEqual(['metric1_bucket', 'metric2_sum']); + expect(client.histogramMetrics).toEqual(['metric1_bucket']); + }); + }); + + describe('queryMetrics', () => { + it('should fetch and process series data', async () => { + mockRequest.mockResolvedValueOnce([ + { __name__: 'metric1', label1: 'value1' }, + { __name__: 'metric2', label2: 'value2' }, + ]); + + const result = await client.queryMetrics(mockTimeRange); + + expect(result.metrics).toEqual(['metric1', 'metric2']); + expect(client.labelKeys).toEqual(['label1', 'label2']); + }); + }); + + describe('queryLabelKeys', () => { + it('should throw error if match parameter is not provided', async () => { + await expect(client.queryLabelKeys(mockTimeRange)).rejects.toThrow( + 'Series endpoint always expects at least one matcher' + ); + }); + + it('should fetch and process label keys from series', async () => { + mockRequest.mockResolvedValueOnce([{ __name__: 'metric1', label1: 'value1', label2: 'value2' }]); + + const result = await client.queryLabelKeys(mockTimeRange, '{job="grafana"}'); + + expect(result).toEqual(['label1', 'label2']); + }); + + it('should use MATCH_ALL_LABELS when empty matcher is provided', async () => { + mockRequest.mockResolvedValueOnce([{ __name__: 'metric1', label1: 'value1', label2: 'value2' }]); + + const result = await client.queryLabelKeys(mockTimeRange, '{}'); + + expect(mockRequest).toHaveBeenCalledWith( + '/api/v1/series', + expect.objectContaining({ + 'match[]': '{__name__!=""}', + }), + expect.any(Object) + ); + expect(result).toEqual(['label1', 'label2']); + }); + }); + + describe('queryLabelValues', () => { + it('should fetch and process label values from series', async () => { + mockRequest.mockResolvedValueOnce([ + { __name__: 'metric1', job: 'grafana' }, + { __name__: 'metric2', job: 'prometheus' }, + ]); + + const result = await client.queryLabelValues(mockTimeRange, 'job', '{__name__="metric1"}'); + + expect(mockRequest).toHaveBeenCalledWith( + '/api/v1/series', + expect.objectContaining({ + 'match[]': '{__name__="metric1"}', + }), + expect.any(Object) + ); + expect(result).toEqual(['grafana', 'prometheus']); + }); + + it('should create matcher with label when no matcher is provided', async () => { + mockRequest.mockResolvedValueOnce([{ __name__: 'metric1', job: 'grafana' }]); + + await client.queryLabelValues(mockTimeRange, 'job'); + + expect(mockRequest).toHaveBeenCalledWith( + '/api/v1/series', + expect.objectContaining({ + 'match[]': '{job!=""}', + }), + expect.any(Object) + ); + }); + + it('should create matcher with label when empty matcher is provided', async () => { + mockRequest.mockResolvedValueOnce([{ __name__: 'metric1', job: 'grafana' }]); + + await client.queryLabelValues(mockTimeRange, 'job', '{}'); + + expect(mockRequest).toHaveBeenCalledWith( + '/api/v1/series', + expect.objectContaining({ + 'match[]': '{job!=""}', + }), + expect.any(Object) + ); + }); + }); +}); + +describe('processSeries', () => { + it('should extract metrics and label keys from series data', () => { + const result = processSeries([ + { + __name__: 'alerts', + alertname: 'AppCrash', + alertstate: 'firing', + instance: 'host.docker.internal:3000', + job: 'grafana', + severity: 'critical', + }, + { + __name__: 'alerts', + alertname: 'AppCrash', + alertstate: 'firing', + instance: 'prometheus-utf8:9112', + job: 'prometheus-utf8', + severity: 'critical', + }, + { + __name__: 'counters_logins', + app: 'backend', + geohash: '9wvfgzurfzb', + instance: 'fake-prometheus-data:9091', + job: 'fake-data-gen', + server: 'backend-01', + }, + ]); + + // Check structure + expect(result).toHaveProperty('metrics'); + expect(result).toHaveProperty('labelKeys'); + + // Verify metrics are extracted correctly + expect(result.metrics).toEqual(['alerts', 'counters_logins']); + + // Verify all metrics are unique + expect(result.metrics.length).toBe(new Set(result.metrics).size); + + // Verify label keys are extracted correctly and don't include __name__ + expect(result.labelKeys).toContain('instance'); + expect(result.labelKeys).toContain('job'); + expect(result.labelKeys).not.toContain('__name__'); + + // Verify all label keys are unique + expect(result.labelKeys.length).toBe(new Set(result.labelKeys).size); + }); + + it('should handle empty series data', () => { + const result = processSeries([]); + + expect(result.metrics).toEqual([]); + expect(result.labelKeys).toEqual([]); + }); + + it('should handle series without __name__ attribute', () => { + const series = [ + { instance: 'localhost:9090', job: 'prometheus' }, + { instance: 'localhost:9100', job: 'node' }, + ]; + + const result = processSeries(series); + + expect(result.metrics).toEqual([]); + expect(result.labelKeys).toEqual(['instance', 'job']); + }); + + it('should extract label values for a specific key when findValuesForKey is provided', () => { + const series = [ + { + __name__: 'alerts', + instance: 'host.docker.internal:3000', + job: 'grafana', + severity: 'critical', + }, + { + __name__: 'alerts', + instance: 'prometheus-utf8:9112', + job: 'prometheus-utf8', + severity: 'critical', + }, + { + __name__: 'counters_logins', + instance: 'fake-prometheus-data:9091', + job: 'fake-data-gen', + severity: 'warning', + }, + ]; + + // Test finding values for 'job' label + const jobResult = processSeries(series, 'job'); + expect(jobResult.labelValues).toEqual(['fake-data-gen', 'grafana', 'prometheus-utf8']); + + // Test finding values for 'severity' label + const severityResult = processSeries(series, 'severity'); + expect(severityResult.labelValues).toEqual(['critical', 'warning']); + + // Test finding values for 'instance' label + const instanceResult = processSeries(series, 'instance'); + expect(instanceResult.labelValues).toEqual([ + 'fake-prometheus-data:9091', + 'host.docker.internal:3000', + 'prometheus-utf8:9112', + ]); + }); + + it('should return empty labelValues array when findValuesForKey is not provided', () => { + const series = [ + { + __name__: 'alerts', + instance: 'host.docker.internal:3000', + job: 'grafana', + }, + ]; + + const result = processSeries(series); + expect(result.labelValues).toEqual([]); + }); + + it('should return empty labelValues array when findValuesForKey does not match any labels', () => { + const series = [ + { + __name__: 'alerts', + instance: 'host.docker.internal:3000', + job: 'grafana', + }, + ]; + + const result = processSeries(series, 'non_existent_label'); + expect(result.labelValues).toEqual([]); + }); +}); + +describe('BaseResourceClient', () => { + const mockRequest = jest.fn(); + const mockGetTimeRangeParams = jest.fn(); + const mockDatasource = { + cacheLevel: PrometheusCacheLevel.Low, + getTimeRangeParams: mockGetTimeRangeParams, + } as unknown as PrometheusDatasource; + + class TestBaseResourceClient extends BaseResourceClient { + constructor() { + super(mockRequest, mockDatasource); + } + } + + let client: TestBaseResourceClient; + + beforeEach(() => { + jest.clearAllMocks(); + client = new TestBaseResourceClient(); + }); + + describe('querySeries', () => { + const mockTimeRange = { + from: dateTime(1681300292392), + to: dateTime(1681300293392), + raw: { + from: 'now-1s', + to: 'now', + }, + }; + + beforeEach(() => { + mockGetTimeRangeParams.mockReturnValue({ start: '1681300260', end: '1681300320' }); + }); + + it('should make request with correct parameters', async () => { + mockRequest.mockResolvedValueOnce([{ __name__: 'metric1' }]); + + const result = await client.querySeries(mockTimeRange, '{job="grafana"}'); + + expect(mockRequest).toHaveBeenCalledWith( + '/api/v1/series', + { + start: '1681300260', + end: '1681300320', + 'match[]': '{job="grafana"}', + limit: '40000', + }, + { headers: { 'X-Grafana-Cache': 'private, max-age=60' } } + ); + expect(result).toEqual([{ __name__: 'metric1' }]); + }); + + it('should use custom limit when provided', async () => { + mockRequest.mockResolvedValueOnce([]); + + await client.querySeries(mockTimeRange, '{job="grafana"}', '1000'); + + expect(mockRequest).toHaveBeenCalledWith( + '/api/v1/series', + { + start: '1681300260', + end: '1681300320', + 'match[]': '{job="grafana"}', + limit: '1000', + }, + { headers: { 'X-Grafana-Cache': 'private, max-age=60' } } + ); + }); + + it('should handle empty response', async () => { + mockRequest.mockResolvedValueOnce(null); + + const result = await client.querySeries(mockTimeRange, '{job="grafana"}'); + + expect(result).toEqual([]); + }); + + it('should handle non-array response', async () => { + mockRequest.mockResolvedValueOnce({ error: 'invalid response' }); + + const result = await client.querySeries(mockTimeRange, '{job="grafana"}'); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/packages/grafana-prometheus/src/resource_clients.ts b/packages/grafana-prometheus/src/resource_clients.ts new file mode 100644 index 00000000000..0aa0db8584c --- /dev/null +++ b/packages/grafana-prometheus/src/resource_clients.ts @@ -0,0 +1,239 @@ +import { TimeRange } from '@grafana/data'; +import { BackendSrvRequest } from '@grafana/runtime'; + +import { getDefaultCacheHeaders } from './caching'; +import { DEFAULT_SERIES_LIMIT } from './components/metrics-browser/types'; +import { PrometheusDatasource } from './datasource'; +import { removeQuotesIfExist } from './language_provider'; +import { getRangeSnapInterval, processHistogramMetrics } from './language_utils'; +import { escapeForUtf8Support } from './utf8_support'; + +type PrometheusSeriesResponse = Array<{ [key: string]: string }>; +type PrometheusLabelsResponse = string[]; + +export interface ResourceApiClient { + metrics: string[]; + histogramMetrics: string[]; + labelKeys: string[]; + cachedLabelValues: Record; + + start: (timeRange: TimeRange) => Promise; + + queryMetrics: (timeRange: TimeRange) => Promise<{ metrics: string[]; histogramMetrics: string[] }>; + queryLabelKeys: (timeRange: TimeRange, match?: string, limit?: string) => Promise; + queryLabelValues: (timeRange: TimeRange, labelKey: string, match?: string, limit?: string) => Promise; + + querySeries: (timeRange: TimeRange, match: string, limit?: string) => Promise; +} + +type RequestFn = ( + url: string, + params?: Record, + options?: Partial +) => Promise; + +const EMPTY_MATCHER = '{}'; +const MATCH_ALL_LABELS = '{__name__!=""}'; +const METRIC_LABEL = '__name__'; + +export abstract class BaseResourceClient { + constructor( + protected readonly request: RequestFn, + protected readonly datasource: PrometheusDatasource + ) {} + + protected async requestLabels( + url: string, + params?: Record, + options?: Partial + ): Promise { + const response = await this.request(url, params, options); + return Array.isArray(response) ? response : []; + } + + protected async requestSeries( + url: string, + params?: Record, + options?: Partial + ): Promise { + const response = await this.request(url, params, options); + return Array.isArray(response) ? response : []; + } + + /** + * Validates and transforms a matcher string for Prometheus series queries. + * + * @param match - The matcher string to validate and transform. Can be undefined, a specific matcher, or '{}'. + * @returns The validated and potentially transformed matcher string. + * @throws Error if the matcher is undefined or empty (null, undefined, or empty string). + * + * @example + * // Returns '{__name__!=""}' for empty matcher + * validateAndTransformMatcher('{}') + * + * // Returns the original matcher for specific matchers + * validateAndTransformMatcher('{job="grafana"}') + */ + protected validateAndTransformMatcher(match?: string): string { + if (!match) { + throw new Error('Series endpoint always expects at least one matcher'); + } + return match === '{}' ? MATCH_ALL_LABELS : match; + } + + /** + * Fetches all time series that match a specific label matcher using **series** endpoint. + * + * @param {TimeRange} timeRange - Time range to use for the query + * @param {string} match - Label matcher to filter time series + * @param {string} limit - Maximum number of series to return + */ + public querySeries = async (timeRange: TimeRange, match: string, limit: string = DEFAULT_SERIES_LIMIT) => { + const effectiveMatch = this.validateAndTransformMatcher(match); + const timeParams = this.datasource.getTimeRangeParams(timeRange); + const searchParams = { ...timeParams, 'match[]': effectiveMatch, limit }; + return await this.requestSeries('/api/v1/series', searchParams, getDefaultCacheHeaders(this.datasource.cacheLevel)); + }; +} + +export class LabelsApiClient extends BaseResourceClient implements ResourceApiClient { + public histogramMetrics: string[] = []; + public metrics: string[] = []; + public labelKeys: string[] = []; + public cachedLabelValues: Record = {}; + + start = async (timeRange: TimeRange) => { + await this.queryMetrics(timeRange); + this.labelKeys = await this.queryLabelKeys(timeRange); + }; + + public queryMetrics = async (timeRange: TimeRange): Promise<{ metrics: string[]; histogramMetrics: string[] }> => { + this.metrics = await this.queryLabelValues(timeRange, METRIC_LABEL); + this.histogramMetrics = processHistogramMetrics(this.metrics); + return { metrics: this.metrics, histogramMetrics: this.histogramMetrics }; + }; + + /** + * Fetches all available label keys from Prometheus using labels endpoint. + * Uses the labels endpoint with optional match parameter for filtering. + * + * @param {TimeRange} timeRange - Time range to use for the query + * @param {string} match - Optional label matcher to filter results + * @param {string} limit - Maximum number of results to return + * @returns {Promise} Array of label keys sorted alphabetically + */ + public queryLabelKeys = async ( + timeRange: TimeRange, + match?: string, + limit: string = DEFAULT_SERIES_LIMIT + ): Promise => { + let url = '/api/v1/labels'; + const timeParams = getRangeSnapInterval(this.datasource.cacheLevel, timeRange); + const searchParams = { limit, ...timeParams, ...(match ? { 'match[]': match } : {}) }; + + const res = await this.requestLabels(url, searchParams, getDefaultCacheHeaders(this.datasource.cacheLevel)); + if (Array.isArray(res)) { + this.labelKeys = res.slice().sort(); + return this.labelKeys.slice(); + } + + return []; + }; + + /** + * Fetches all values for a specific label key from Prometheus using labels values endpoint. + * + * @param {TimeRange} timeRange - Time range to use for the query + * @param {string} labelKey - The label key to fetch values for + * @param {string} match - Optional label matcher to filter results + * @param {string} limit - Maximum number of results to return + * @returns {Promise} Array of label values + */ + public queryLabelValues = async ( + timeRange: TimeRange, + labelKey: string, + match?: string, + limit: string = DEFAULT_SERIES_LIMIT + ): Promise => { + const timeParams = this.datasource.getAdjustedInterval(timeRange); + const searchParams = { limit, ...timeParams, ...(match ? { 'match[]': match } : {}) }; + const interpolatedName = this.datasource.interpolateString(labelKey); + const interpolatedAndEscapedName = escapeForUtf8Support(removeQuotesIfExist(interpolatedName)); + const url = `/api/v1/label/${interpolatedAndEscapedName}/values`; + const value = await this.requestLabels(url, searchParams, getDefaultCacheHeaders(this.datasource.cacheLevel)); + return value ?? []; + }; +} + +export class SeriesApiClient extends BaseResourceClient implements ResourceApiClient { + public histogramMetrics: string[] = []; + public metrics: string[] = []; + public labelKeys: string[] = []; + public cachedLabelValues: Record = {}; + + start = async (timeRange: TimeRange) => { + await this.queryMetrics(timeRange); + }; + + public queryMetrics = async (timeRange: TimeRange): Promise<{ metrics: string[]; histogramMetrics: string[] }> => { + const series = await this.querySeries(timeRange, MATCH_ALL_LABELS); + const { metrics, labelKeys } = processSeries(series); + this.metrics = metrics; + this.histogramMetrics = processHistogramMetrics(this.metrics); + this.labelKeys = labelKeys; + return { metrics: this.metrics, histogramMetrics: this.histogramMetrics }; + }; + + public queryLabelKeys = async ( + timeRange: TimeRange, + match?: string, + limit: string = DEFAULT_SERIES_LIMIT + ): Promise => { + const effectiveMatch = this.validateAndTransformMatcher(match); + const series = await this.querySeries(timeRange, effectiveMatch, limit); + const { labelKeys } = processSeries(series); + return labelKeys; + }; + + public queryLabelValues = async ( + timeRange: TimeRange, + labelKey: string, + match?: string, + limit: string = DEFAULT_SERIES_LIMIT + ): Promise => { + const effectiveMatch = !match || match === EMPTY_MATCHER ? `{${labelKey}!=""}` : match; + const series = await this.querySeries(timeRange, effectiveMatch, limit); + const { labelValues } = processSeries(series, labelKey); + return labelValues; + }; +} + +export function processSeries(series: Array<{ [key: string]: string }>, findValuesForKey?: string) { + const metrics: Set = new Set(); + const labelKeys: Set = new Set(); + const labelValues: Set = new Set(); + + // Extract metrics and label keys + series.forEach((item) => { + // Add the __name__ value to metrics + if (METRIC_LABEL in item) { + metrics.add(item.__name__); + } + + // Add all keys except __name__ to labelKeys + Object.keys(item).forEach((key) => { + if (key !== METRIC_LABEL) { + labelKeys.add(key); + } + if (findValuesForKey && key === findValuesForKey) { + labelValues.add(item[key]); + } + }); + }); + + return { + metrics: Array.from(metrics).sort(), + labelKeys: Array.from(labelKeys).sort(), + labelValues: Array.from(labelValues).sort(), + }; +}