From 31efa8383bfc6b798b46d8012a1c72b7ce7110a5 Mon Sep 17 00:00:00 2001 From: ismail simsek Date: Tue, 27 May 2025 14:05:17 +0200 Subject: [PATCH] introduce resource clients --- .../src/language_provider.test.ts | 134 ------------ .../src/language_provider.ts | 186 ++++------------- .../src/resource_clients.test.ts | 134 ++++++++++++ .../src/resource_clients.ts | 193 ++++++++++++++++++ 4 files changed, 367 insertions(+), 280 deletions(-) create mode 100644 packages/grafana-prometheus/src/resource_clients.test.ts create mode 100644 packages/grafana-prometheus/src/resource_clients.ts diff --git a/packages/grafana-prometheus/src/language_provider.test.ts b/packages/grafana-prometheus/src/language_provider.test.ts index a533b8ad871..c84e1869072 100644 --- a/packages/grafana-prometheus/src/language_provider.test.ts +++ b/packages/grafana-prometheus/src/language_provider.test.ts @@ -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([]); - }); -}); diff --git a/packages/grafana-prometheus/src/language_provider.ts b/packages/grafana-prometheus/src/language_provider.ts index 9c0e2654bfd..59a5ee3ef82 100644 --- a/packages/grafana-prometheus/src/language_provider.ts +++ b/packages/grafana-prometheus/src/language_provider.ts @@ -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 => { + 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} Array of label keys sorted alphabetically - */ - private _withLabelsApiFetchLabelKeys = async ( - timeRange: TimeRange, - match?: string, - limit: string = DEFAULT_SERIES_LIMIT - ): Promise => { - let url = API_V1.LABELS; - const timeParams = getRangeSnapInterval(this.datasource.cacheLevel, timeRange); - const searchParams = { limit, ...timeParams, ...(match ? { 'match[]': match } : {}) }; - - const res = await this.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} Array of label values - */ - private _withLabelsApiFetchLabelValues = async ( - timeRange: TimeRange, - labelKey: string, - match?: string, - limit: string = DEFAULT_SERIES_LIMIT - ): Promise => { - const timeParams = this.datasource.getAdjustedInterval(timeRange); - const searchParams = { limit, ...timeParams, ...(match ? { 'match[]': match } : {}) }; - const interpolatedName = this.datasource.interpolateString(labelKey); - const interpolatedAndEscapedName = escapeForUtf8Support(removeQuotesIfExist(interpolatedName)); - const url = API_V1.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 => { - 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 => { - 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 => { @@ -669,11 +601,7 @@ export class PrometheusLanguageProvider extends PromQlLanguageProvider implement }; public queryLabelKeys = async (timeRange: TimeRange, match?: string, limit?: string): Promise => { - 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 => { - 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 = new Set(); - const labelKeys: Set = new Set(); - const labelValues: Set = new Set(); - - // Extract metrics and label keys - series.forEach((item) => { - // Add the __name__ value to metrics - if ('__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. diff --git a/packages/grafana-prometheus/src/resource_clients.test.ts b/packages/grafana-prometheus/src/resource_clients.test.ts new file mode 100644 index 00000000000..7e7e2c36360 --- /dev/null +++ b/packages/grafana-prometheus/src/resource_clients.test.ts @@ -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([]); + }); +}); diff --git a/packages/grafana-prometheus/src/resource_clients.ts b/packages/grafana-prometheus/src/resource_clients.ts new file mode 100644 index 00000000000..b1e59a6b253 --- /dev/null +++ b/packages/grafana-prometheus/src/resource_clients.ts @@ -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; + + start: (timeRange: TimeRange) => Promise; + + queryMetrics: (timeRange: TimeRange) => Promise<{ metrics: string[]; histogramMetrics: string[] }>; + queryLabelKeys: (timeRange: TimeRange, match?: string, limit?: string) => Promise; + queryLabelValues: (timeRange: TimeRange, labelKey: string, match?: string, limit?: string) => Promise; +} + +const MATCH_ALL_LABELS = '{__name__!=""}'; +const METRIC_LABEL = '__name__'; + +abstract class BaseResourceClient { + constructor( + protected readonly request: (url: string, params?: any, options?: Partial) => Promise, + 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 = {}; + + 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} Array of label keys sorted alphabetically + */ + public queryLabelKeys = async ( + timeRange: TimeRange, + match?: string, + limit: string = DEFAULT_SERIES_LIMIT + ): Promise => { + let url = '/api/v1/labels'; + const timeParams = getRangeSnapInterval(this.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} Array of label values + */ + public queryLabelValues = async ( + timeRange: TimeRange, + labelKey: string, + match?: string, + limit: string = DEFAULT_SERIES_LIMIT + ): Promise => { + 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 = {}; + + 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 => { + 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 => { + 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 = new Set(); + const labelKeys: Set = new Set(); + const labelValues: Set = new Set(); + + // Extract metrics and label keys + series.forEach((item) => { + // Add the __name__ value to metrics + if (METRIC_LABEL in item) { + metrics.add(item.__name__); + } + + // Add all keys except __name__ to labelKeys + Object.keys(item).forEach((key) => { + if (key !== METRIC_LABEL) { + labelKeys.add(key); + } + if (findValuesForKey && key === findValuesForKey) { + labelValues.add(item[key]); + } + }); + }); + + return { + metrics: Array.from(metrics).sort(), + labelKeys: Array.from(labelKeys).sort(), + labelValues: Array.from(labelValues).sort(), + }; +}