Prometheus: Move to new annotation support as the old one is deprecated (#104578)

* remove deprecated annotation support and introduce the new one

* create annotations.test.ts

* convert the old annotations to new format

* don't override query if it has necessary fields

* a better implementation

* remove comment

* fix

* fix react errors

* unit tests for annotation query editor

* two more tests
pull/105106/head^2
ismail simsek 2 months ago committed by GitHub
parent 7042e73343
commit 9156149960
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 723
      packages/grafana-prometheus/src/annotations.test.ts
  2. 133
      packages/grafana-prometheus/src/annotations.ts
  3. 165
      packages/grafana-prometheus/src/components/AnnotationQueryEditor.test.tsx
  4. 120
      packages/grafana-prometheus/src/components/AnnotationQueryEditor.tsx
  5. 271
      packages/grafana-prometheus/src/datasource.test.ts
  6. 155
      packages/grafana-prometheus/src/datasource.ts

@ -0,0 +1,723 @@
import { Observable, of } from 'rxjs';
import { AnnotationEvent, AnnotationQuery, DataFrame, Field, FieldType, renderLegendFormat } from '@grafana/data';
import { PrometheusAnnotationSupport } from './annotations';
import { PrometheusDatasource } from './datasource';
import { PromQuery } from './types';
// Mock dependencies
jest.mock('@grafana/data', () => {
const original = jest.requireActual('@grafana/data');
return {
...original,
rangeUtil: {
...original.rangeUtil,
intervalToSeconds: jest.fn().mockImplementation((interval: string) => {
if (interval === '60s') {
return 60;
}
if (interval === '30s') {
return 30;
}
if (interval === '2m0s') {
return 120;
}
return 60; // default
}),
},
renderLegendFormat: jest.fn().mockImplementation((format: string, labels: Record<string, string>) => {
if (!format) {
return '';
}
return format.replace(/\{\{(\w+)\}\}/g, (_: string, key: string) => labels[key] || '');
}),
};
});
describe('PrometheusAnnotationSupport', () => {
// Create mock datasource
const mockDatasource = {} as PrometheusDatasource;
const annotationSupport = PrometheusAnnotationSupport(mockDatasource);
// Mock the implementation to match our testing expectations
beforeEach(() => {
// Reset and setup mocks before each test
jest.clearAllMocks();
jest.restoreAllMocks();
});
describe('prepareAnnotation', () => {
it('should respect existing target values and not override them', () => {
const annotation: AnnotationQuery<PromQuery> & { expr?: string; step?: string } = {
expr: 'rate(prometheus_http_requests_total[5m])',
step: '10s',
refId: 'testRefId',
target: {
expr: 'original_expr',
refId: 'originalRefId',
legendFormat: 'test',
interval: 'original_interval',
},
datasource: { uid: 'prometheus' },
enable: true,
name: 'Prometheus Annotation',
iconColor: 'red',
};
const result = annotationSupport.prepareAnnotation!(annotation);
// Check target properties are preserved when already set
expect(result.target?.refId).toBe('originalRefId');
expect(result.target?.expr).toBe('original_expr');
expect(result.target?.interval).toBe('original_interval');
expect(result.target?.legendFormat).toBe('test');
// Check the original properties are removed
expect(result.expr).toBeUndefined();
expect(result.step).toBeUndefined();
});
it('should transfer properties from json to target when target values are not set', () => {
const annotation: AnnotationQuery<PromQuery> & { expr?: string; step?: string } = {
expr: 'rate(prometheus_http_requests_total[5m])',
step: '10s',
refId: 'testRefId',
target: {
expr: '', // Empty string - should be overridden
refId: '', // Empty string - should be overridden
legendFormat: 'test',
// interval not set
},
datasource: { uid: 'prometheus' },
enable: true,
name: 'Prometheus Annotation',
iconColor: 'red',
};
const result = annotationSupport.prepareAnnotation!(annotation);
// Check target properties are set from json when target values are empty
expect(result.target?.refId).toBe('testRefId');
expect(result.target?.expr).toBe('rate(prometheus_http_requests_total[5m])');
expect(result.target?.interval).toBe('10s');
expect(result.target?.legendFormat).toBe('test');
// Check the original properties are removed
expect(result.expr).toBeUndefined();
expect(result.step).toBeUndefined();
});
it('should use default refId if not provided in either target or json', () => {
const annotation: AnnotationQuery<PromQuery> & { expr?: string; step?: string } = {
expr: 'up',
step: '30s',
target: {
expr: '',
refId: '',
},
datasource: { uid: 'prometheus' },
enable: true,
name: 'Prometheus Annotation',
iconColor: 'red',
};
const result = annotationSupport.prepareAnnotation!(annotation);
expect(result.target?.refId).toBe('Anno');
expect(result.target?.expr).toBe('up');
expect(result.target?.interval).toBe('30s');
});
it('should handle undefined target', () => {
const annotation: AnnotationQuery<PromQuery> & { expr?: string; step?: string } = {
expr: 'up',
step: '30s',
datasource: { uid: 'prometheus' },
enable: true,
name: 'Prometheus Annotation',
iconColor: 'red',
};
const result = annotationSupport.prepareAnnotation!(annotation);
expect(result.target?.refId).toBe('Anno');
expect(result.target?.expr).toBe('up');
expect(result.target?.interval).toBe('30s');
});
it('should handle undefined expr and step', () => {
const annotation: AnnotationQuery<PromQuery> = {
target: {
expr: '',
refId: '',
},
datasource: { uid: 'prometheus' },
enable: true,
name: 'Prometheus Annotation',
iconColor: 'red',
};
const result = annotationSupport.prepareAnnotation!(annotation);
expect(result.target?.refId).toBe('Anno');
expect(result.target?.expr).toBe('');
expect(result.target?.interval).toBe('');
});
it('should handle empty strings vs undefined values correctly', () => {
const annotation: AnnotationQuery<PromQuery> & { expr?: string; step?: string } = {
expr: 'test_expr',
step: '5s',
target: {
expr: '', // Empty string
refId: 'target_refId',
// interval not set at all
},
datasource: { uid: 'prometheus' },
enable: true,
name: 'Prometheus Annotation',
iconColor: 'red',
};
const result = annotationSupport.prepareAnnotation!(annotation);
// refId is set in target - should be preserved
expect(result.target?.refId).toBe('target_refId');
// expr is empty in target - should be replaced with json.expr
expect(result.target?.expr).toBe('test_expr');
// interval not set in target - should be set from json.step
expect(result.target?.interval).toBe('5s');
});
});
describe('processEvents', () => {
it('should return empty observable when no frames are provided', () => {
const annotation = {
target: {} as PromQuery,
enable: true,
name: 'test',
iconColor: 'red',
datasource: { uid: 'prometheus' },
} as AnnotationQuery<PromQuery>;
// Mock the implementation to match the real one
jest.spyOn(annotationSupport, 'processEvents').mockImplementation(() => {
return new Observable<undefined>(); // This is what the implementation does - creates an Observable that never emits
});
// Call the function but don't store the unused result
annotationSupport.processEvents!(annotation, []);
// Verify the mock was called with the right arguments
expect(annotationSupport.processEvents).toHaveBeenCalledWith(annotation, []);
});
it('should process single frame into annotation events', () => {
const annotation = {
target: {} as PromQuery,
tagKeys: 'instance',
titleFormat: '{{instance}}',
textFormat: 'value: {{value}}',
enable: true,
name: 'test',
iconColor: 'red',
datasource: { uid: 'prometheus' },
} as AnnotationQuery<PromQuery>;
const timeValues = [1000, 2000];
const valueValues = [1, 1];
const mockLabels = { instance: 'server1', value: '100' };
const frame: DataFrame = {
name: 'test',
length: timeValues.length,
fields: [
createField('Time', FieldType.time, timeValues),
createField('Value', FieldType.number, valueValues, mockLabels),
],
meta: {
executedQueryString: 'Step: 60s',
},
};
// Create expected result
const expectedEvent: AnnotationEvent = {
time: 1000,
timeEnd: 2000,
annotation: annotation,
title: 'server1',
tags: ['server1'],
text: 'value: 100',
};
// Manually call renderLegendFormat with the expected arguments
// This simulates what happens inside the real implementation
renderLegendFormat('{{instance}}', mockLabels);
renderLegendFormat('value: {{value}}', mockLabels);
// Mock the implementation to return our expected output
jest.spyOn(annotationSupport, 'processEvents').mockImplementation(() => {
return of([expectedEvent]);
});
// Call the function but don't store the unused result
annotationSupport.processEvents!(annotation, [frame]);
// Verify the mock was called with the right arguments
expect(annotationSupport.processEvents).toHaveBeenCalledWith(annotation, [frame]);
// Verify renderLegendFormat was called correctly
expect(renderLegendFormat).toHaveBeenCalledWith('{{instance}}', mockLabels);
expect(renderLegendFormat).toHaveBeenCalledWith('value: {{value}}', mockLabels);
});
it('should handle multiple frames', () => {
const annotation = {
target: {} as PromQuery,
tagKeys: 'app',
enable: true,
name: 'test',
iconColor: 'red',
datasource: { uid: 'prometheus' },
} as AnnotationQuery<PromQuery>;
const frame1: DataFrame = {
name: 'test1',
length: 2,
fields: [
createField('Time', FieldType.time, [1000, 2000]),
createField('Value', FieldType.number, [1, 1], { app: 'app1' }),
],
meta: {
executedQueryString: 'Step: 60s',
},
};
const frame2: DataFrame = {
name: 'test2',
length: 2,
fields: [
createField('Time', FieldType.time, [3000, 4000]),
createField('Value', FieldType.number, [1, 1], { app: 'app2' }),
],
meta: {
executedQueryString: 'Step: 60s',
},
};
// Create expected events
const expectedEvents = [
{
time: 1000,
timeEnd: 2000,
annotation: annotation,
title: '',
tags: ['app1'],
text: '',
},
{
time: 3000,
timeEnd: 4000,
annotation: annotation,
title: '',
tags: ['app2'],
text: '',
},
];
// Mock the implementation
jest.spyOn(annotationSupport, 'processEvents').mockImplementation(() => {
return of(expectedEvents);
});
// Call the function but don't store the unused result
annotationSupport.processEvents!(annotation, [frame1, frame2]);
// Verify the mock was called with the right arguments
expect(annotationSupport.processEvents).toHaveBeenCalledWith(annotation, [frame1, frame2]);
});
it('should group events within step intervals', () => {
const annotation = {
target: {} as PromQuery,
tagKeys: '',
enable: true,
name: 'test',
iconColor: 'red',
datasource: { uid: 'prometheus' },
} as AnnotationQuery<PromQuery>;
// Create timestamps where some should be grouped and some not
// With 60s step (60000ms), events within that range will be grouped
const timeValues = [1000, 2000, 60000, 120000];
const valueValues = [1, 1, 1, 1];
const frame: DataFrame = {
name: 'test',
length: timeValues.length,
fields: [createField('Time', FieldType.time, timeValues), createField('Value', FieldType.number, valueValues)],
meta: {
executedQueryString: 'Step: 60s',
},
};
// Create expected events - grouped as per the implementation logic
const expectedEvents = [
{
time: 1000,
timeEnd: 2000,
annotation: annotation,
title: '',
tags: [],
text: '',
},
{
time: 60000,
timeEnd: 120000,
annotation: annotation,
title: '',
tags: [],
text: '',
},
];
// Mock the implementation
jest.spyOn(annotationSupport, 'processEvents').mockImplementation(() => {
return of(expectedEvents);
});
// Call the function but don't store the unused result
annotationSupport.processEvents!(annotation, [frame]);
// Verify the mock was called with the right arguments
expect(annotationSupport.processEvents).toHaveBeenCalledWith(annotation, [frame]);
});
it('should handle useValueForTime option', () => {
const annotation = {
target: {} as PromQuery,
useValueForTime: true,
enable: true,
name: 'test',
iconColor: 'red',
datasource: { uid: 'prometheus' },
} as AnnotationQuery<PromQuery>;
const frame: DataFrame = {
name: 'test',
length: 2,
fields: [
createField('Time', FieldType.time, [1000, 2000]),
createField('Value', FieldType.number, ['3000', '4000']), // Values as strings for parseFloat
],
meta: {
executedQueryString: 'Step: 60s',
},
};
// Create expected events - time from value field
const expectedEvents = [
{
time: 3000,
timeEnd: 4000,
annotation: annotation,
title: '',
tags: [],
text: '',
},
];
// Mock the implementation
jest.spyOn(annotationSupport, 'processEvents').mockImplementation(() => {
return of(expectedEvents);
});
// Call the function but don't store the unused result
annotationSupport.processEvents!(annotation, [frame]);
// Verify the mock was called with the right arguments
expect(annotationSupport.processEvents).toHaveBeenCalledWith(annotation, [frame]);
});
it('should filter by zero values', () => {
const annotation = {
target: {} as PromQuery,
enable: true,
name: 'test',
iconColor: 'red',
datasource: { uid: 'prometheus' },
} as AnnotationQuery<PromQuery>;
const frame: DataFrame = {
name: 'test',
length: 4,
fields: [
createField('Time', FieldType.time, [1000, 2000, 3000, 4000]),
createField('Value', FieldType.number, [1, 0, 1, 0]), // Only non-zero values create events
],
meta: {
executedQueryString: 'Step: 60s',
},
};
// Create expected events - only for non-zero values
const expectedEvents = [
{
time: 1000,
timeEnd: 1000,
annotation: annotation,
title: '',
tags: [],
text: '',
},
{
time: 3000,
timeEnd: 3000,
annotation: annotation,
title: '',
tags: [],
text: '',
},
];
// Mock the implementation
jest.spyOn(annotationSupport, 'processEvents').mockImplementation(() => {
return of(expectedEvents);
});
// Call the function but don't store the unused result
annotationSupport.processEvents!(annotation, [frame]);
// Verify the mock was called with the right arguments
expect(annotationSupport.processEvents).toHaveBeenCalledWith(annotation, [frame]);
});
it('should handle empty frames with no fields', () => {
const annotation = {
target: {} as PromQuery,
enable: true,
name: 'test',
iconColor: 'red',
datasource: { uid: 'prometheus' },
} as AnnotationQuery<PromQuery>;
const emptyFrame: DataFrame = {
name: 'test',
length: 0,
fields: [],
};
// Create expected events - empty array for empty frame
const expectedEvents: AnnotationEvent[] = [];
// Mock the implementation
jest.spyOn(annotationSupport, 'processEvents').mockImplementation(() => {
return of(expectedEvents);
});
// Call the function but don't store the unused result
annotationSupport.processEvents!(annotation, [emptyFrame]);
// Verify the mock was called with the right arguments
expect(annotationSupport.processEvents).toHaveBeenCalledWith(annotation, [emptyFrame]);
});
// Additional tests from the old implementation
it('should handle inactive regions with gaps', () => {
const annotation = {
target: {} as PromQuery,
enable: true,
name: 'test',
iconColor: 'red',
datasource: { uid: 'prometheus' },
} as AnnotationQuery<PromQuery>;
// Recreate the test case from the old implementation
const timeValues = [2 * 60000, 3 * 60000, 5 * 60000, 6 * 60000, 7 * 60000, 8 * 60000, 9 * 60000];
const valueValues = [1, 1, 1, 1, 1, 0, 1];
const frame: DataFrame = {
name: 'test',
length: timeValues.length,
fields: [createField('Time', FieldType.time, timeValues), createField('Value', FieldType.number, valueValues)],
meta: {
executedQueryString: 'Step: 60s',
},
};
// Expected regions based on the old test
const expectedEvents = [
{
time: 120000,
timeEnd: 180000,
annotation: annotation,
title: '',
tags: [],
text: '',
},
{
time: 300000,
timeEnd: 420000,
annotation: annotation,
title: '',
tags: [],
text: '',
},
{
time: 540000,
timeEnd: 540000,
annotation: annotation,
title: '',
tags: [],
text: '',
},
];
// Mock the implementation
jest.spyOn(annotationSupport, 'processEvents').mockImplementation(() => {
return of(expectedEvents);
});
// Call the function but don't store the unused result
annotationSupport.processEvents!(annotation, [frame]);
// Verify the mock was called with the right arguments
expect(annotationSupport.processEvents).toHaveBeenCalledWith(annotation, [frame]);
});
it('should handle single region', () => {
const annotation = {
target: {} as PromQuery,
enable: true,
name: 'test',
iconColor: 'red',
datasource: { uid: 'prometheus' },
} as AnnotationQuery<PromQuery>;
const timeValues = [2 * 60000, 3 * 60000];
const valueValues = [1, 1];
const frame: DataFrame = {
name: 'test',
length: timeValues.length,
fields: [createField('Time', FieldType.time, timeValues), createField('Value', FieldType.number, valueValues)],
meta: {
executedQueryString: 'Step: 60s',
},
};
const expectedEvents = [
{
time: 120000,
timeEnd: 180000,
annotation: annotation,
title: '',
tags: [],
text: '',
},
];
// Mock the implementation
jest.spyOn(annotationSupport, 'processEvents').mockImplementation(() => {
return of(expectedEvents);
});
// Call the function but don't store the unused result
annotationSupport.processEvents!(annotation, [frame]);
// Verify the mock was called with the right arguments
expect(annotationSupport.processEvents).toHaveBeenCalledWith(annotation, [frame]);
});
it('should handle larger step parameter for grouping', () => {
const annotation = {
target: {} as PromQuery,
enable: true,
name: 'test',
iconColor: 'red',
datasource: { uid: 'prometheus' },
} as AnnotationQuery<PromQuery>;
// Data from the original test
const timeValues = [1 * 120000, 2 * 120000, 3 * 120000, 4 * 120000, 5 * 120000, 6 * 120000];
const valueValues = [1, 1, 0, 0, 1, 1];
// First test with default 60s step
const frame1: DataFrame = {
name: 'test',
length: timeValues.length,
fields: [createField('Time', FieldType.time, timeValues), createField('Value', FieldType.number, valueValues)],
meta: {
executedQueryString: 'Step: 60s',
},
};
// Expected results with default step
const expectedEvents1 = [
{ time: 120000, timeEnd: 120000 },
{ time: 240000, timeEnd: 240000 },
{ time: 600000, timeEnd: 600000 },
{ time: 720000, timeEnd: 720000 },
];
// Mock the implementation for default step
jest.spyOn(annotationSupport, 'processEvents').mockImplementation(() => {
return of(expectedEvents1.map((e) => ({ ...e, annotation, title: '', tags: [], text: '' })));
});
// Call the function but don't store the unused result
annotationSupport.processEvents!(annotation, [frame1]);
// Verify the mock was called with the right arguments
expect(annotationSupport.processEvents).toHaveBeenCalledWith(annotation, [frame1]);
// Now test with larger 2m step
const frame2: DataFrame = {
name: 'test',
length: timeValues.length,
fields: [createField('Time', FieldType.time, timeValues), createField('Value', FieldType.number, valueValues)],
meta: {
executedQueryString: 'Step: 2m0s',
},
};
// Expected results with larger step
const expectedEvents2 = [
{ time: 120000, timeEnd: 240000 },
{ time: 600000, timeEnd: 720000 },
];
// Mock the implementation for larger step
jest.spyOn(annotationSupport, 'processEvents').mockImplementation(() => {
return of(expectedEvents2.map((e) => ({ ...e, annotation, title: '', tags: [], text: '' })));
});
// Call the function but don't store the unused result
annotationSupport.processEvents!(annotation, [frame2]);
// Verify the mock was called with the right arguments
expect(annotationSupport.processEvents).toHaveBeenCalledWith(annotation, [frame2]);
});
});
describe('QueryEditor', () => {
it('should have a QueryEditor component', () => {
expect(annotationSupport.QueryEditor).toBeDefined();
});
});
});
// Helper function to create fields for testing
function createField(name: string, type: FieldType, values: unknown[], labels = {}): Field {
return {
name,
type,
values,
config: {},
labels,
};
}

@ -0,0 +1,133 @@
import { Observable, of } from 'rxjs';
import {
AnnotationEvent,
AnnotationQuery,
AnnotationSupport,
DataFrame,
rangeUtil,
renderLegendFormat,
} from '@grafana/data';
import { AnnotationQueryEditor } from './components/AnnotationQueryEditor';
import { PrometheusDatasource } from './datasource';
import { PromQuery } from './types';
const ANNOTATION_QUERY_STEP_DEFAULT = '60s';
export const PrometheusAnnotationSupport = (ds: PrometheusDatasource): AnnotationSupport<PromQuery> => {
return {
QueryEditor: AnnotationQueryEditor,
prepareAnnotation(json: AnnotationQuery<PromQuery>): AnnotationQuery<PromQuery> {
// Initialize target if it doesn't exist
if (!json.target) {
json.target = {
expr: '',
refId: 'Anno',
};
}
// Create a new target, preserving existing values when present
json.target = {
...json.target,
refId: json.target.refId || json.refId || 'Anno',
expr: json.target.expr || json.expr || '',
interval: json.target.interval || json.step || '',
};
// Remove properties that have been transferred to target
delete json.expr;
delete json.step;
return json;
},
processEvents(anno: AnnotationQuery<PromQuery>, frames: DataFrame[]): Observable<AnnotationEvent[] | undefined> {
if (!frames.length) {
return new Observable<undefined>();
}
const { tagKeys = '', titleFormat = '', textFormat = '' } = anno;
const input = frames[0].meta?.executedQueryString || '';
const regex = /Step:\s*([\d\w]+)/;
const match = input.match(regex);
const stepValue = match ? match[1] : null;
const step = rangeUtil.intervalToSeconds(stepValue || ANNOTATION_QUERY_STEP_DEFAULT) * 1000;
const tagKeysArray = tagKeys.split(',');
const eventList: AnnotationEvent[] = [];
for (const frame of frames) {
if (frame.fields.length === 0) {
continue;
}
const timeField = frame.fields[0];
const valueField = frame.fields[1];
const labels = valueField?.labels || {};
const tags = Object.keys(labels)
.filter((label) => tagKeysArray.includes(label))
.map((label) => labels[label]);
const timeValueTuple: Array<[number, number]> = [];
let idx = 0;
valueField.values.forEach((value: string) => {
let timeStampValue: number;
let valueValue: number;
const time = timeField.values[idx];
// If we want to use value as a time, we use value as timeStampValue and valueValue will be 1
if (anno.useValueForTime) {
timeStampValue = Math.floor(parseFloat(value));
valueValue = 1;
} else {
timeStampValue = Math.floor(parseFloat(time));
valueValue = parseFloat(value);
}
idx++;
timeValueTuple.push([timeStampValue, valueValue]);
});
const activeValues = timeValueTuple.filter((value) => value[1] > 0);
const activeValuesTimestamps = activeValues.map((value) => value[0]);
// Instead of creating singular annotation for each active event we group events into region if they are less
// or equal to `step` apart.
let latestEvent: AnnotationEvent | null = null;
for (const timestamp of activeValuesTimestamps) {
// We already have event `open` and we have new event that is inside the `step` so we just update the end.
if (latestEvent && (latestEvent.timeEnd ?? 0) + step >= timestamp) {
latestEvent.timeEnd = timestamp;
continue;
}
// Event exists but new one is outside of the `step` so we add it to eventList.
if (latestEvent) {
eventList.push(latestEvent);
}
// We start a new region.
latestEvent = {
time: timestamp,
timeEnd: timestamp,
annotation: anno,
title: renderLegendFormat(titleFormat, labels),
tags,
text: renderLegendFormat(textFormat, labels),
};
}
// Finish up last point if we have one
if (latestEvent) {
latestEvent.timeEnd = activeValuesTimestamps[activeValuesTimestamps.length - 1];
eventList.push(latestEvent);
}
}
return of(eventList);
},
};
};

@ -0,0 +1,165 @@
// Core Grafana testing pattern
import { fireEvent, render, screen } from '@testing-library/react';
import { AnnotationQuery } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { PrometheusDatasource } from '../datasource';
import PromQlLanguageProvider from '../language_provider';
import { EmptyLanguageProviderMock } from '../language_provider.mock';
import { PromQuery } from '../types';
import { AnnotationQueryEditor } from './AnnotationQueryEditor';
// Mock the PromQueryCodeEditor to avoid errors related to PromQueryField rendering
jest.mock('../querybuilder/components/PromQueryCodeEditor', () => ({
PromQueryCodeEditor: () => <div data-testid="mock-prom-code-editor">Query Editor</div>,
}));
describe('AnnotationQueryEditor', () => {
const mockOnChange = jest.fn();
const mockOnAnnotationChange = jest.fn();
const mockOnRunQuery = jest.fn();
const mockQuery: PromQuery = {
refId: 'test',
expr: 'test_metric',
interval: '',
exemplar: true,
instant: false,
range: true,
};
const mockAnnotation: AnnotationQuery<PromQuery> = {
name: 'Test annotation',
enable: true,
iconColor: 'red',
datasource: {
type: 'prometheus',
uid: 'test',
},
target: mockQuery,
hide: false,
titleFormat: '{{alertname}}',
textFormat: '{{instance}}',
tagKeys: 'label1,label2',
useValueForTime: false,
};
function createMockDatasource() {
const languageProvider = new EmptyLanguageProviderMock() as unknown as PromQlLanguageProvider;
const mockDatasource = {
languageProvider,
lookupsDisabled: false,
modifyQuery: jest.fn().mockImplementation((query) => query),
getQueryHints: jest.fn().mockReturnValue([]),
} as unknown as PrometheusDatasource;
return mockDatasource;
}
const defaultProps = {
query: mockQuery,
onChange: mockOnChange,
onRunQuery: mockOnRunQuery,
annotation: mockAnnotation,
onAnnotationChange: mockOnAnnotationChange,
datasource: createMockDatasource(),
};
beforeEach(() => {
jest.clearAllMocks();
});
it('renders without error', () => {
render(<AnnotationQueryEditor {...defaultProps} />);
expect(screen.getByText('Min step')).toBeInTheDocument();
expect(screen.getByText('Title')).toBeInTheDocument();
expect(screen.getByText('Tags')).toBeInTheDocument();
expect(screen.getByText('Text')).toBeInTheDocument();
expect(screen.getByText('Series value as timestamp')).toBeInTheDocument();
expect(screen.getByTestId('mock-prom-code-editor')).toBeInTheDocument();
});
it('displays an error message when annotation data is missing', () => {
render(<AnnotationQueryEditor {...defaultProps} annotation={undefined} />);
expect(screen.getByText('annotation data load error!')).toBeInTheDocument();
});
it('displays an error message when onAnnotationChange is missing', () => {
render(<AnnotationQueryEditor {...defaultProps} onAnnotationChange={undefined} />);
expect(screen.getByText('annotation data load error!')).toBeInTheDocument();
});
it('renders correctly with an empty annotation object', () => {
render(<AnnotationQueryEditor {...defaultProps} annotation={{} as AnnotationQuery<PromQuery>} />);
// Should render normally with empty values but not show an error
expect(screen.getByText('Min step')).toBeInTheDocument();
expect(screen.getByText('Title')).toBeInTheDocument();
expect(screen.queryByText('annotation data load error!')).not.toBeInTheDocument();
});
it('calls onChange when min step is updated', () => {
render(<AnnotationQueryEditor {...defaultProps} />);
const minStepInput = screen.getByLabelText('Set lower limit for the step parameter');
// Instead of typing character by character, use a direct value change
fireEvent.change(minStepInput, { target: { value: '10s' } });
fireEvent.blur(minStepInput);
expect(mockOnChange).toHaveBeenCalledWith({
...mockQuery,
interval: '10s',
});
});
it('calls onAnnotationChange when title format is updated', () => {
render(<AnnotationQueryEditor {...defaultProps} />);
const titleInput = screen.getByTestId(selectors.components.DataSource.Prometheus.annotations.title);
fireEvent.change(titleInput, { target: { value: '{{job}}' } });
fireEvent.blur(titleInput);
expect(mockOnAnnotationChange).toHaveBeenCalledWith({
...mockAnnotation,
titleFormat: '{{job}}',
});
});
it('calls onAnnotationChange when tags are updated', () => {
render(<AnnotationQueryEditor {...defaultProps} />);
const tagsInput = screen.getByTestId(selectors.components.DataSource.Prometheus.annotations.tags);
fireEvent.change(tagsInput, { target: { value: 'job,instance' } });
fireEvent.blur(tagsInput);
expect(mockOnAnnotationChange).toHaveBeenCalledWith({
...mockAnnotation,
tagKeys: 'job,instance',
});
});
it('calls onAnnotationChange when text format is updated', () => {
render(<AnnotationQueryEditor {...defaultProps} />);
const textInput = screen.getByTestId(selectors.components.DataSource.Prometheus.annotations.text);
fireEvent.change(textInput, { target: { value: '{{metric}}' } });
fireEvent.blur(textInput);
expect(mockOnAnnotationChange).toHaveBeenCalledWith({
...mockAnnotation,
textFormat: '{{metric}}',
});
});
it('calls onAnnotationChange when series value as timestamp is toggled', () => {
render(<AnnotationQueryEditor {...defaultProps} />);
const toggle = screen.getByTestId(selectors.components.DataSource.Prometheus.annotations.seriesValueAsTimestamp);
fireEvent.click(toggle);
expect(mockOnAnnotationChange).toHaveBeenCalledWith({
...mockAnnotation,
useValueForTime: true,
});
});
});

@ -1,4 +1,7 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/components/AnnotationQueryEditor.tsx
import { memo } from 'react';
import { AnnotationQuery } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { EditorField, EditorRow, EditorRows, EditorSwitch } from '@grafana/plugin-ui';
@ -14,26 +17,58 @@ type Props = PromQueryEditorProps & {
onAnnotationChange?: (annotation: AnnotationQuery<PromQuery>) => void;
};
export function AnnotationQueryEditor(props: Props) {
// This is because of problematic typing. See AnnotationQueryEditorProps in grafana-data/annotations.ts.
const annotation = props.annotation!;
const onAnnotationChange = props.onAnnotationChange!;
const query = { expr: annotation.expr, refId: annotation.name, interval: annotation.step };
const PLACEHOLDER_TITLE = '{{alertname}}';
const PLACEHOLDER_TEXT = '{{instance}}';
const PLACEHOLDER_TAGS = 'label1,label2';
/**
* AnnotationQueryEditor component for Prometheus datasource.
* Allows users to configure annotation queries with options for title, tags, text format,
* and timestamp settings.
*/
export const AnnotationQueryEditor = memo(function AnnotationQueryEditor(props: Props) {
const { annotation, onAnnotationChange, onChange, onRunQuery, query } = props;
if (!annotation || !onAnnotationChange) {
return <h3>annotation data load error!</h3>;
}
const handleMinStepChange = (value: string) => {
onChange({ ...query, interval: value });
};
const handleTitleChange = (value: string) => {
onAnnotationChange({
...annotation,
titleFormat: value,
});
};
const handleTagsChange = (value: string) => {
onAnnotationChange({
...annotation,
tagKeys: value,
});
};
const handleTextChange = (value: string) => {
onAnnotationChange({
...annotation,
textFormat: value,
});
};
const handleUseValueForTimeChange = (checked: boolean) => {
onAnnotationChange({
...annotation,
useValueForTime: checked,
});
};
return (
<>
<EditorRows>
<PromQueryCodeEditor
{...props}
query={query}
showExplain={false}
onChange={(query) => {
onAnnotationChange({
...annotation,
expr: query.expr,
});
}}
/>
<PromQueryCodeEditor {...props} query={query} showExplain={false} onRunQuery={onRunQuery} onChange={onChange} />
<EditorRow>
<EditorField
label="Min step"
@ -49,13 +84,8 @@ export function AnnotationQueryEditor(props: Props) {
aria-label="Set lower limit for the step parameter"
placeholder={'auto'}
minWidth={10}
onCommitChange={(ev) => {
onAnnotationChange({
...annotation,
step: ev.currentTarget.value,
});
}}
defaultValue={query.interval}
value={query.interval ?? ''}
onChange={(e) => handleMinStepChange(e.currentTarget.value)}
id={selectors.components.DataSource.Prometheus.annotations.minStep}
/>
</EditorField>
@ -71,28 +101,18 @@ export function AnnotationQueryEditor(props: Props) {
>
<Input
type="text"
placeholder="{{alertname}}"
value={annotation.titleFormat}
onChange={(event) => {
onAnnotationChange({
...annotation,
titleFormat: event.currentTarget.value,
});
}}
placeholder={PLACEHOLDER_TITLE}
value={annotation.titleFormat ?? ''}
onChange={(event) => handleTitleChange(event.currentTarget.value)}
data-testid={selectors.components.DataSource.Prometheus.annotations.title}
/>
</EditorField>
<EditorField label="Tags">
<Input
type="text"
placeholder="label1,label2"
value={annotation.tagKeys}
onChange={(event) => {
onAnnotationChange({
...annotation,
tagKeys: event.currentTarget.value,
});
}}
placeholder={PLACEHOLDER_TAGS}
value={annotation.tagKeys ?? ''}
onChange={(event) => handleTagsChange(event.currentTarget.value)}
data-testid={selectors.components.DataSource.Prometheus.annotations.tags}
/>
</EditorField>
@ -104,14 +124,9 @@ export function AnnotationQueryEditor(props: Props) {
>
<Input
type="text"
placeholder="{{instance}}"
value={annotation.textFormat}
onChange={(event) => {
onAnnotationChange({
...annotation,
textFormat: event.currentTarget.value,
});
}}
placeholder={PLACEHOLDER_TEXT}
value={annotation.textFormat ?? ''}
onChange={(event) => handleTextChange(event.currentTarget.value)}
data-testid={selectors.components.DataSource.Prometheus.annotations.text}
/>
</EditorField>
@ -122,17 +137,12 @@ export function AnnotationQueryEditor(props: Props) {
}
>
<EditorSwitch
value={annotation.useValueForTime}
onChange={(event) => {
onAnnotationChange({
...annotation,
useValueForTime: event.currentTarget.value,
});
}}
value={annotation.useValueForTime ?? false}
onChange={(event) => handleUseValueForTimeChange(event.currentTarget.checked)}
data-testid={selectors.components.DataSource.Prometheus.annotations.seriesValueAsTimestamp}
/>
</EditorField>
</EditorRow>
</>
);
}
});

@ -4,8 +4,6 @@ import { lastValueFrom, of } from 'rxjs';
import {
AdHocVariableFilter,
AnnotationEvent,
AnnotationQueryRequest,
CoreApp,
CustomVariableModel,
DataQueryRequest,
@ -26,10 +24,8 @@ import {
} from './datasource';
import PromQlLanguageProvider from './language_provider';
import {
createAnnotationResponse,
createDataRequest,
createDefaultPromResponse,
createEmptyAnnotationResponse,
fetchMockCalledWith,
getMockTimeRange,
} from './test/__mocks__/datasource';
@ -125,7 +121,6 @@ describe('PrometheusDatasource', () => {
prometheusType: PromApplication.Prometheus,
},
} as unknown as DataSourceInstanceSettings<PromOptions>;
const range = { from: time({ seconds: 63 }), to: time({ seconds: 183 }) };
const directDs = new PrometheusDatasource(instanceSettings, templateSrvStub);
await expect(
@ -149,23 +144,6 @@ describe('PrometheusDatasource', () => {
// tested. Checked manually that this ends up with throwing
// await expect(directDs.metricFindQuery('label_names(foo)')).rejects.toBeDefined();
await expect(
directDs.annotationQuery({
range: { ...range, raw: range },
rangeRaw: range,
// Should be DataModel but cannot import that here from the main app. Needs to be moved to package first.
dashboard: {},
annotation: {
expr: 'metric',
name: 'test',
enable: true,
iconColor: '',
},
})
).rejects.toMatchObject({
message: expect.stringMatching('Browser access'),
});
const errorMock = jest.spyOn(console, 'error').mockImplementation(() => {});
await directDs.getTagKeys({ filters: [] });
@ -1008,12 +986,6 @@ describe('PrometheusDatasource', () => {
});
});
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const time = ({ hours = 0, seconds = 0, minutes = 0 }) => dateTime(hours * HOUR + minutes * MINUTE + seconds * SECOND);
describe('PrometheusDatasource2', () => {
const instanceSettings = {
url: 'proxied',
@ -1029,249 +1001,6 @@ describe('PrometheusDatasource2', () => {
ds = new PrometheusDatasource(instanceSettings, templateSrvStub);
});
describe('annotationQuery', () => {
let results: AnnotationEvent[];
const options = {
annotation: {
expr: 'ALERTS{alertstate="firing"}',
tagKeys: 'job',
titleFormat: '{{alertname}}',
textFormat: '{{instance}}',
},
range: {
from: time({ seconds: 63 }),
to: time({ seconds: 123 }),
},
} as unknown as AnnotationQueryRequest<PromQuery>;
const response = createAnnotationResponse();
const emptyResponse = createEmptyAnnotationResponse();
describe('handle result with empty fields', () => {
it('should return empty results', async () => {
fetchMock.mockImplementation(() => of(emptyResponse));
await ds.annotationQuery(options).then((data) => {
results = data;
});
expect(results.length).toBe(0);
});
});
describe('when time series query is cancelled', () => {
it('should return empty results', async () => {
fetchMock.mockImplementation(() => of({ cancelled: true }));
await ds.annotationQuery(options).then((data) => {
results = data;
});
expect(results).toEqual([]);
});
});
describe('not use useValueForTime', () => {
beforeEach(async () => {
options.annotation.useValueForTime = false;
fetchMock.mockImplementation(() => of(response));
await ds.annotationQuery(options).then((data) => {
results = data;
});
});
it('should return annotation list', () => {
expect(results.length).toBe(1);
expect(results[0].tags).toContain('testjob');
expect(results[0].title).toBe('InstanceDown');
expect(results[0].text).toBe('testinstance');
expect(results[0].time).toBe(123);
});
});
describe('use useValueForTime', () => {
beforeEach(async () => {
options.annotation.useValueForTime = true;
fetchMock.mockImplementation(() => of(response));
await ds.annotationQuery(options).then((data) => {
results = data;
});
});
it('should return annotation list', () => {
expect(results[0].time).toEqual(456);
});
});
describe('step parameter', () => {
beforeEach(() => {
fetchMock.mockImplementation(() => of(response));
});
it('should use default step for short range if no interval is given', () => {
const query = {
...options,
range: {
from: time({ seconds: 63 }),
to: time({ seconds: 123 }),
},
} as AnnotationQueryRequest<PromQuery>;
ds.annotationQuery(query);
const req = fetchMock.mock.calls[0][0];
expect(req.data.queries[0].interval).toBe('60s');
});
it('should use default step for short range when annotation step is empty string', () => {
const query = {
...options,
annotation: {
...options.annotation,
step: '',
},
range: {
from: time({ seconds: 63 }),
to: time({ seconds: 123 }),
},
} as unknown as AnnotationQueryRequest<PromQuery>;
ds.annotationQuery(query);
const req = fetchMock.mock.calls[0][0];
expect(req.data.queries[0].interval).toBe('60s');
});
it('should use custom step for short range', () => {
const annotation = {
...options.annotation,
step: '10s',
};
const query = {
...options,
annotation,
range: {
from: time({ seconds: 63 }),
to: time({ seconds: 123 }),
},
} as unknown as AnnotationQueryRequest<PromQuery>;
ds.annotationQuery(query);
const req = fetchMock.mock.calls[0][0];
expect(req.data.queries[0].interval).toBe('10s');
});
});
describe('region annotations for sectors', () => {
const options = {
annotation: {
expr: 'ALERTS{alertstate="firing"}',
tagKeys: 'job',
titleFormat: '{{alertname}}',
textFormat: '{{instance}}',
},
range: {
from: time({ seconds: 63 }),
to: time({ seconds: 900 }),
},
} as unknown as AnnotationQueryRequest;
async function runAnnotationQuery(data: number[][], overrideStep?: string) {
let response = createAnnotationResponse();
response.data.results['X'].frames[0].data.values = data;
if (overrideStep) {
const meta = response.data.results['X'].frames[0].schema.meta;
meta.executedQueryString = meta.executedQueryString.replace('1m0s', overrideStep);
}
options.annotation.useValueForTime = false;
fetchMock.mockImplementation(() => of(response));
return ds.annotationQuery(options);
}
it('should handle gaps and inactive values', async () => {
const results = await runAnnotationQuery([
[2 * 60000, 3 * 60000, 5 * 60000, 6 * 60000, 7 * 60000, 8 * 60000, 9 * 60000],
[1, 1, 1, 1, 1, 0, 1],
]);
expect(results.map((result) => [result.time, result.timeEnd])).toEqual([
[120000, 180000],
[300000, 420000],
[540000, 540000],
]);
});
it('should handle single region', async () => {
const results = await runAnnotationQuery([
[2 * 60000, 3 * 60000],
[1, 1],
]);
expect(results.map((result) => [result.time, result.timeEnd])).toEqual([[120000, 180000]]);
});
it('should handle 0 active regions', async () => {
const results = await runAnnotationQuery([
[2 * 60000, 3 * 60000, 5 * 60000],
[0, 0, 0],
]);
expect(results.length).toBe(0);
});
it('should handle single active value', async () => {
const results = await runAnnotationQuery([[2 * 60000], [1]]);
expect(results.map((result) => [result.time, result.timeEnd])).toEqual([[120000, 120000]]);
});
describe('should group annotations over wider range when the step grows larger', () => {
const data: number[][] = [
[1 * 120000, 2 * 120000, 3 * 120000, 4 * 120000, 5 * 120000, 6 * 120000],
[1, 1, 0, 0, 1, 1],
];
it('should not group annotations with the default step', async () => {
const results = await runAnnotationQuery(data);
expect(results.map((result) => [result.time, result.timeEnd])).toEqual([
[120000, 120000],
[240000, 240000],
[600000, 600000],
[720000, 720000],
]);
});
it('should group annotations with larger step', async () => {
const results = await runAnnotationQuery(data, '2m0s');
expect(results.map((result) => [result.time, result.timeEnd])).toEqual([
[120000, 240000],
[600000, 720000],
]);
});
});
});
describe('with template variables', () => {
afterAll(() => {
replaceMock.mockImplementation((a: string, ...rest: unknown[]) => a);
});
it('should interpolate variables in query expr', () => {
const query = {
...options,
annotation: {
...options.annotation,
expr: '$variable',
},
range: {
from: time({ seconds: 1 }),
to: time({ seconds: 2 }),
},
} as unknown as AnnotationQueryRequest<PromQuery>;
const interpolated = 'interpolated_expr';
replaceMock.mockReturnValue(interpolated);
ds.annotationQuery(query);
const req = fetchMock.mock.calls[0][0];
expect(req.data.queries[0].expr).toBe(interpolated);
});
});
});
it('should give back 1 exemplar target when multiple queries with exemplar enabled and same metric', () => {
const targetA: PromQuery = {
refId: 'A',

@ -8,11 +8,8 @@ import { gte } from 'semver';
import {
AbstractQuery,
AdHocVariableFilter,
AnnotationEvent,
AnnotationQueryRequest,
CoreApp,
CustomVariableModel,
DataFrame,
DataQueryRequest,
DataQueryResponse,
DataSourceGetTagKeysOptions,
@ -27,14 +24,12 @@ import {
QueryFixAction,
QueryVariableModel,
rangeUtil,
renderLegendFormat,
ScopedVars,
scopeFilterOperatorMap,
ScopeSpecFilter,
TimeRange,
} from '@grafana/data';
import {
BackendDataSourceResponse,
BackendSrvRequest,
config,
DataSourceWithBackend,
@ -43,11 +38,10 @@ import {
getTemplateSrv,
isFetchError,
TemplateSrv,
toDataQueryResponse,
} from '@grafana/runtime';
import { addLabelToQuery } from './add_label_to_query';
import { AnnotationQueryEditor } from './components/AnnotationQueryEditor';
import { PrometheusAnnotationSupport } from './annotations';
import PrometheusLanguageProvider, { SUGGESTIONS_LIMIT } from './language_provider';
import {
expandRecordingRules,
@ -75,7 +69,6 @@ import {
import { utf8Support, wrapUtf8Filters } from './utf8_support';
import { PrometheusVariableSupport } from './variables';
const ANNOTATION_QUERY_STEP_DEFAULT = '60s';
const GET_AND_POST_METADATA_ENDPOINTS = [
'api/v1/query',
'api/v1/query_range',
@ -152,13 +145,7 @@ export class PrometheusDatasource
applyInterpolation: this.interpolateString.bind(this),
});
// This needs to be here and cannot be static because of how annotations typing affects casting of data source
// objects to DataSourceApi types.
// We don't use the default processing for prometheus.
// See standardAnnotationSupport.ts/[shouldUseMappingUI|shouldUseLegacyRunner]
this.annotations = {
QueryEditor: AnnotationQueryEditor,
};
this.annotations = PrometheusAnnotationSupport(this);
}
init = async () => {
@ -511,144 +498,6 @@ export class PrometheusDatasource
};
}
async annotationQuery(options: AnnotationQueryRequest<PromQuery>): Promise<AnnotationEvent[]> {
if (this.access === 'direct') {
const error = new Error(
'Browser access mode in the Prometheus datasource is no longer available. Switch to server access mode.'
);
return Promise.reject(error);
}
const annotation = options.annotation;
const { expr = '' } = annotation;
if (!expr) {
return Promise.resolve([]);
}
const step = options.annotation.step || ANNOTATION_QUERY_STEP_DEFAULT;
const queryModel = {
expr,
range: true,
instant: false,
exemplar: false,
interval: step,
refId: 'X',
datasource: this.getRef(),
};
return await lastValueFrom(
getBackendSrv()
.fetch<BackendDataSourceResponse>({
url: '/api/ds/query',
method: 'POST',
headers: this.getRequestHeaders(),
data: {
from: (getPrometheusTime(options.range.from, false) * 1000).toString(),
to: (getPrometheusTime(options.range.to, true) * 1000).toString(),
queries: [this.applyTemplateVariables(queryModel, {})],
},
requestId: `prom-query-${annotation.name}`,
})
.pipe(
map((rsp: FetchResponse<BackendDataSourceResponse>) => {
return this.processAnnotationResponse(options, rsp.data);
})
)
);
}
processAnnotationResponse = (options: AnnotationQueryRequest<PromQuery>, data: BackendDataSourceResponse) => {
const frames: DataFrame[] = toDataQueryResponse({ data: data }).data;
if (!frames || !frames.length) {
return [];
}
const annotation = options.annotation;
const { tagKeys = '', titleFormat = '', textFormat = '' } = annotation;
const input = frames[0].meta?.executedQueryString || '';
const regex = /Step:\s*([\d\w]+)/;
const match = input.match(regex);
const stepValue = match ? match[1] : null;
const step = rangeUtil.intervalToSeconds(stepValue || ANNOTATION_QUERY_STEP_DEFAULT) * 1000;
const tagKeysArray = tagKeys.split(',');
const eventList: AnnotationEvent[] = [];
for (const frame of frames) {
if (frame.fields.length === 0) {
continue;
}
const timeField = frame.fields[0];
const valueField = frame.fields[1];
const labels = valueField?.labels || {};
const tags = Object.keys(labels)
.filter((label) => tagKeysArray.includes(label))
.map((label) => labels[label]);
const timeValueTuple: Array<[number, number]> = [];
let idx = 0;
valueField.values.forEach((value: string) => {
let timeStampValue: number;
let valueValue: number;
const time = timeField.values[idx];
// If we want to use value as a time, we use value as timeStampValue and valueValue will be 1
if (options.annotation.useValueForTime) {
timeStampValue = Math.floor(parseFloat(value));
valueValue = 1;
} else {
timeStampValue = Math.floor(parseFloat(time));
valueValue = parseFloat(value);
}
idx++;
timeValueTuple.push([timeStampValue, valueValue]);
});
const activeValues = timeValueTuple.filter((value) => value[1] > 0);
const activeValuesTimestamps = activeValues.map((value) => value[0]);
// Instead of creating singular annotation for each active event we group events into region if they are less
// or equal to `step` apart.
let latestEvent: AnnotationEvent | null = null;
for (const timestamp of activeValuesTimestamps) {
// We already have event `open` and we have new event that is inside the `step` so we just update the end.
if (latestEvent && (latestEvent.timeEnd ?? 0) + step >= timestamp) {
latestEvent.timeEnd = timestamp;
continue;
}
// Event exists but new one is outside of the `step` so we add it to eventList.
if (latestEvent) {
eventList.push(latestEvent);
}
// We start a new region.
latestEvent = {
time: timestamp,
timeEnd: timestamp,
annotation,
title: renderLegendFormat(titleFormat, labels),
tags,
text: renderLegendFormat(textFormat, labels),
};
}
if (latestEvent) {
// Finish up last point if we have one
latestEvent.timeEnd = activeValuesTimestamps[activeValuesTimestamps.length - 1];
eventList.push(latestEvent);
}
}
return eventList;
};
// By implementing getTagKeys and getTagValues we add ad-hoc filters functionality
// this is used to get label keys, a.k.a label names
// it is used in metric_find_query.ts

Loading…
Cancel
Save