introduce resource clients

This commit is contained in:
ismail simsek
2025-05-27 14:05:17 +02:00
parent 6b0536be00
commit 31efa8383b
4 changed files with 367 additions and 280 deletions
@@ -8,7 +8,6 @@ import { PrometheusDatasource } from './datasource';
import {
exportToAbstractQuery,
importFromAbstractQuery,
processSeries,
removeQuotesIfExist,
PrometheusLanguageProviderInterface,
PrometheusLanguageProvider,
@@ -948,136 +947,3 @@ describe('removeQuotesIfExist', () => {
expect(result).toBe('12345');
});
});
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([]);
});
});
@@ -23,13 +23,13 @@ import { PrometheusDatasource } from './datasource';
import {
extractLabelMatchers,
fixSummariesMetadata,
getRangeSnapInterval,
processHistogramMetrics,
processLabels,
toPromLikeQuery,
} from './language_utils';
import PromqlSyntax from './promql';
import { buildVisualQueryFromString } from './querybuilder/parsing';
import { LabelsApiClient, ResourceApiClient, SeriesApiClient } from './resource_clients';
import { PromMetricsMetadata, PromQuery } from './types';
import { escapeForUtf8Support, isValidLegacyName } from './utf8_support';
@@ -48,8 +48,6 @@ const API_V1 = {
LABELS_VALUES: (labelKey: string) => `/api/v1/label/${labelKey}/values`,
};
const MATCH_ALL_LABELS = '{__name__!=""}';
export interface PrometheusBaseLanguageProvider {
datasource: PrometheusDatasource;
@@ -526,9 +524,40 @@ export interface PrometheusLanguageProviderInterface
export class PrometheusLanguageProvider extends PromQlLanguageProvider implements PrometheusLanguageProviderInterface {
private _metricsMetadata?: PromMetricsMetadata;
private _histogramMetrics: string[] = [];
private _metrics: string[] = [];
private _labelKeys: string[] = [];
private _resourceClient: ResourceApiClient;
constructor(datasource: PrometheusDatasource) {
super(datasource);
this.datasource = datasource;
const { cacheLevel, getAdjustedInterval, getTimeRangeParams, interpolateString } = this.datasource;
if (this.datasource.hasLabelsMatchAPISupport()) {
this._resourceClient = new LabelsApiClient(
this.request,
cacheLevel,
getAdjustedInterval,
getTimeRangeParams,
interpolateString
);
} else {
this._resourceClient = new SeriesApiClient(
this.request,
cacheLevel,
getAdjustedInterval,
getTimeRangeParams,
interpolateString
);
}
}
start = async (timeRange: TimeRange = getDefaultTimeRange()): Promise<any[]> => {
if (this.datasource.lookupsDisabled) {
return [];
}
return Promise.all([this._resourceClient.start(timeRange), this._queryMetadata()]);
};
/**
* Fetches metadata for metrics from Prometheus.
@@ -550,117 +579,20 @@ export class PrometheusLanguageProvider extends PromQlLanguageProvider implement
return fixSummariesMetadata(metadata);
};
// ===================================
// Labels API
// ===================================
/**
* 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
*/
private _withLabelsApiFetchLabelKeys = 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.request(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
*/
private _withLabelsApiFetchLabelValues = 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.LABELS_VALUES(interpolatedAndEscapedName);
const value = await this.request(url, searchParams, getDefaultCacheHeaders(this.datasource.cacheLevel));
return value ?? [];
};
// ===================================
// Series API
// ===================================
/**
* 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
*/
private _withSeriesApiFetchAllSeries = async (
timeRange: TimeRange,
match: string,
limit: string = DEFAULT_SERIES_LIMIT
) => {
const timeParams = this.datasource.getTimeRangeParams(timeRange);
const searchParams = { ...timeParams, 'match[]': match, limit };
return await this.request(API_V1.SERIES, searchParams, getDefaultCacheHeaders(this.datasource.cacheLevel));
};
private _withSeriesApiFetchLabelKeys = async (
timeRange: TimeRange,
match: string,
limit: string = DEFAULT_SERIES_LIMIT
): Promise<string[]> => {
const series = await this._withSeriesApiFetchAllSeries(timeRange, match, limit);
const { labelKeys } = processSeries(series);
return labelKeys;
};
private _withSeriesApiFetchLabelValues = async (
timeRange: TimeRange,
labelKey: string,
match: string,
limit: string = DEFAULT_SERIES_LIMIT
): Promise<string[]> => {
const series = await this._withSeriesApiFetchAllSeries(timeRange, match, limit);
const { labelValues } = processSeries(series, labelKey);
return labelValues;
};
public retrieveMetricsMetadata = (): PromMetricsMetadata | undefined => {
return this._metricsMetadata;
};
public retrieveHistogramMetrics = (): string[] => {
return this._histogramMetrics;
return this._resourceClient?.histogramMetrics;
};
public retrieveMetrics = (): string[] => {
return this._metrics;
return this._resourceClient?.metrics;
};
public retrieveLabelKeys = (): string[] => {
return this._labelKeys;
return this._resourceClient?.labelKeys;
};
public queryMetricsMetadata = async (): Promise<PromMetricsMetadata> => {
@@ -669,11 +601,7 @@ export class PrometheusLanguageProvider extends PromQlLanguageProvider implement
};
public queryLabelKeys = async (timeRange: TimeRange, match?: string, limit?: string): Promise<string[]> => {
if (this.datasource.hasLabelsMatchAPISupport()) {
return await this._withLabelsApiFetchLabelKeys(timeRange, match, limit);
}
return await this._withSeriesApiFetchLabelKeys(timeRange, match ?? MATCH_ALL_LABELS, limit);
return await this._resourceClient.queryLabelKeys(timeRange, match, limit);
};
public queryLabelValues = async (
@@ -682,11 +610,7 @@ export class PrometheusLanguageProvider extends PromQlLanguageProvider implement
match?: string,
limit?: string
): Promise<string[]> => {
if (this.datasource.hasLabelsMatchAPISupport()) {
return await this._withLabelsApiFetchLabelValues(timeRange, labelKey, match, limit);
}
return await this._withSeriesApiFetchLabelValues(timeRange, labelKey, match ?? MATCH_ALL_LABELS, limit);
return await this._resourceClient.queryLabelValues(timeRange, labelKey, match, limit);
};
}
@@ -716,36 +640,6 @@ export const exportToAbstractQuery = (query: PromQuery): AbstractQuery => {
};
};
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 ('__name__' in item) {
metrics.add(item.__name__);
}
// Add all keys except __name__ to labelKeys
Object.keys(item).forEach((key) => {
if (key !== '__name__') {
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(),
};
}
/**
* Checks if an error is a cancelled request error.
* Used to avoid logging cancelled request errors.
@@ -0,0 +1,134 @@
import { processSeries } from './resource_clients';
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([]);
});
});
@@ -0,0 +1,193 @@
import { ScopedVars, TimeRange } from '@grafana/data';
import { BackendSrvRequest } from '@grafana/runtime';
import { getDefaultCacheHeaders } from './caching';
import { DEFAULT_SERIES_LIMIT } from './components/metrics-browser/types';
import { removeQuotesIfExist } from './language_provider';
import { getRangeSnapInterval, processHistogramMetrics } from './language_utils';
import { PrometheusCacheLevel } from './types';
import { escapeForUtf8Support } from './utf8_support';
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[]>;
}
const MATCH_ALL_LABELS = '{__name__!=""}';
const METRIC_LABEL = '__name__';
abstract class BaseResourceClient {
constructor(
protected readonly request: (url: string, params?: any, options?: Partial<BackendSrvRequest>) => Promise<any>,
protected readonly cacheLevel: PrometheusCacheLevel,
protected readonly getAdjustedInterval: (timeRange: TimeRange) => { start: string; end: string },
protected readonly getTimeRangeParams: (timeRange: TimeRange) => { start: string; end: string },
protected readonly interpolateString: (string: string, scopedVars?: ScopedVars) => string
) {}
}
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.queryLabelKeys(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.cacheLevel, timeRange);
const searchParams = { limit, ...timeParams, ...(match ? { 'match[]': match } : {}) };
const res = await this.request(url, searchParams, getDefaultCacheHeaders(this.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.getAdjustedInterval(timeRange);
const searchParams = { limit, ...timeParams, ...(match ? { 'match[]': match } : {}) };
const interpolatedName = this.interpolateString(labelKey);
const interpolatedAndEscapedName = escapeForUtf8Support(removeQuotesIfExist(interpolatedName));
const url = `/api/v1/label/${interpolatedAndEscapedName}/values`;
const value = await this.request(url, searchParams, getDefaultCacheHeaders(this.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);
};
/**
* 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
*/
private _fetchAllSeries = async (timeRange: TimeRange, match: string, limit: string = DEFAULT_SERIES_LIMIT) => {
const timeParams = this.getTimeRangeParams(timeRange);
const searchParams = { ...timeParams, 'match[]': match, limit };
return await this.request('/api/v1/series', searchParams, getDefaultCacheHeaders(this.cacheLevel));
};
public queryMetrics = async (timeRange: TimeRange): Promise<{ metrics: string[]; histogramMetrics: string[] }> => {
const series = await this._fetchAllSeries(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[]> => {
if (!match) {
throw new Error('Series endpoint always expects at least one matcher');
}
const series = await this._fetchAllSeries(timeRange, match, limit);
const { labelKeys } = processSeries(series);
return labelKeys;
};
public queryLabelValues = async (
timeRange: TimeRange,
labelKey: string,
match?: string,
limit: string = DEFAULT_SERIES_LIMIT
): Promise<string[]> => {
if (!match) {
throw new Error('Series endpoint always expects at least one matcher');
}
const series = await this._fetchAllSeries(timeRange, match, 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(),
};
}