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
pull/106920/head
ismail simsek 1 month ago committed by GitHub
parent e3cbe54b45
commit c7e338342a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 11
      .betterer.results
  2. 149
      packages/grafana-prometheus/src/caching.test.ts
  3. 101
      packages/grafana-prometheus/src/caching.ts
  4. 1
      packages/grafana-prometheus/src/components/VariableQueryEditor.test.tsx
  5. 8
      packages/grafana-prometheus/src/datasource.ts
  6. 866
      packages/grafana-prometheus/src/language_provider.test.ts
  7. 557
      packages/grafana-prometheus/src/language_provider.ts
  8. 10
      packages/grafana-prometheus/src/querybuilder/components/MetricsLabelsSection.tsx
  9. 10
      packages/grafana-prometheus/src/querybuilder/components/metrics-modal/state/helpers.ts
  10. 474
      packages/grafana-prometheus/src/resource_clients.test.ts
  11. 239
      packages/grafana-prometheus/src/resource_clients.ts

@ -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"]

@ -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
},
});
});
});
});

@ -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=<duration>
* @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;
};

@ -138,7 +138,6 @@ describe('PromVariableQueryEditor', () => {
hasLabelsMatchAPISupport: () => true,
languageProvider: {
start: () => Promise.resolve([]),
syntax: () => {},
getLabelKeys: () => [],
metrics: [],
metricsMetadata: {},

@ -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<PromQuery[]> {
return abstractQueries.map((abstractQuery) => this.languageProvider.importFromAbstractQuery(abstractQuery));
return abstractQueries.map((abstractQuery) => importFromAbstractQuery(abstractQuery));
}
async exportToAbstractQueries(queries: PromQuery[]): Promise<AbstractQuery[]> {
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

File diff suppressed because it is too large Load Diff

@ -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<any[]>;
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<BackendSrvRequest>) => Promise<any>;
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<string[]>;
}
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<Record<string, string[]>>;
/**
* @deprecated Use queryLabelValues() method insteadIt'll determine the right endpoint based on the datasource settings
*/
fetchLabelValues: (range: TimeRange, key: string, limit?: string) => Promise<string[]>;
/**
* @deprecated Use queryLabelValues() method insteadIt'll determine the right endpoint based on the datasource settings
*/
getLabelValues: (range: TimeRange, key: string) => Promise<string[]>;
/**
* @deprecated If you need labelKeys or labelValues please use queryLabelKeys() or queryLabelValues() functions
*/
fetchLabels: (timeRange: TimeRange, queries?: PromQuery[], limit?: string) => Promise<string[]>;
/**
* @deprecated Use queryLabelValues() method insteadIt'll determine the right endpoint based on the datasource settings
*/
getSeriesValues: (timeRange: TimeRange, labelName: string, selector: string) => Promise<string[]>;
/**
* @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<string[]>;
/**
* @deprecated Use queryLabelKeys() method instead. It'll determine the right endpoint based on the datasource settings
*/
getSeriesLabels: (timeRange: TimeRange, selector: string, otherLabels: Label[]) => Promise<string[]>;
/**
* @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<Record<string, string[]>>;
/**
* @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<Record<string, string[]>>;
/**
* @deprecated Use queryLabelKeys() method instead. It'll determine the right endpoint based on the datasource settings
*/
fetchSeriesLabelsMatch: (timeRange: TimeRange, name: string, withLimit?: string) => Promise<Record<string, string[]>>;
/**
* @deprecated If you need labelKeys or labelValues please use queryLabelKeys() or queryLabelValues() functions
*/
fetchSeries: (timeRange: TimeRange, match: string) => Promise<Array<Record<string, string>>>;
/**
* @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<any>;
declare labelFetchTs: number;
datasource: PrometheusDatasource;
histogramMetrics: string[];
metrics: string[];
metricsMetadata?: PromMetricsMetadata;
labelKeys: string[] = [];
declare labelFetchTs: number;
constructor(datasource: PrometheusDatasource, initialValues?: Partial<PromQlLanguageProvider>) {
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<BackendSrvRequest>) => {
request = async (url: string, params = {}, options?: Partial<BackendSrvRequest>) => {
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<any[]> => {
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<Record<string, string[]>> {
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<string[]> => {
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<BackendSrvRequest> | 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<Record<string, string[]>> => {
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<string , []>
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<Array<Record<string, string>>> => {
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 | Prism.Token>): string {
let nameLabelValue = '';
export interface PrometheusLanguageProviderInterface
extends PrometheusBaseLanguageProvider,
PrometheusLegacyLanguageProvider {
retrieveMetricsMetadata: () => PromMetricsMetadata;
retrieveHistogramMetrics: () => string[];
retrieveMetrics: () => string[];
retrieveLabelKeys: () => string[];
for (const token of tokens) {
if (typeof token === 'string') {
nameLabelValue = token;
break;
queryMetricsMetadata: () => Promise<PromMetricsMetadata>;
queryLabelKeys: (timeRange: TimeRange, match?: string, limit?: string) => Promise<string[]>;
queryLabelValues: (timeRange: TimeRange, labelKey: string, match?: string, limit?: string) => Promise<string[]>;
}
/**
* 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);
}
return nameLabelValue;
/**
* 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;
}
/**
* 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<any[]> => {
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<PromMetricsMetadata>} 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<PromMetricsMetadata>} Promise that resolves to the fetched metadata
*/
public queryMetricsMetadata = async (): Promise<PromMetricsMetadata> => {
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<string[]>} Array of matching label key names, sorted alphabetically
*/
public queryLabelKeys = async (timeRange: TimeRange, match?: string, limit?: string): Promise<string[]> => {
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<string[]>} 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<string[]> => {
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 | Prism.Token>): 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);
};

@ -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}`;
}

@ -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
* */

@ -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([]);
});
});
});

@ -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<string, string[]>;
start: (timeRange: TimeRange) => Promise<void>;
queryMetrics: (timeRange: TimeRange) => Promise<{ metrics: string[]; histogramMetrics: string[] }>;
queryLabelKeys: (timeRange: TimeRange, match?: string, limit?: string) => Promise<string[]>;
queryLabelValues: (timeRange: TimeRange, labelKey: string, match?: string, limit?: string) => Promise<string[]>;
querySeries: (timeRange: TimeRange, match: string, limit?: string) => Promise<PrometheusSeriesResponse>;
}
type RequestFn = (
url: string,
params?: Record<string, unknown>,
options?: Partial<BackendSrvRequest>
) => Promise<unknown>;
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<string, unknown>,
options?: Partial<BackendSrvRequest>
): Promise<PrometheusLabelsResponse> {
const response = await this.request(url, params, options);
return Array.isArray(response) ? response : [];
}
protected async requestSeries(
url: string,
params?: Record<string, unknown>,
options?: Partial<BackendSrvRequest>
): Promise<PrometheusSeriesResponse> {
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<string, string[]> = {};
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<string[]>} Array of label keys sorted alphabetically
*/
public queryLabelKeys = async (
timeRange: TimeRange,
match?: string,
limit: string = DEFAULT_SERIES_LIMIT
): Promise<string[]> => {
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<string[]>} Array of label values
*/
public queryLabelValues = async (
timeRange: TimeRange,
labelKey: string,
match?: string,
limit: string = DEFAULT_SERIES_LIMIT
): Promise<string[]> => {
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<string, string[]> = {};
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<string[]> => {
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<string[]> => {
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<string> = new Set();
const labelKeys: Set<string> = new Set();
const labelValues: Set<string> = 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(),
};
}
Loading…
Cancel
Save