mirror of
https://github.com/grafana/grafana.git
synced 2025-08-01 18:26:52 +08:00
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 7e64b93b5faf253bbde62e9b9ce330a63d07aebd. * 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 5c93ac324f9bba37a0da26d59c7b1d6d63ab9fd3. * 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 38e493c189d627ee7fd1ef9b551059ea64d6eff0. * Revert "Revert "always return data"" This reverts commit b5d3b5d2b0e915a510a5cf044177e57277669ce2. * 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
This commit is contained in:
@ -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.", "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": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[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.
|
||||
*
|
||||
* @param {PrometheusCacheLevel} cacheLevel - The cache level (None, Low, Medium, High)
|
||||
* @returns {number} Cache duration in minutes:
|
||||
* @returns Cache duration in minutes:
|
||||
* - Medium: 10 minutes
|
||||
* - High: 60 minutes
|
||||
* - Default (None/Low): 1 minute
|
||||
*/
|
||||
export function getCacheDurationInMinutes(cacheLevel: PrometheusCacheLevel) {
|
||||
export const getCacheDurationInMinutes = (cacheLevel: PrometheusCacheLevel) => {
|
||||
switch (cacheLevel) {
|
||||
case PrometheusCacheLevel.Medium:
|
||||
return 10;
|
||||
@ -61,14 +61,14 @@ export function getCacheDurationInMinutes(cacheLevel: PrometheusCacheLevel) {
|
||||
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:
|
||||
* @returns Object containing headers with cache control directives:
|
||||
* - X-Grafana-Cache: private, max-age=<duration>
|
||||
* @example
|
||||
* // 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).
|
||||
*
|
||||
* @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
|
||||
* // For Medium level, returns { headers: { 'X-Grafana-Cache': 'private, max-age=600' } }
|
||||
* getDefaultCacheHeaders(PrometheusCacheLevel.Medium)
|
||||
|
@ -30,7 +30,7 @@ function setup(app: CoreApp): { onRunQuery: jest.Mock } {
|
||||
start: () => Promise.resolve([]),
|
||||
syntax: () => {},
|
||||
getLabelKeys: () => [],
|
||||
metrics: [],
|
||||
retrieveMetrics: () => [],
|
||||
},
|
||||
} as unknown as PrometheusDatasource;
|
||||
const onRunQuery = jest.fn();
|
||||
|
@ -29,7 +29,7 @@ const defaultProps = {
|
||||
start: () => Promise.resolve([]),
|
||||
syntax: () => {},
|
||||
getLabelKeys: () => [],
|
||||
metrics: [],
|
||||
retrieveMetrics: () => [],
|
||||
},
|
||||
} as unknown as PrometheusDatasource,
|
||||
query: {
|
||||
|
@ -45,7 +45,7 @@ export const PromQueryField = (props: PromQueryFieldProps) => {
|
||||
const [labelBrowserVisible, setLabelBrowserVisible] = useState(false);
|
||||
|
||||
const updateLanguage = useCallback(() => {
|
||||
if (languageProvider.metrics) {
|
||||
if (languageProvider.retrieveMetrics()) {
|
||||
setSyntaxLoaded(true);
|
||||
}
|
||||
}, [languageProvider]);
|
||||
|
@ -32,20 +32,14 @@ Object.defineProperty(window, 'localStorage', { value: localStorageMock });
|
||||
const setupLanguageProviderMock = () => {
|
||||
const mockTimeRange = getMockTimeRange();
|
||||
const mockLanguageProvider = {
|
||||
metrics: ['metric1', 'metric2', 'metric3'],
|
||||
labelKeys: ['__name__', 'instance', 'job', 'service'],
|
||||
metricsMetadata: {
|
||||
retrieveMetrics: () => ['metric1', 'metric2', 'metric3'],
|
||||
retrieveLabelKeys: () => ['__name__', 'instance', 'job', 'service'],
|
||||
retrieveMetricsMetadata: () => ({
|
||||
metric1: { type: 'counter', help: 'Test metric 1' },
|
||||
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') {
|
||||
return Promise.resolve(['grafana', 'prometheus']);
|
||||
}
|
||||
@ -57,10 +51,6 @@ const setupLanguageProviderMock = () => {
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
}),
|
||||
fetchLabelsWithMatch: jest.fn().mockResolvedValue({
|
||||
job: ['job1', 'job2'],
|
||||
instance: ['instance1', 'instance2'],
|
||||
}),
|
||||
} as unknown as PrometheusLanguageProviderInterface;
|
||||
|
||||
return { mockTimeRange, mockLanguageProvider };
|
||||
@ -298,34 +288,54 @@ describe('MetricsBrowserContext', () => {
|
||||
describe('selector operations', () => {
|
||||
it('should clear all selections', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { renderWithProvider } = setupTest();
|
||||
const { renderWithProvider, mockLanguageProvider } = setupTest();
|
||||
renderWithProvider(<TestComponent />);
|
||||
|
||||
// Wait for component to be ready
|
||||
// Wait for initial data load
|
||||
await waitFor(() => {
|
||||
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-label'));
|
||||
await user.click(screen.getByTestId('select-label-value'));
|
||||
|
||||
// Verify selections
|
||||
await waitFor(() => {
|
||||
expect(mockLanguageProvider.queryLabelKeys).toHaveBeenCalled();
|
||||
expect(screen.getByTestId('selected-metric').textContent).toBe('metric1');
|
||||
expect(screen.getByTestId('selected-label-keys').textContent).toBe('job');
|
||||
expect(screen.getByTestId('selector').textContent).not.toBe('{}');
|
||||
});
|
||||
|
||||
// Clear all selections
|
||||
// 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');
|
||||
});
|
||||
|
||||
// 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'));
|
||||
|
||||
// Verify all fields are cleared
|
||||
// Verify everything is cleared
|
||||
await waitFor(() => {
|
||||
// Check that all selections are cleared
|
||||
expect(screen.getByTestId('selected-metric').textContent).toBe('');
|
||||
expect(screen.getByTestId('selected-label-keys').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([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -41,7 +41,7 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P
|
||||
}
|
||||
}, [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) => {
|
||||
if (e instanceof Error) {
|
||||
setErr(`${msg}: ${e.message}`);
|
||||
@ -54,10 +54,10 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P
|
||||
// Get metadata details for a metric if available
|
||||
const getMetricDetails = useCallback(
|
||||
(metricName: string) => {
|
||||
const meta = languageProvider.metricsMetadata;
|
||||
const meta = languageProvider.retrieveMetricsMetadata();
|
||||
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
|
||||
@ -89,11 +89,10 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P
|
||||
const fetchMetrics = useCallback(
|
||||
async (safeSelector?: string) => {
|
||||
try {
|
||||
const fetchedMetrics = await languageProvider.fetchSeriesValuesWithMatch(
|
||||
const fetchedMetrics = await languageProvider.queryLabelValues(
|
||||
timeRangeRef.current,
|
||||
METRIC_LABEL,
|
||||
safeSelector,
|
||||
'MetricsBrowser_M',
|
||||
effectiveLimit
|
||||
);
|
||||
return fetchedMetrics.map((m) => ({
|
||||
@ -113,13 +112,9 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P
|
||||
const fetchLabelKeys = useCallback(
|
||||
async (safeSelector?: string) => {
|
||||
try {
|
||||
if (safeSelector) {
|
||||
return Object.keys(
|
||||
await languageProvider.fetchSeriesLabelsMatch(timeRangeRef.current, safeSelector, effectiveLimit)
|
||||
);
|
||||
} else {
|
||||
return (await languageProvider.fetchLabels(timeRangeRef.current, undefined, effectiveLimit)) || [];
|
||||
}
|
||||
return (
|
||||
(await languageProvider.queryLabelKeys(timeRangeRef.current, safeSelector || undefined, effectiveLimit)) ?? []
|
||||
);
|
||||
} catch (e) {
|
||||
handleError(e, 'Error fetching labels');
|
||||
return [];
|
||||
@ -135,17 +130,18 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P
|
||||
const newSelectedLabelValues: Record<string, string[]> = {};
|
||||
for (const lk of labelKeys) {
|
||||
try {
|
||||
const values = await languageProvider.fetchSeriesValuesWithMatch(
|
||||
const values = await languageProvider.queryLabelValues(
|
||||
timeRangeRef.current,
|
||||
lk,
|
||||
safeSelector,
|
||||
`MetricsBrowser_LV_${lk}`,
|
||||
effectiveLimit
|
||||
);
|
||||
transformedLabelValues[lk] = values;
|
||||
if (selectedLabelValues[lk]) {
|
||||
newSelectedLabelValues[lk] = [...selectedLabelValues[lk]];
|
||||
}
|
||||
|
||||
setErr('');
|
||||
} catch (e) {
|
||||
handleError(e, 'Error fetching label values');
|
||||
}
|
||||
@ -294,11 +290,10 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P
|
||||
if (selectedLabelKeys.length !== 0) {
|
||||
for (const lk of selectedLabelKeys) {
|
||||
try {
|
||||
const fetchedLabelValues = await languageProvider.fetchSeriesValuesWithMatch(
|
||||
const fetchedLabelValues = await languageProvider.queryLabelValues(
|
||||
timeRange,
|
||||
lk,
|
||||
safeSelector,
|
||||
`MetricsBrowser_LV_${lk}`,
|
||||
effectiveLimit
|
||||
);
|
||||
|
||||
@ -314,6 +309,8 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P
|
||||
fetchedLabelValues.includes(item)
|
||||
);
|
||||
}
|
||||
|
||||
setErr('');
|
||||
} catch (e: unknown) {
|
||||
handleError(e, 'Error fetching label values');
|
||||
}
|
||||
@ -352,7 +349,7 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P
|
||||
setErr('');
|
||||
|
||||
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)`);
|
||||
} catch (e) {
|
||||
handleError(e, 'Validation failed');
|
||||
|
@ -8,7 +8,7 @@ import { useMetricsState } from './useMetricsState';
|
||||
// Mock implementations
|
||||
const createMockLanguageProvider = (metrics: string[] = []): PrometheusLanguageProviderInterface =>
|
||||
({
|
||||
metrics,
|
||||
retrieveMetrics: () => metrics,
|
||||
}) as unknown as PrometheusLanguageProviderInterface;
|
||||
|
||||
const createMockDatasource = (lookupsDisabled = false): PrometheusDatasource =>
|
||||
|
@ -25,7 +25,7 @@ export function useMetricsState(
|
||||
syntaxLoaded: boolean
|
||||
) {
|
||||
return useMemo(() => {
|
||||
const hasMetrics = languageProvider.metrics.length > 0;
|
||||
const hasMetrics = languageProvider.retrieveMetrics().length > 0;
|
||||
const chooserText = getChooserText(datasource.lookupsDisabled, syntaxLoaded, hasMetrics);
|
||||
const buttonDisabled = !(syntaxLoaded && hasMetrics);
|
||||
|
||||
@ -34,5 +34,5 @@ export function useMetricsState(
|
||||
chooserText,
|
||||
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
|
||||
export class EmptyLanguageProviderMock {
|
||||
metrics = [];
|
||||
|
||||
constructor() {}
|
||||
|
||||
start() {
|
||||
return new Promise((resolve) => {
|
||||
resolve('');
|
||||
});
|
||||
}
|
||||
|
||||
getLabelKeys = jest.fn().mockReturnValue([]);
|
||||
getLabelValues = jest.fn().mockReturnValue([]);
|
||||
getSeries = jest.fn().mockReturnValue({ __name__: [] });
|
||||
@ -17,4 +20,5 @@ export class EmptyLanguageProviderMock {
|
||||
fetchLabelsWithMatch = jest.fn().mockReturnValue([]);
|
||||
fetchLabels = jest.fn();
|
||||
loadMetricsMetadata = jest.fn();
|
||||
retrieveMetrics = jest.fn().mockReturnValue(['metric']);
|
||||
}
|
||||
|
@ -173,17 +173,18 @@ describe('SeriesApiClient', () => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
it('should use MATCH_ALL_LABELS when no matcher is provided', async () => {
|
||||
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']);
|
||||
});
|
||||
|
||||
@ -201,6 +202,14 @@ describe('SeriesApiClient', () => {
|
||||
);
|
||||
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', () => {
|
||||
@ -215,7 +224,7 @@ describe('SeriesApiClient', () => {
|
||||
expect(mockRequest).toHaveBeenCalledWith(
|
||||
'/api/v1/series',
|
||||
expect.objectContaining({
|
||||
'match[]': '{__name__="metric1"}',
|
||||
'match[]': '{__name__="metric1",job!=""}',
|
||||
}),
|
||||
expect.any(Object)
|
||||
);
|
||||
@ -249,6 +258,184 @@ describe('SeriesApiClient', () => {
|
||||
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 { removeQuotesIfExist } from './language_provider';
|
||||
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 PrometheusLabelsResponse = string[];
|
||||
@ -60,27 +61,6 @@ export abstract class BaseResourceClient {
|
||||
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.
|
||||
*
|
||||
@ -89,7 +69,7 @@ export abstract class BaseResourceClient {
|
||||
* @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 effectiveMatch = !match || match === EMPTY_MATCHER ? MATCH_ALL_LABELS : 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));
|
||||
@ -166,6 +146,8 @@ export class LabelsApiClient extends BaseResourceClient implements ResourceApiCl
|
||||
}
|
||||
|
||||
export class SeriesApiClient extends BaseResourceClient implements ResourceApiClient {
|
||||
private _seriesCache: SeriesCache = new SeriesCache(this.datasource.cacheLevel);
|
||||
|
||||
public histogramMetrics: string[] = [];
|
||||
public metrics: 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[] }> => {
|
||||
const series = await this.querySeries(timeRange, MATCH_ALL_LABELS);
|
||||
const { metrics, labelKeys } = processSeries(series);
|
||||
const series = await this.querySeries(timeRange, MATCH_ALL_LABELS, DEFAULT_SERIES_LIMIT);
|
||||
const { metrics, labelKeys } = processSeries(series, METRIC_LABEL);
|
||||
this.metrics = metrics;
|
||||
this.histogramMetrics = processHistogramMetrics(this.metrics);
|
||||
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 };
|
||||
};
|
||||
|
||||
@ -189,9 +173,15 @@ export class SeriesApiClient extends BaseResourceClient implements ResourceApiCl
|
||||
match?: string,
|
||||
limit: string = DEFAULT_SERIES_LIMIT
|
||||
): 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 { labelKeys } = processSeries(series);
|
||||
this._seriesCache.setLabelKeys(timeRange, effectiveMatch, limit, labelKeys);
|
||||
return labelKeys;
|
||||
};
|
||||
|
||||
@ -201,13 +191,123 @@ export class SeriesApiClient extends BaseResourceClient implements ResourceApiCl
|
||||
match?: string,
|
||||
limit: string = DEFAULT_SERIES_LIMIT
|
||||
): 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 { labelValues } = processSeries(series, labelKey);
|
||||
this._seriesCache.setLabelValues(timeRange, effectiveMatch, limit, 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) {
|
||||
const metrics: Set<string> = new Set();
|
||||
const labelKeys: Set<string> = new Set();
|
||||
|
Reference in New Issue
Block a user