Skip to content

Commit b79e634

Browse files
Merge pull request #678 from rioloc/feat/split_query_range_requests
OU-632: Split ALERTS query_range into several requests for Incidents
2 parents 5bd4223 + 179ebfe commit b79e634

4 files changed

Lines changed: 290 additions & 116 deletions

File tree

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
// Setup global.window before importing modules that use it
2+
(global as any).window = {
3+
SERVER_FLAGS: {
4+
prometheusBaseURL: '/api/prometheus',
5+
prometheusTenancyBaseURL: '/api/prometheus-tenancy',
6+
alertManagerBaseURL: '/api/alertmanager',
7+
},
8+
};
9+
10+
import { createAlertsQuery, fetchDataForIncidentsAndAlerts } from './api';
11+
import { PrometheusResponse, consoleFetchJSON } from '@openshift-console/dynamic-plugin-sdk';
12+
import { buildPrometheusUrl } from '../utils';
13+
14+
// Mock the SDK
15+
jest.mock('@openshift-console/dynamic-plugin-sdk', () => ({
16+
PrometheusEndpoint: {
17+
QUERY_RANGE: 'api/v1/query_range',
18+
},
19+
consoleFetchJSON: jest.fn(),
20+
}));
21+
22+
// Mock the global utils to avoid window access side effects
23+
jest.mock('../utils', () => ({
24+
getPrometheusBasePath: jest.fn(),
25+
buildPrometheusUrl: jest.fn(),
26+
}));
27+
28+
describe('createAlertsQuery', () => {
29+
it('should create a valid alerts query', () => {
30+
const alertsQuery = createAlertsQuery([
31+
{
32+
src_alertname: 'test',
33+
src_severity: 'critical',
34+
src_namespace: 'test',
35+
src_silenced: 'false',
36+
},
37+
{
38+
src_alertname: 'test2',
39+
src_severity: 'warning',
40+
src_namespace: 'test2',
41+
src_silenced: 'false',
42+
},
43+
{
44+
src_alertname: 'test2',
45+
src_severity: 'warning',
46+
src_namespace: 'test2',
47+
src_silenced: 'true',
48+
},
49+
]);
50+
expect(alertsQuery).toEqual([
51+
'ALERTS{alertname="test", severity="critical", namespace="test"} or ALERTS{alertname="test2", severity="warning", namespace="test2"}',
52+
]);
53+
});
54+
it('should create valid alerts queries array', () => {
55+
const alertsQuery = createAlertsQuery(
56+
[
57+
{
58+
src_alertname: 'test',
59+
src_severity: 'critical',
60+
src_namespace: 'test',
61+
src_silenced: 'false',
62+
},
63+
{
64+
src_alertname: 'test2',
65+
src_severity: 'warning',
66+
src_namespace: 'test2',
67+
src_silenced: 'false',
68+
},
69+
{
70+
src_alertname: 'test2',
71+
src_severity: 'warning',
72+
src_namespace: 'test2',
73+
src_silenced: 'true',
74+
},
75+
],
76+
100,
77+
);
78+
expect(alertsQuery).toEqual([
79+
'ALERTS{alertname="test", severity="critical", namespace="test"}',
80+
'ALERTS{alertname="test2", severity="warning", namespace="test2"}',
81+
]);
82+
});
83+
});
84+
85+
describe('fetchDataForIncidentsAndAlerts', () => {
86+
it('should fetch data for incidents and alerts', async () => {
87+
(buildPrometheusUrl as jest.Mock).mockReturnValue('/mock/url');
88+
const now = Date.now();
89+
90+
const result1 = {
91+
metric: {
92+
alertname: 'test',
93+
severity: 'critical',
94+
namespace: 'test',
95+
},
96+
values: [
97+
[now - 1000, '1'],
98+
[now - 500, '2'],
99+
] as [number, string][],
100+
};
101+
102+
const result2 = {
103+
metric: {
104+
alertname: 'test2',
105+
severity: 'warning',
106+
namespace: 'test2',
107+
},
108+
values: [
109+
[now - 2000, '3'],
110+
[now - 1500, '4'],
111+
] as [number, string][],
112+
};
113+
114+
const mockPrometheusResponse1: PrometheusResponse = {
115+
status: 'success',
116+
data: {
117+
resultType: 'matrix',
118+
result: [result1],
119+
},
120+
};
121+
122+
const mockPrometheusResponse2: PrometheusResponse = {
123+
status: 'success',
124+
data: {
125+
resultType: 'matrix',
126+
result: [result2],
127+
},
128+
};
129+
130+
const mockConsoleFetchJSON = consoleFetchJSON as jest.MockedFunction<typeof consoleFetchJSON>;
131+
mockConsoleFetchJSON
132+
.mockResolvedValueOnce(mockPrometheusResponse1)
133+
.mockResolvedValueOnce(mockPrometheusResponse2);
134+
135+
const range = { endTime: now, duration: 86400000 };
136+
const customQuery = [
137+
'ALERTS{alertname="test", severity="critical", namespace="test"}',
138+
'ALERTS{alertname="test2", severity="warning", namespace="test2"}',
139+
];
140+
const result = await fetchDataForIncidentsAndAlerts(mockConsoleFetchJSON, range, customQuery);
141+
expect(result).toEqual({
142+
status: 'success',
143+
data: {
144+
resultType: 'matrix',
145+
result: [result1, result2],
146+
},
147+
});
148+
expect(mockConsoleFetchJSON).toHaveBeenCalledTimes(2);
149+
});
150+
});
Lines changed: 107 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,49 @@
11
/* eslint-disable max-len */
22

3-
import { PrometheusEndpoint, PrometheusResponse } from '@openshift-console/dynamic-plugin-sdk';
3+
import {
4+
consoleFetchJSON,
5+
PrometheusEndpoint,
6+
PrometheusResponse,
7+
} from '@openshift-console/dynamic-plugin-sdk';
48
import { getPrometheusBasePath, buildPrometheusUrl } from '../utils';
59
import { PROMETHEUS_QUERY_INTERVAL_SECONDS } from './utils';
10+
11+
const MAX_URL_LENGTH = 2048;
12+
13+
/**
14+
* Creates a single Prometheus alert query string from a grouped alert value.
15+
* @param {Object} query - Single grouped alert object with src_ prefixed properties and layer/component.
16+
* @returns {string} - A string representing a single Prometheus alert query.
17+
*/
18+
const createSingleAlertQuery = (query) => {
19+
// Dynamically get all keys starting with "src_"
20+
const srcKeys = Object.keys(query).filter(
21+
(key) => key.startsWith('src_') && key != 'src_silenced',
22+
);
23+
24+
// Create the alertParts array using the dynamically discovered src_ keys,
25+
// but remove the "src_" prefix from the keys in the final query string.
26+
const alertParts = srcKeys
27+
.filter((key) => query[key]) // Only include keys that are present in the query object
28+
.map((key) => `${key.replace('src_', '')}="${query[key]}"`) // Remove "src_" prefix from keys
29+
.join(', ');
30+
31+
// Construct the query string for each grouped alert
32+
return `ALERTS{${alertParts}}`;
33+
};
34+
635
/**
736
* Creates a Prometheus alerts query string from grouped alert values.
837
* The function dynamically includes any properties in the input objects that have the "src_" prefix,
938
* but the prefix is removed from the keys in the final query string.
1039
*
1140
* @param {Object[]} groupedAlertsValues - Array of grouped alert objects.
12-
* Each alert object should contain various properties, including "src_" prefixed properties,
13-
* as well as "layer" and "component" for constructing the meta fields in the query.
41+
* Each alert object should contain various properties, including "src_" prefixed properties
1442
*
1543
* @param {string} groupedAlertsValues[].layer - The layer of the alert, used in the absent condition.
1644
* @param {string} groupedAlertsValues[].component - The component of the alert, used in the absent condition.
17-
* @returns {string} - A string representing the combined Prometheus alerts query.
18-
* Each alert query is formatted as `(ALERTS{key="value", ...} + on () group_left (component, layer) (absent(meta{layer="value", component="value"})))`
19-
* and multiple queries are joined by "or".
45+
* @returns {string[]} - An array of strings representing the combined Prometheus alerts query.
46+
* Each alert query is formatted as `(ALERTS{key="value", ...} and multiple queries are joined by "or".
2047
*
2148
* @example
2249
* const alerts = [
@@ -38,63 +65,91 @@ import { PROMETHEUS_QUERY_INTERVAL_SECONDS } from './utils';
3865
*
3966
* const query = createAlertsQuery(alerts);
4067
* // Returns:
41-
* // '(ALERTS{alertname="AlertmanagerReceiversNotConfigured", namespace="openshift-monitoring", severity="warning"} + on () group_left (component, layer) (absent(meta{layer="core", component="monitoring"}))) or
42-
* // (ALERTS{alertname="AnotherAlert", namespace="default", severity="critical"} + on () group_left (component, layer) (absent(meta{layer="app", component="frontend"})))'
68+
* // ['ALERTS{alertname="AlertmanagerReceiversNotConfigured", namespace="openshift-monitoring", severity="warning"} or
69+
* // ALERTS{alertname="AnotherAlert", namespace="default", severity="critical"}']
4370
*/
44-
export const createAlertsQuery = (groupedAlertsValues) => {
45-
const alertsQuery = groupedAlertsValues
46-
.map((query) => {
47-
// Dynamically get all keys starting with "src_"
48-
const srcKeys = Object.keys(query).filter((key) => key.startsWith('src_'));
49-
50-
// Create the alertParts array using the dynamically discovered src_ keys,
51-
// but remove the "src_" prefix from the keys in the final query string.
52-
const alertParts = srcKeys
53-
.filter((key) => query[key]) // Only include keys that are present in the query object
54-
.map((key) => `${key.replace('src_', '')}="${query[key]}"`) // Remove "src_" prefix from keys
55-
.join(', ');
56-
57-
// Construct the query string for each grouped alert
58-
return `(ALERTS{${alertParts}} + on () group_left (component, layer) (absent(meta{layer="${query.layer}", component="${query.component}"})))`;
59-
})
60-
.join(' or '); // Join all individual alert queries with "or"
61-
62-
// TODO: remove duplicated conditions, optimize query
63-
64-
return alertsQuery;
71+
export const createAlertsQuery = (groupedAlertsValues, max_url_length = MAX_URL_LENGTH) => {
72+
const queries = [];
73+
const alertsMap = new Map<string, boolean>();
74+
75+
let currentQueryParts = [];
76+
let currentQueryLength = 0;
77+
78+
for (const alertValue of groupedAlertsValues) {
79+
const singleAlertQuery = createSingleAlertQuery(alertValue);
80+
if (alertsMap.has(singleAlertQuery)) {
81+
continue;
82+
}
83+
alertsMap.set(singleAlertQuery, true);
84+
const newQueryLength = currentQueryLength + singleAlertQuery.length + 4; // 4 for ' or '
85+
86+
if (newQueryLength <= max_url_length) {
87+
currentQueryParts.push(singleAlertQuery);
88+
currentQueryLength = newQueryLength;
89+
continue;
90+
}
91+
queries.push(currentQueryParts.join(' or '));
92+
currentQueryParts = [singleAlertQuery];
93+
currentQueryLength = singleAlertQuery.length;
94+
}
95+
96+
if (currentQueryParts.length > 0) {
97+
queries.push(currentQueryParts.join(' or '));
98+
}
99+
100+
return queries;
65101
};
66102

67-
export const fetchDataForIncidentsAndAlerts = (
103+
export const fetchDataForIncidentsAndAlerts = async (
68104
fetch: (url: string) => Promise<PrometheusResponse>,
69105
range: { endTime: number; duration: number },
70-
customQuery: string,
106+
customQuery: string | string[],
71107
) => {
72108
// Calculate samples to ensure step=PROMETHEUS_QUERY_INTERVAL_SECONDS (300s / 5 minutes)
73109
// For 24h duration: Math.ceil(86400000 / 288 / 1000) = 300 seconds
74110
const samples = Math.floor(range.duration / (PROMETHEUS_QUERY_INTERVAL_SECONDS * 1000));
111+
const queries = Array.isArray(customQuery) ? customQuery : [customQuery];
75112

76-
const url = buildPrometheusUrl({
77-
prometheusUrlProps: {
78-
endpoint: PrometheusEndpoint.QUERY_RANGE,
79-
endTime: range.endTime,
80-
query: customQuery,
81-
samples,
82-
timespan: range.duration,
83-
},
84-
basePath: getPrometheusBasePath({
85-
prometheus: 'cmo',
86-
useTenancyPath: false,
87-
}),
88-
});
89-
90-
if (!url) {
91-
// Return empty result when query is empty to avoid making invalid API calls
92-
return Promise.resolve({
93-
data: {
94-
result: [],
113+
const promises = queries.map((query) => {
114+
const url = buildPrometheusUrl({
115+
prometheusUrlProps: {
116+
endpoint: PrometheusEndpoint.QUERY_RANGE,
117+
endTime: range.endTime,
118+
query,
119+
samples,
120+
timespan: range.duration,
95121
},
122+
basePath: getPrometheusBasePath({
123+
prometheus: 'cmo',
124+
useTenancyPath: false,
125+
}),
96126
});
97-
}
98127

99-
return fetch(url);
128+
if (!url) {
129+
// Return empty result when query is empty to avoid making invalid API calls
130+
return Promise.resolve({
131+
status: 'success',
132+
data: {
133+
resultType: 'matrix',
134+
result: [],
135+
},
136+
} as PrometheusResponse);
137+
}
138+
139+
return consoleFetchJSON(url);
140+
});
141+
142+
const responses = await Promise.all(promises);
143+
144+
// Merge responses
145+
const combinedResult = responses.flatMap((r) => r.data?.result || []);
146+
147+
// Construct a synthetic response
148+
return {
149+
status: 'success',
150+
data: {
151+
resultType: responses[0]?.data?.resultType || 'matrix',
152+
result: combinedResult,
153+
},
154+
} as PrometheusResponse;
100155
};

0 commit comments

Comments
 (0)