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
This commit is contained in:
+10
-1
@@ -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[]>;
|
||||
|
||||
request: (url: string, params?: any, options?: Partial<BackendSrvRequest>) => Promise<any>;
|
||||
|
||||
fetchSuggestions: (
|
||||
timeRange?: TimeRange,
|
||||
queries?: PromQuery[],
|
||||
scopes?: Scope[],
|
||||
adhocFilters?: AdHocVariableFilter[],
|
||||
labelName?: string,
|
||||
limit?: number,
|
||||
requestId?: string
|
||||
) => Promise<string[]>;
|
||||
}
|
||||
|
||||
export function getMetadataHelp(metric: string, metadata: PromMetricsMetadata): string | undefined {
|
||||
if (!metadata[metric]) {
|
||||
return undefined;
|
||||
}
|
||||
return metadata[metric].help;
|
||||
/**
|
||||
* @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<{}>;
|
||||
}
|
||||
|
||||
export function getMetadataType(metric: string, metadata: PromMetricsMetadata): string | undefined {
|
||||
if (!metadata[metric]) {
|
||||
return undefined;
|
||||
}
|
||||
return metadata[metric].type;
|
||||
}
|
||||
/**
|
||||
* 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;
|
||||
|
||||
const PREFIX_DELIMITER_REGEX =
|
||||
/(="|!="|=~"|!~"|\{|\[|\(|\+|-|\/|\*|%|\^|\band\b|\bor\b|\bunless\b|==|>=|!=|<=|>|<|=|~|,)/;
|
||||
datasource: PrometheusDatasource;
|
||||
|
||||
const secondsInDay = 86400;
|
||||
export default class PromQlLanguageProvider extends LanguageProvider {
|
||||
histogramMetrics: string[];
|
||||
metrics: string[];
|
||||
metricsMetadata?: PromMetricsMetadata;
|
||||
declare startTask: Promise<any>;
|
||||
datasource: PrometheusDatasource;
|
||||
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,6 +511,263 @@ export default class PromQlLanguageProvider extends LanguageProvider {
|
||||
};
|
||||
}
|
||||
|
||||
export interface PrometheusLanguageProviderInterface
|
||||
extends PrometheusBaseLanguageProvider,
|
||||
PrometheusLegacyLanguageProvider {
|
||||
retrieveMetricsMetadata: () => PromMetricsMetadata;
|
||||
retrieveHistogramMetrics: () => string[];
|
||||
retrieveMetrics: () => string[];
|
||||
retrieveLabelKeys: () => string[];
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = '';
|
||||
|
||||
@@ -476,15 +780,28 @@ function getNameLabelValue(promQuery: string, tokens: Array<string | Prism.Token
|
||||
return nameLabelValue;
|
||||
}
|
||||
|
||||
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
|
||||
export function removeQuotesIfExist(input: string): string {
|
||||
const match = input.match(/^"(.*)"$/); // extract the content inside the quotes
|
||||
return match?.[1] ?? input;
|
||||
}
|
||||
/**
|
||||
* 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}`;
|
||||
}
|
||||
|
||||
+9
-1
@@ -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(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user