Prometheus: Use new language provider methods in metrics browser and query field (#106163)

* refactor language provider

* update tests

* more tests

* betterer and api endpoints

* copilot updates

* betterer

* remove default value

* prettier

* introduce new methods

* provide unit tests for labelValues

* update metadata fetch

* move all cache related stuff in caching.ts

* provide interface

* provide deprecation messages

* unit tests for new interface

* separation of concerns

* update tests

* fix unit test

* fix some types

* Revert "fix some types"

This reverts commit 7e64b93b5f.

* revert interface usage

* betterer

* use PrometheusLanguageProviderInterface in everywhere

* introduce resource clients

* unit tests

* act accordingly with the feature toggle

* some more unit tests

* add feature toggle

* Revert "add feature toggle"

This reverts commit 5c93ac324f.

* remove feature toggle

* update tests

* backward compatibility

* fix scope issues

* comment update

* stronger types

* prettier

* betterer

* use new methods in metrics browser and query field

* always return data

* Revert "always return data"

This reverts commit 38e493c189.

* Revert "Revert "always return data""

This reverts commit b5d3b5d2b0.

* handle error

* lint

* introduce resource clients and better refactoring

* prettier

* type fixes

* betterer

* no empty matcher for series calls

* better matchers

* add additional tests

* proper match string for series

* introduce series cache

* introduce series cache for series label values

* lint

* cache values too

* utf8 safe label values query with series endpoint

* fix unit tests

* import fixes

* more import fixes

* fix unit tests
pull/105853/merge
ismail simsek 1 month ago committed by GitHub
parent df33310530
commit 4ba09df7dd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      .betterer.results
  2. 10
      packages/grafana-prometheus/src/caching.ts
  3. 2
      packages/grafana-prometheus/src/components/PromQueryEditorByApp.test.tsx
  4. 2
      packages/grafana-prometheus/src/components/PromQueryField.test.tsx
  5. 2
      packages/grafana-prometheus/src/components/PromQueryField.tsx
  6. 60
      packages/grafana-prometheus/src/components/metrics-browser/MetricsBrowserContext.test.tsx
  7. 828
      packages/grafana-prometheus/src/components/metrics-browser/useMetricsLabelsValues.test.ts
  8. 31
      packages/grafana-prometheus/src/components/metrics-browser/useMetricsLabelsValues.ts
  9. 2
      packages/grafana-prometheus/src/components/useMetricsState.test.ts
  10. 4
      packages/grafana-prometheus/src/components/useMetricsState.ts
  11. 4
      packages/grafana-prometheus/src/language_provider.mock.ts
  12. 205
      packages/grafana-prometheus/src/resource_clients.test.ts
  13. 154
      packages/grafana-prometheus/src/resource_clients.ts

@ -476,6 +476,12 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "0"], [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.", "1"]
], ],
"packages/grafana-prometheus/src/resource_clients.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/types.ts:5381": [ "packages/grafana-prometheus/src/types.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"], [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.", "1"],

@ -47,12 +47,12 @@ export const getDaysToCacheMetadata = (cacheLevel: PrometheusCacheLevel): number
* Used for general API response caching. * Used for general API response caching.
* *
* @param {PrometheusCacheLevel} cacheLevel - The cache level (None, Low, Medium, High) * @param {PrometheusCacheLevel} cacheLevel - The cache level (None, Low, Medium, High)
* @returns {number} Cache duration in minutes: * @returns Cache duration in minutes:
* - Medium: 10 minutes * - Medium: 10 minutes
* - High: 60 minutes * - High: 60 minutes
* - Default (None/Low): 1 minute * - Default (None/Low): 1 minute
*/ */
export function getCacheDurationInMinutes(cacheLevel: PrometheusCacheLevel) { export const getCacheDurationInMinutes = (cacheLevel: PrometheusCacheLevel) => {
switch (cacheLevel) { switch (cacheLevel) {
case PrometheusCacheLevel.Medium: case PrometheusCacheLevel.Medium:
return 10; return 10;
@ -61,14 +61,14 @@ export function getCacheDurationInMinutes(cacheLevel: PrometheusCacheLevel) {
default: default:
return 1; return 1;
} }
} };
/** /**
* Builds cache headers for Prometheus API requests. * Builds cache headers for Prometheus API requests.
* Creates a standard cache control header with private scope and max-age directive. * Creates a standard cache control header with private scope and max-age directive.
* *
* @param {number} durationInSeconds - Cache duration in seconds * @param {number} durationInSeconds - Cache duration in seconds
* @returns {object} Object containing headers with cache control directives: * @returns Object containing headers with cache control directives:
* - X-Grafana-Cache: private, max-age=<duration> * - X-Grafana-Cache: private, max-age=<duration>
* @example * @example
* // Returns { headers: { 'X-Grafana-Cache': 'private, max-age=300' } } * // Returns { headers: { 'X-Grafana-Cache': 'private, max-age=300' } }
@ -88,7 +88,7 @@ export const buildCacheHeaders = (durationInSeconds: number) => {
* Returns undefined if caching is disabled (None level). * Returns undefined if caching is disabled (None level).
* *
* @param {PrometheusCacheLevel} cacheLevel - Cache level (None, Low, Medium, High) * @param {PrometheusCacheLevel} cacheLevel - Cache level (None, Low, Medium, High)
* @returns {object|undefined} Cache headers object or undefined if caching is disabled * @returns Cache headers object or undefined if caching is disabled
* @example * @example
* // For Medium level, returns { headers: { 'X-Grafana-Cache': 'private, max-age=600' } } * // For Medium level, returns { headers: { 'X-Grafana-Cache': 'private, max-age=600' } }
* getDefaultCacheHeaders(PrometheusCacheLevel.Medium) * getDefaultCacheHeaders(PrometheusCacheLevel.Medium)

@ -30,7 +30,7 @@ function setup(app: CoreApp): { onRunQuery: jest.Mock } {
start: () => Promise.resolve([]), start: () => Promise.resolve([]),
syntax: () => {}, syntax: () => {},
getLabelKeys: () => [], getLabelKeys: () => [],
metrics: [], retrieveMetrics: () => [],
}, },
} as unknown as PrometheusDatasource; } as unknown as PrometheusDatasource;
const onRunQuery = jest.fn(); const onRunQuery = jest.fn();

@ -29,7 +29,7 @@ const defaultProps = {
start: () => Promise.resolve([]), start: () => Promise.resolve([]),
syntax: () => {}, syntax: () => {},
getLabelKeys: () => [], getLabelKeys: () => [],
metrics: [], retrieveMetrics: () => [],
}, },
} as unknown as PrometheusDatasource, } as unknown as PrometheusDatasource,
query: { query: {

@ -45,7 +45,7 @@ export const PromQueryField = (props: PromQueryFieldProps) => {
const [labelBrowserVisible, setLabelBrowserVisible] = useState(false); const [labelBrowserVisible, setLabelBrowserVisible] = useState(false);
const updateLanguage = useCallback(() => { const updateLanguage = useCallback(() => {
if (languageProvider.metrics) { if (languageProvider.retrieveMetrics()) {
setSyntaxLoaded(true); setSyntaxLoaded(true);
} }
}, [languageProvider]); }, [languageProvider]);

@ -32,20 +32,14 @@ Object.defineProperty(window, 'localStorage', { value: localStorageMock });
const setupLanguageProviderMock = () => { const setupLanguageProviderMock = () => {
const mockTimeRange = getMockTimeRange(); const mockTimeRange = getMockTimeRange();
const mockLanguageProvider = { const mockLanguageProvider = {
metrics: ['metric1', 'metric2', 'metric3'], retrieveMetrics: () => ['metric1', 'metric2', 'metric3'],
labelKeys: ['__name__', 'instance', 'job', 'service'], retrieveLabelKeys: () => ['__name__', 'instance', 'job', 'service'],
metricsMetadata: { retrieveMetricsMetadata: () => ({
metric1: { type: 'counter', help: 'Test metric 1' }, metric1: { type: 'counter', help: 'Test metric 1' },
metric2: { type: 'gauge', help: 'Test metric 2' }, metric2: { type: 'gauge', help: 'Test metric 2' },
},
fetchLabels: jest.fn().mockResolvedValue(['__name__', 'instance', 'job', 'service']),
fetchSeriesLabelsMatch: jest.fn().mockResolvedValue({
__name__: ['metric1', 'metric2'],
instance: ['instance1', 'instance2'],
job: ['job1', 'job2'],
service: ['service1', 'service2'],
}), }),
fetchSeriesValuesWithMatch: jest.fn().mockImplementation((_timeRange: TimeRange, label: string) => { queryLabelKeys: jest.fn().mockResolvedValue(['__name__', 'instance', 'job', 'service']),
queryLabelValues: jest.fn().mockImplementation((_timeRange: TimeRange, label: string) => {
if (label === 'job') { if (label === 'job') {
return Promise.resolve(['grafana', 'prometheus']); return Promise.resolve(['grafana', 'prometheus']);
} }
@ -57,10 +51,6 @@ const setupLanguageProviderMock = () => {
} }
return Promise.resolve([]); return Promise.resolve([]);
}), }),
fetchLabelsWithMatch: jest.fn().mockResolvedValue({
job: ['job1', 'job2'],
instance: ['instance1', 'instance2'],
}),
} as unknown as PrometheusLanguageProviderInterface; } as unknown as PrometheusLanguageProviderInterface;
return { mockTimeRange, mockLanguageProvider }; return { mockTimeRange, mockLanguageProvider };
@ -298,34 +288,54 @@ describe('MetricsBrowserContext', () => {
describe('selector operations', () => { describe('selector operations', () => {
it('should clear all selections', async () => { it('should clear all selections', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const { renderWithProvider } = setupTest(); const { renderWithProvider, mockLanguageProvider } = setupTest();
renderWithProvider(<TestComponent />); renderWithProvider(<TestComponent />);
// Wait for component to be ready // Wait for initial data load
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('metrics-count').textContent).toBe('3'); expect(screen.getByTestId('metrics-count').textContent).toBe('3');
}); });
// Make selections // Step 1: Select a metric
await user.click(screen.getByTestId('select-metric')); await user.click(screen.getByTestId('select-metric'));
await user.click(screen.getByTestId('select-label'));
await user.click(screen.getByTestId('select-label-value'));
// Verify selections
await waitFor(() => { await waitFor(() => {
expect(mockLanguageProvider.queryLabelKeys).toHaveBeenCalled();
expect(screen.getByTestId('selected-metric').textContent).toBe('metric1'); expect(screen.getByTestId('selected-metric').textContent).toBe('metric1');
});
// Step 2: Select a label
await user.click(screen.getByTestId('select-label'));
await waitFor(() => {
expect(mockLanguageProvider.queryLabelValues).toHaveBeenCalledWith(
expect.anything(),
'job',
expect.anything(),
expect.anything()
);
expect(screen.getByTestId('selected-label-keys').textContent).toBe('job'); expect(screen.getByTestId('selected-label-keys').textContent).toBe('job');
expect(screen.getByTestId('selector').textContent).not.toBe('{}');
}); });
// Clear all selections // Step 3: Select a label value
await user.click(screen.getByTestId('select-label-value'));
await waitFor(() => {
expect(screen.getByTestId('selector').textContent).toContain('job="grafana"');
});
// Step 4: Clear all selections
await user.click(screen.getByTestId('clear')); await user.click(screen.getByTestId('clear'));
// Verify all fields are cleared // Verify everything is cleared
await waitFor(() => { await waitFor(() => {
// Check that all selections are cleared
expect(screen.getByTestId('selected-metric').textContent).toBe(''); expect(screen.getByTestId('selected-metric').textContent).toBe('');
expect(screen.getByTestId('selected-label-keys').textContent).toBe(''); expect(screen.getByTestId('selected-label-keys').textContent).toBe('');
expect(screen.getByTestId('selector').textContent).toBe('{}'); expect(screen.getByTestId('selector').textContent).toBe('{}');
// Verify localStorage was cleared
const mockCalls = localStorageMock.setItem.mock.calls;
const lastCall = mockCalls[mockCalls.length - 1];
expect(lastCall[0]).toBe(LAST_USED_LABELS_KEY);
expect(JSON.parse(lastCall[1])).toEqual([]);
}); });
}); });

@ -41,7 +41,7 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P
} }
}, [timeRange]); }, [timeRange]);
//Handler for error processing - logs the error and updates UI state // Handler for error processing - logs the error and updates UI state
const handleError = useCallback((e: unknown, msg: string) => { const handleError = useCallback((e: unknown, msg: string) => {
if (e instanceof Error) { if (e instanceof Error) {
setErr(`${msg}: ${e.message}`); setErr(`${msg}: ${e.message}`);
@ -54,10 +54,10 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P
// Get metadata details for a metric if available // Get metadata details for a metric if available
const getMetricDetails = useCallback( const getMetricDetails = useCallback(
(metricName: string) => { (metricName: string) => {
const meta = languageProvider.metricsMetadata; const meta = languageProvider.retrieveMetricsMetadata();
return meta && meta[metricName] ? `(${meta[metricName].type}) ${meta[metricName].help}` : undefined; return meta && meta[metricName] ? `(${meta[metricName].type}) ${meta[metricName].help}` : undefined;
}, },
[languageProvider.metricsMetadata] [languageProvider]
); );
// Builds a safe selector string from metric name and label values // Builds a safe selector string from metric name and label values
@ -89,11 +89,10 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P
const fetchMetrics = useCallback( const fetchMetrics = useCallback(
async (safeSelector?: string) => { async (safeSelector?: string) => {
try { try {
const fetchedMetrics = await languageProvider.fetchSeriesValuesWithMatch( const fetchedMetrics = await languageProvider.queryLabelValues(
timeRangeRef.current, timeRangeRef.current,
METRIC_LABEL, METRIC_LABEL,
safeSelector, safeSelector,
'MetricsBrowser_M',
effectiveLimit effectiveLimit
); );
return fetchedMetrics.map((m) => ({ return fetchedMetrics.map((m) => ({
@ -113,13 +112,9 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P
const fetchLabelKeys = useCallback( const fetchLabelKeys = useCallback(
async (safeSelector?: string) => { async (safeSelector?: string) => {
try { try {
if (safeSelector) { return (
return Object.keys( (await languageProvider.queryLabelKeys(timeRangeRef.current, safeSelector || undefined, effectiveLimit)) ?? []
await languageProvider.fetchSeriesLabelsMatch(timeRangeRef.current, safeSelector, effectiveLimit) );
);
} else {
return (await languageProvider.fetchLabels(timeRangeRef.current, undefined, effectiveLimit)) || [];
}
} catch (e) { } catch (e) {
handleError(e, 'Error fetching labels'); handleError(e, 'Error fetching labels');
return []; return [];
@ -135,17 +130,18 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P
const newSelectedLabelValues: Record<string, string[]> = {}; const newSelectedLabelValues: Record<string, string[]> = {};
for (const lk of labelKeys) { for (const lk of labelKeys) {
try { try {
const values = await languageProvider.fetchSeriesValuesWithMatch( const values = await languageProvider.queryLabelValues(
timeRangeRef.current, timeRangeRef.current,
lk, lk,
safeSelector, safeSelector,
`MetricsBrowser_LV_${lk}`,
effectiveLimit effectiveLimit
); );
transformedLabelValues[lk] = values; transformedLabelValues[lk] = values;
if (selectedLabelValues[lk]) { if (selectedLabelValues[lk]) {
newSelectedLabelValues[lk] = [...selectedLabelValues[lk]]; newSelectedLabelValues[lk] = [...selectedLabelValues[lk]];
} }
setErr('');
} catch (e) { } catch (e) {
handleError(e, 'Error fetching label values'); handleError(e, 'Error fetching label values');
} }
@ -294,11 +290,10 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P
if (selectedLabelKeys.length !== 0) { if (selectedLabelKeys.length !== 0) {
for (const lk of selectedLabelKeys) { for (const lk of selectedLabelKeys) {
try { try {
const fetchedLabelValues = await languageProvider.fetchSeriesValuesWithMatch( const fetchedLabelValues = await languageProvider.queryLabelValues(
timeRange, timeRange,
lk, lk,
safeSelector, safeSelector,
`MetricsBrowser_LV_${lk}`,
effectiveLimit effectiveLimit
); );
@ -314,6 +309,8 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P
fetchedLabelValues.includes(item) fetchedLabelValues.includes(item)
); );
} }
setErr('');
} catch (e: unknown) { } catch (e: unknown) {
handleError(e, 'Error fetching label values'); handleError(e, 'Error fetching label values');
} }
@ -352,7 +349,7 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P
setErr(''); setErr('');
try { try {
const results = await languageProvider.fetchSeriesLabelsMatch(timeRangeRef.current, selector, effectiveLimit); const results = await languageProvider.queryLabelKeys(timeRangeRef.current, selector, effectiveLimit);
setValidationStatus(`Selector is valid (${Object.keys(results).length} labels found)`); setValidationStatus(`Selector is valid (${Object.keys(results).length} labels found)`);
} catch (e) { } catch (e) {
handleError(e, 'Validation failed'); handleError(e, 'Validation failed');

@ -8,7 +8,7 @@ import { useMetricsState } from './useMetricsState';
// Mock implementations // Mock implementations
const createMockLanguageProvider = (metrics: string[] = []): PrometheusLanguageProviderInterface => const createMockLanguageProvider = (metrics: string[] = []): PrometheusLanguageProviderInterface =>
({ ({
metrics, retrieveMetrics: () => metrics,
}) as unknown as PrometheusLanguageProviderInterface; }) as unknown as PrometheusLanguageProviderInterface;
const createMockDatasource = (lookupsDisabled = false): PrometheusDatasource => const createMockDatasource = (lookupsDisabled = false): PrometheusDatasource =>

@ -25,7 +25,7 @@ export function useMetricsState(
syntaxLoaded: boolean syntaxLoaded: boolean
) { ) {
return useMemo(() => { return useMemo(() => {
const hasMetrics = languageProvider.metrics.length > 0; const hasMetrics = languageProvider.retrieveMetrics().length > 0;
const chooserText = getChooserText(datasource.lookupsDisabled, syntaxLoaded, hasMetrics); const chooserText = getChooserText(datasource.lookupsDisabled, syntaxLoaded, hasMetrics);
const buttonDisabled = !(syntaxLoaded && hasMetrics); const buttonDisabled = !(syntaxLoaded && hasMetrics);
@ -34,5 +34,5 @@ export function useMetricsState(
chooserText, chooserText,
buttonDisabled, buttonDisabled,
}; };
}, [languageProvider.metrics, datasource.lookupsDisabled, syntaxLoaded]); }, [languageProvider, datasource.lookupsDisabled, syntaxLoaded]);
} }

@ -1,12 +1,15 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/language_provider.mock.ts // Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/language_provider.mock.ts
export class EmptyLanguageProviderMock { export class EmptyLanguageProviderMock {
metrics = []; metrics = [];
constructor() {} constructor() {}
start() { start() {
return new Promise((resolve) => { return new Promise((resolve) => {
resolve(''); resolve('');
}); });
} }
getLabelKeys = jest.fn().mockReturnValue([]); getLabelKeys = jest.fn().mockReturnValue([]);
getLabelValues = jest.fn().mockReturnValue([]); getLabelValues = jest.fn().mockReturnValue([]);
getSeries = jest.fn().mockReturnValue({ __name__: [] }); getSeries = jest.fn().mockReturnValue({ __name__: [] });
@ -17,4 +20,5 @@ export class EmptyLanguageProviderMock {
fetchLabelsWithMatch = jest.fn().mockReturnValue([]); fetchLabelsWithMatch = jest.fn().mockReturnValue([]);
fetchLabels = jest.fn(); fetchLabels = jest.fn();
loadMetricsMetadata = jest.fn(); loadMetricsMetadata = jest.fn();
retrieveMetrics = jest.fn().mockReturnValue(['metric']);
} }

@ -173,17 +173,18 @@ describe('SeriesApiClient', () => {
}); });
describe('queryLabelKeys', () => { describe('queryLabelKeys', () => {
it('should throw error if match parameter is not provided', async () => { it('should use MATCH_ALL_LABELS when no matcher is 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' }]); mockRequest.mockResolvedValueOnce([{ __name__: 'metric1', label1: 'value1', label2: 'value2' }]);
const result = await client.queryLabelKeys(mockTimeRange, '{job="grafana"}'); const result = await client.queryLabelKeys(mockTimeRange);
expect(mockRequest).toHaveBeenCalledWith(
'/api/v1/series',
expect.objectContaining({
'match[]': '{__name__!=""}',
}),
expect.any(Object)
);
expect(result).toEqual(['label1', 'label2']); expect(result).toEqual(['label1', 'label2']);
}); });
@ -201,6 +202,14 @@ describe('SeriesApiClient', () => {
); );
expect(result).toEqual(['label1', 'label2']); expect(result).toEqual(['label1', 'label2']);
}); });
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']);
});
}); });
describe('queryLabelValues', () => { describe('queryLabelValues', () => {
@ -215,7 +224,7 @@ describe('SeriesApiClient', () => {
expect(mockRequest).toHaveBeenCalledWith( expect(mockRequest).toHaveBeenCalledWith(
'/api/v1/series', '/api/v1/series',
expect.objectContaining({ expect.objectContaining({
'match[]': '{__name__="metric1"}', 'match[]': '{__name__="metric1",job!=""}',
}), }),
expect.any(Object) expect.any(Object)
); );
@ -249,6 +258,184 @@ describe('SeriesApiClient', () => {
expect.any(Object) expect.any(Object)
); );
}); });
it('should use cache for subsequent identical queries', async () => {
// Setup mock response for first call
mockRequest.mockResolvedValueOnce([
{ __name__: 'metric1', job: 'grafana' },
{ __name__: 'metric2', job: 'prometheus' },
]);
// First query - should hit the backend
const firstResult = await client.queryLabelValues(mockTimeRange, 'job', '{__name__="metric1"}');
expect(firstResult).toEqual(['grafana', 'prometheus']);
expect(mockRequest).toHaveBeenCalledTimes(1);
expect(mockRequest).toHaveBeenCalledWith(
'/api/v1/series',
expect.objectContaining({
'match[]': '{__name__="metric1",job!=""}',
}),
expect.any(Object)
);
// Reset mock to verify it's not called again
mockRequest.mockClear();
// Second query with same parameters - should use cache
const secondResult = await client.queryLabelValues(mockTimeRange, 'job', '{__name__="metric1"}');
expect(secondResult).toEqual(['grafana', 'prometheus']);
expect(mockRequest).not.toHaveBeenCalled();
});
});
describe('SeriesCache', () => {
let cache: any; // Using any to access private members for testing
beforeEach(() => {
jest.useFakeTimers();
cache = (client as any)._seriesCache;
});
afterEach(() => {
jest.useRealTimers();
});
describe('cache key generation', () => {
it('should generate different cache keys for keys and values', () => {
const keyKey = cache.getCacheKey(mockTimeRange, '{job="test"}', '1000', 'key');
const valueKey = cache.getCacheKey(mockTimeRange, '{job="test"}', '1000', 'value');
expect(keyKey).not.toEqual(valueKey);
});
it('should use cache level from constructor for time range snapping', () => {
const highLevelCache = new SeriesApiClient(mockRequest, {
cacheLevel: PrometheusCacheLevel.High,
getAdjustedInterval: mockGetAdjustedInterval,
getTimeRangeParams: mockGetTimeRangeParams,
} as unknown as PrometheusDatasource);
const lowLevelCache = new SeriesApiClient(mockRequest, {
cacheLevel: PrometheusCacheLevel.Low,
getAdjustedInterval: mockGetAdjustedInterval,
getTimeRangeParams: mockGetTimeRangeParams,
} as unknown as PrometheusDatasource);
const highKey = (highLevelCache as any)._seriesCache.getCacheKey(mockTimeRange, '{job="test"}', '1000', 'key');
const lowKey = (lowLevelCache as any)._seriesCache.getCacheKey(mockTimeRange, '{job="test"}', '1000', 'key');
expect(highKey).not.toEqual(lowKey);
});
});
describe('cache size management', () => {
beforeEach(() => {
// Start with a clean cache for each test
cache._cache = {};
cache._accessTimestamps = {};
});
it('should remove oldest entries when max entries limit is reached', () => {
// Override MAX_CACHE_ENTRIES for testing
Object.defineProperty(cache, 'MAX_CACHE_ENTRIES', { value: 5 });
// Add entries up to the limit
cache.setLabelKeys(mockTimeRange, 'match1', '1000', ['key1']);
jest.advanceTimersByTime(1000);
cache.setLabelKeys(mockTimeRange, 'match2', '1000', ['key2']);
jest.advanceTimersByTime(1000);
cache.setLabelKeys(mockTimeRange, 'match3', '1000', ['key3']);
jest.advanceTimersByTime(1000);
cache.setLabelKeys(mockTimeRange, 'match4', '1000', ['key4']);
jest.advanceTimersByTime(1000);
cache.setLabelKeys(mockTimeRange, 'match5', '1000', ['key5']);
// Access first entry to make it more recently used
cache.getLabelKeys(mockTimeRange, 'match1', '1000');
jest.advanceTimersByTime(1000);
// Add sixth entry - this should trigger cache cleaning
// and remove 20% (1 entry) of the oldest entries
cache.setLabelKeys(mockTimeRange, 'match6', '1000', ['key6']);
// Verify cache state - should have removed one entry (match2)
expect(Object.keys(cache._cache).length).toBe(5);
// Second entry should be removed (was least recently used)
expect(cache.getLabelKeys(mockTimeRange, 'match2', '1000')).toBeUndefined();
// First entry should exist (was accessed recently)
expect(cache.getLabelKeys(mockTimeRange, 'match1', '1000')).toEqual(['key1']);
// Third entry should exist
expect(cache.getLabelKeys(mockTimeRange, 'match3', '1000')).toEqual(['key3']);
// Fourth entry should exist
expect(cache.getLabelKeys(mockTimeRange, 'match4', '1000')).toEqual(['key4']);
// Fifth entry should exist
expect(cache.getLabelKeys(mockTimeRange, 'match5', '1000')).toEqual(['key5']);
// Sixth entry should exist (newest)
expect(cache.getLabelKeys(mockTimeRange, 'match6', '1000')).toEqual(['key6']);
});
it('should remove oldest entries when max size limit is reached', () => {
// Override MAX_CACHE_SIZE_BYTES for testing - set to small value to trigger cleanup
Object.defineProperty(cache, 'MAX_CACHE_SIZE_BYTES', { value: 10 }); // Very small size to force cleanup
// Create entries that will exceed the size limit
const largeArray = Array(5).fill('large_value');
// Add first large entry
cache.setLabelKeys(mockTimeRange, 'match1', '1000', largeArray);
// Verify initial size
expect(Object.keys(cache._cache).length).toBe(1);
expect(cache.getCacheSizeInBytes()).toBeGreaterThan(10);
// Add second large entry - should trigger size-based cleanup
cache.setLabelKeys(mockTimeRange, 'match2', '1000', largeArray);
// Verify cache state - should only have the newest entry
expect(Object.keys(cache._cache).length).toBe(1);
expect(cache.getLabelKeys(mockTimeRange, 'match1', '1000')).toBeUndefined();
expect(cache.getLabelKeys(mockTimeRange, 'match2', '1000')).toEqual(largeArray);
// Add third entry to verify the cleanup continues to work
cache.setLabelKeys(mockTimeRange, 'match3', '1000', largeArray);
expect(Object.keys(cache._cache).length).toBe(1);
expect(cache.getLabelKeys(mockTimeRange, 'match2', '1000')).toBeUndefined();
expect(cache.getLabelKeys(mockTimeRange, 'match3', '1000')).toEqual(largeArray);
});
it('should update access time when getting cached values', () => {
// Add an entry
cache.setLabelKeys(mockTimeRange, 'match1', '1000', ['key1']);
const cacheKey = cache.getCacheKey(mockTimeRange, 'match1', '1000', 'key');
const initialTimestamp = cache._accessTimestamps[cacheKey];
// Advance time
jest.advanceTimersByTime(1000);
// Access the entry
cache.getLabelKeys(mockTimeRange, 'match1', '1000');
const updatedTimestamp = cache._accessTimestamps[cacheKey];
// Verify timestamp was updated
expect(updatedTimestamp).toBeGreaterThan(initialTimestamp);
});
});
describe('label values caching', () => {
it('should cache and retrieve label values', () => {
const values = ['value1', 'value2'];
cache.setLabelValues(mockTimeRange, '{job="test"}', '1000', values);
const cachedValues = cache.getLabelValues(mockTimeRange, '{job="test"}', '1000');
expect(cachedValues).toEqual(values);
});
it('should return undefined for non-existent label values', () => {
const result = cache.getLabelValues(mockTimeRange, '{job="nonexistent"}', '1000');
expect(result).toBeUndefined();
});
});
}); });
}); });

@ -6,7 +6,8 @@ import { DEFAULT_SERIES_LIMIT } from './components/metrics-browser/types';
import { PrometheusDatasource } from './datasource'; import { PrometheusDatasource } from './datasource';
import { removeQuotesIfExist } from './language_provider'; import { removeQuotesIfExist } from './language_provider';
import { getRangeSnapInterval, processHistogramMetrics } from './language_utils'; import { getRangeSnapInterval, processHistogramMetrics } from './language_utils';
import { escapeForUtf8Support } from './utf8_support'; import { PrometheusCacheLevel } from './types';
import { escapeForUtf8Support, utf8Support } from './utf8_support';
type PrometheusSeriesResponse = Array<{ [key: string]: string }>; type PrometheusSeriesResponse = Array<{ [key: string]: string }>;
type PrometheusLabelsResponse = string[]; type PrometheusLabelsResponse = string[];
@ -60,27 +61,6 @@ export abstract class BaseResourceClient {
return Array.isArray(response) ? response : []; 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. * Fetches all time series that match a specific label matcher using **series** endpoint.
* *
@ -89,7 +69,7 @@ export abstract class BaseResourceClient {
* @param {string} limit - Maximum number of series to return * @param {string} limit - Maximum number of series to return
*/ */
public querySeries = async (timeRange: TimeRange, match: string, limit: string = DEFAULT_SERIES_LIMIT) => { public querySeries = async (timeRange: TimeRange, match: string, limit: string = DEFAULT_SERIES_LIMIT) => {
const effectiveMatch = this.validateAndTransformMatcher(match); const effectiveMatch = !match || match === EMPTY_MATCHER ? MATCH_ALL_LABELS : match;
const timeParams = this.datasource.getTimeRangeParams(timeRange); const timeParams = this.datasource.getTimeRangeParams(timeRange);
const searchParams = { ...timeParams, 'match[]': effectiveMatch, limit }; const searchParams = { ...timeParams, 'match[]': effectiveMatch, limit };
return await this.requestSeries('/api/v1/series', searchParams, getDefaultCacheHeaders(this.datasource.cacheLevel)); return await this.requestSeries('/api/v1/series', searchParams, getDefaultCacheHeaders(this.datasource.cacheLevel));
@ -166,6 +146,8 @@ export class LabelsApiClient extends BaseResourceClient implements ResourceApiCl
} }
export class SeriesApiClient extends BaseResourceClient implements ResourceApiClient { export class SeriesApiClient extends BaseResourceClient implements ResourceApiClient {
private _seriesCache: SeriesCache = new SeriesCache(this.datasource.cacheLevel);
public histogramMetrics: string[] = []; public histogramMetrics: string[] = [];
public metrics: string[] = []; public metrics: string[] = [];
public labelKeys: string[] = []; public labelKeys: string[] = [];
@ -176,11 +158,13 @@ export class SeriesApiClient extends BaseResourceClient implements ResourceApiCl
}; };
public queryMetrics = async (timeRange: TimeRange): Promise<{ metrics: string[]; histogramMetrics: string[] }> => { public queryMetrics = async (timeRange: TimeRange): Promise<{ metrics: string[]; histogramMetrics: string[] }> => {
const series = await this.querySeries(timeRange, MATCH_ALL_LABELS); const series = await this.querySeries(timeRange, MATCH_ALL_LABELS, DEFAULT_SERIES_LIMIT);
const { metrics, labelKeys } = processSeries(series); const { metrics, labelKeys } = processSeries(series, METRIC_LABEL);
this.metrics = metrics; this.metrics = metrics;
this.histogramMetrics = processHistogramMetrics(this.metrics); this.histogramMetrics = processHistogramMetrics(this.metrics);
this.labelKeys = labelKeys; this.labelKeys = labelKeys;
this._seriesCache.setLabelValues(timeRange, MATCH_ALL_LABELS, DEFAULT_SERIES_LIMIT, metrics);
this._seriesCache.setLabelKeys(timeRange, MATCH_ALL_LABELS, DEFAULT_SERIES_LIMIT, labelKeys);
return { metrics: this.metrics, histogramMetrics: this.histogramMetrics }; return { metrics: this.metrics, histogramMetrics: this.histogramMetrics };
}; };
@ -189,9 +173,15 @@ export class SeriesApiClient extends BaseResourceClient implements ResourceApiCl
match?: string, match?: string,
limit: string = DEFAULT_SERIES_LIMIT limit: string = DEFAULT_SERIES_LIMIT
): Promise<string[]> => { ): Promise<string[]> => {
const effectiveMatch = this.validateAndTransformMatcher(match); const effectiveMatch = !match || match === EMPTY_MATCHER ? MATCH_ALL_LABELS : match;
const maybeCachedKeys = this._seriesCache.getLabelKeys(timeRange, effectiveMatch, limit);
if (maybeCachedKeys) {
return maybeCachedKeys;
}
const series = await this.querySeries(timeRange, effectiveMatch, limit); const series = await this.querySeries(timeRange, effectiveMatch, limit);
const { labelKeys } = processSeries(series); const { labelKeys } = processSeries(series);
this._seriesCache.setLabelKeys(timeRange, effectiveMatch, limit, labelKeys);
return labelKeys; return labelKeys;
}; };
@ -201,13 +191,123 @@ export class SeriesApiClient extends BaseResourceClient implements ResourceApiCl
match?: string, match?: string,
limit: string = DEFAULT_SERIES_LIMIT limit: string = DEFAULT_SERIES_LIMIT
): Promise<string[]> => { ): Promise<string[]> => {
const effectiveMatch = !match || match === EMPTY_MATCHER ? `{${labelKey}!=""}` : match; const utf8SafeLabelKey = utf8Support(labelKey);
const effectiveMatch =
!match || match === EMPTY_MATCHER
? `{${utf8SafeLabelKey}!=""}`
: match.slice(0, match.length - 1).concat(`,${utf8SafeLabelKey}!=""}`);
const maybeCachedValues = this._seriesCache.getLabelValues(timeRange, effectiveMatch, limit);
if (maybeCachedValues) {
return maybeCachedValues;
}
const series = await this.querySeries(timeRange, effectiveMatch, limit); const series = await this.querySeries(timeRange, effectiveMatch, limit);
const { labelValues } = processSeries(series, labelKey); const { labelValues } = processSeries(series, labelKey);
this._seriesCache.setLabelValues(timeRange, effectiveMatch, limit, labelValues);
return labelValues; return labelValues;
}; };
} }
class SeriesCache {
private readonly MAX_CACHE_ENTRIES = 1000; // Maximum number of cache entries
private readonly MAX_CACHE_SIZE_BYTES = 50 * 1024 * 1024; // 50MB max cache size
private _cache: Record<string, string[]> = {};
private _accessTimestamps: Record<string, number> = {};
constructor(private cacheLevel: PrometheusCacheLevel = PrometheusCacheLevel.High) {}
public setLabelKeys(timeRange: TimeRange, match: string, limit: string, keys: string[]) {
// Check and potentially clean cache before adding new entry
this.cleanCacheIfNeeded();
const cacheKey = this.getCacheKey(timeRange, match, limit, 'key');
this._cache[cacheKey] = keys.slice().sort();
this._accessTimestamps[cacheKey] = Date.now();
}
public getLabelKeys(timeRange: TimeRange, match: string, limit: string): string[] | undefined {
const cacheKey = this.getCacheKey(timeRange, match, limit, 'key');
const result = this._cache[cacheKey];
if (result) {
// Update access timestamp on cache hit
this._accessTimestamps[cacheKey] = Date.now();
}
return result;
}
public setLabelValues(timeRange: TimeRange, match: string, limit: string, values: string[]) {
// Check and potentially clean cache before adding new entry
this.cleanCacheIfNeeded();
const cacheKey = this.getCacheKey(timeRange, match, limit, 'value');
this._cache[cacheKey] = values.slice().sort();
this._accessTimestamps[cacheKey] = Date.now();
}
public getLabelValues(timeRange: TimeRange, match: string, limit: string): string[] | undefined {
const cacheKey = this.getCacheKey(timeRange, match, limit, 'value');
const result = this._cache[cacheKey];
if (result) {
// Update access timestamp on cache hit
this._accessTimestamps[cacheKey] = Date.now();
}
return result;
}
private getCacheKey(timeRange: TimeRange, match: string, limit: string, type: 'key' | 'value') {
const snappedTimeRange = getRangeSnapInterval(this.cacheLevel, timeRange);
return [snappedTimeRange.start, snappedTimeRange.end, limit, match, type].join('|');
}
private cleanCacheIfNeeded() {
// Check number of entries
const currentEntries = Object.keys(this._cache).length;
if (currentEntries >= this.MAX_CACHE_ENTRIES) {
// Calculate 20% of current entries, but ensure we remove at least 1 entry
const entriesToRemove = Math.max(1, Math.floor(currentEntries - this.MAX_CACHE_ENTRIES + 1));
this.removeOldestEntries(entriesToRemove);
}
// Check cache size in bytes
const currentSize = this.getCacheSizeInBytes();
if (currentSize > this.MAX_CACHE_SIZE_BYTES) {
// Calculate 20% of current entries, but ensure we remove at least 1 entry
const entriesToRemove = Math.max(1, Math.floor(Object.keys(this._cache).length * 0.2));
this.removeOldestEntries(entriesToRemove);
}
}
private getCacheSizeInBytes(): number {
let size = 0;
for (const key in this._cache) {
// Calculate size of key
size += key.length * 2; // Approximate size of string in bytes (UTF-16)
// Calculate size of value array
const value = this._cache[key];
for (const item of value) {
size += item.length * 2; // Approximate size of each string in bytes
}
}
return size;
}
private removeOldestEntries(count: number) {
// Get all entries sorted by timestamp (oldest first)
const entries = Object.entries(this._accessTimestamps).sort(
([, timestamp1], [, timestamp2]) => timestamp1 - timestamp2
);
// Take the oldest 'count' entries
const entriesToRemove = entries.slice(0, count);
// Remove these entries from both cache and timestamps
for (const [key] of entriesToRemove) {
delete this._cache[key];
delete this._accessTimestamps[key];
}
}
}
export function processSeries(series: Array<{ [key: string]: string }>, findValuesForKey?: string) { export function processSeries(series: Array<{ [key: string]: string }>, findValuesForKey?: string) {
const metrics: Set<string> = new Set(); const metrics: Set<string> = new Set();
const labelKeys: Set<string> = new Set(); const labelKeys: Set<string> = new Set();

Loading…
Cancel
Save