@oneuptime/common 11.3.4 → 11.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Server/Infrastructure/Queue.ts +103 -16
- package/Server/Services/AnalyticsDatabaseService.ts +9 -5
- package/Server/Services/TeamMemberService.ts +38 -1
- package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +31 -0
- package/Server/Utils/Monitor/IncomingRequestIncidentGrouping.ts +428 -0
- package/Server/Utils/Monitor/MonitorAlert.ts +158 -8
- package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +26 -0
- package/Server/Utils/Monitor/MonitorIncident.ts +147 -2
- package/Server/Utils/Monitor/MonitorResource.ts +74 -0
- package/Tests/Server/Utils/AnalyticsDatabase/ClusterAwareSchema.test.ts +2 -1
- package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +32 -0
- package/Tests/Server/Utils/Monitor/IncomingRequestIncidentGrouping.test.ts +389 -0
- package/Tests/Server/Utils/Monitor/SeriesAbsenceResolutionGuard.test.ts +177 -0
- package/Tests/Types/Events/Recurring.test.ts +475 -0
- package/Tests/Types/Monitor/CriteriaFilter.test.ts +348 -0
- package/Tests/Types/OnCallDutyPolicy/RestrictionTimes.test.ts +331 -0
- package/Tests/UI/Components/Alert.test.tsx +23 -15
- package/Tests/UI/Components/Breadcrumbs.test.tsx +18 -6
- package/Tests/UI/Components/Button.test.tsx +3 -3
- package/Tests/UI/Components/DictionaryFilterOperator.test.ts +174 -0
- package/Tests/UI/Components/EmptyState/EmptyState.test.tsx +4 -3
- package/Tests/UI/Components/Input.test.tsx +3 -2
- package/Tests/UI/Components/Pill.test.tsx +4 -2
- package/Tests/UI/Components/SideMenuItem.test.tsx +18 -8
- package/Tests/UI/Components/TextArea.test.tsx +6 -4
- package/Types/Monitor/IncomingMonitor/IncidentGroupingConfig.ts +74 -0
- package/Types/Monitor/MonitorCriteriaInstance.ts +22 -0
- package/UI/Components/Dictionary/Dictionary.tsx +123 -50
- package/UI/Components/Dictionary/DictionaryFilterOperator.ts +96 -1
- package/UI/Components/MasterPage/MasterPage.tsx +6 -1
- package/build/dist/Server/Infrastructure/Queue.js +83 -15
- package/build/dist/Server/Infrastructure/Queue.js.map +1 -1
- package/build/dist/Server/Services/AnalyticsDatabaseService.js +9 -5
- package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
- package/build/dist/Server/Services/TeamMemberService.js +37 -1
- package/build/dist/Server/Services/TeamMemberService.js.map +1 -1
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +26 -0
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/IncomingRequestIncidentGrouping.js +288 -0
- package/build/dist/Server/Utils/Monitor/IncomingRequestIncidentGrouping.js.map +1 -0
- package/build/dist/Server/Utils/Monitor/MonitorAlert.js +119 -8
- package/build/dist/Server/Utils/Monitor/MonitorAlert.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +24 -0
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorIncident.js +103 -2
- package/build/dist/Server/Utils/Monitor/MonitorIncident.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorResource.js +62 -0
- package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
- package/build/dist/Types/Monitor/IncomingMonitor/IncidentGroupingConfig.js +8 -0
- package/build/dist/Types/Monitor/IncomingMonitor/IncidentGroupingConfig.js.map +1 -0
- package/build/dist/Types/Monitor/MonitorCriteriaInstance.js +10 -0
- package/build/dist/Types/Monitor/MonitorCriteriaInstance.js.map +1 -1
- package/build/dist/UI/Components/Dictionary/Dictionary.js +60 -9
- package/build/dist/UI/Components/Dictionary/Dictionary.js.map +1 -1
- package/build/dist/UI/Components/Dictionary/DictionaryFilterOperator.js +69 -0
- package/build/dist/UI/Components/Dictionary/DictionaryFilterOperator.js.map +1 -1
- package/build/dist/UI/Components/MasterPage/MasterPage.js +7 -1
- package/build/dist/UI/Components/MasterPage/MasterPage.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import IncomingRequestIncidentGrouping from "../../../../Server/Utils/Monitor/IncomingRequestIncidentGrouping";
|
|
2
|
+
import DataToProcess from "../../../../Server/Utils/Monitor/DataToProcess";
|
|
3
|
+
import IncidentGroupingConfig from "../../../../Types/Monitor/IncomingMonitor/IncidentGroupingConfig";
|
|
4
|
+
import IncomingMonitorRequest from "../../../../Types/Monitor/IncomingMonitor/IncomingMonitorRequest";
|
|
5
|
+
import MonitorCriteriaInstance from "../../../../Types/Monitor/MonitorCriteriaInstance";
|
|
6
|
+
import { JSONObject } from "../../../../Types/JSON";
|
|
7
|
+
import ObjectID from "../../../../Types/ObjectID";
|
|
8
|
+
import { PerSeriesCriteriaMatch } from "../../../../Types/Probe/ProbeApiIngestResponse";
|
|
9
|
+
|
|
10
|
+
function makeRequest(
|
|
11
|
+
requestBody: JSONObject | string | undefined,
|
|
12
|
+
overrides?: Partial<IncomingMonitorRequest>,
|
|
13
|
+
): DataToProcess {
|
|
14
|
+
const request: IncomingMonitorRequest = {
|
|
15
|
+
projectId: ObjectID.generate(),
|
|
16
|
+
monitorId: ObjectID.generate(),
|
|
17
|
+
requestBody: requestBody,
|
|
18
|
+
incomingRequestReceivedAt: new Date(0),
|
|
19
|
+
checkedAt: new Date(0),
|
|
20
|
+
...overrides,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
return request as unknown as DataToProcess;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function makeCriteria(
|
|
27
|
+
grouping: IncidentGroupingConfig | undefined,
|
|
28
|
+
id: string = "criteria-1",
|
|
29
|
+
): MonitorCriteriaInstance {
|
|
30
|
+
const criteria: MonitorCriteriaInstance = new MonitorCriteriaInstance();
|
|
31
|
+
criteria.data!.id = id;
|
|
32
|
+
criteria.data!.incidentGrouping = grouping;
|
|
33
|
+
return criteria;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Grafana / Alertmanager-shaped payloads.
|
|
37
|
+
function firingAlertmanagerPayload(): JSONObject {
|
|
38
|
+
return {
|
|
39
|
+
status: "firing",
|
|
40
|
+
alerts: [
|
|
41
|
+
{ status: "firing", labels: { alertname: "High RAM Usage" } },
|
|
42
|
+
{ status: "firing", labels: { alertname: "High CPU Usage" } },
|
|
43
|
+
{ status: "firing", labels: { alertname: "High Disk Usage" } },
|
|
44
|
+
],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("IncomingRequestIncidentGrouping", () => {
|
|
49
|
+
describe("collectFiringMatches", () => {
|
|
50
|
+
it("returns one firing match per array element (Grafana alerts[*])", () => {
|
|
51
|
+
const matches: Array<PerSeriesCriteriaMatch> =
|
|
52
|
+
IncomingRequestIncidentGrouping.collectFiringMatches({
|
|
53
|
+
dataToProcess: makeRequest(firingAlertmanagerPayload()),
|
|
54
|
+
criteriaInstance: makeCriteria({
|
|
55
|
+
groupByJSONPath: "requestBody.alerts[*].labels.alertname",
|
|
56
|
+
}),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(matches).toHaveLength(3);
|
|
60
|
+
expect(
|
|
61
|
+
matches.map((m: PerSeriesCriteriaMatch) => {
|
|
62
|
+
return m.labels;
|
|
63
|
+
}),
|
|
64
|
+
).toEqual([
|
|
65
|
+
{ alertname: "High RAM Usage" },
|
|
66
|
+
{ alertname: "High CPU Usage" },
|
|
67
|
+
{ alertname: "High Disk Usage" },
|
|
68
|
+
]);
|
|
69
|
+
// Distinct keys produce distinct fingerprints (=> concurrent incidents).
|
|
70
|
+
const fingerprints: Set<string> = new Set<string>(
|
|
71
|
+
matches.map((m: PerSeriesCriteriaMatch) => {
|
|
72
|
+
return m.fingerprint;
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
expect(fingerprints.size).toBe(3);
|
|
76
|
+
expect(
|
|
77
|
+
matches.every((m: PerSeriesCriteriaMatch) => {
|
|
78
|
+
return m.criteriaMetId === "criteria-1";
|
|
79
|
+
}),
|
|
80
|
+
).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("supports a simple (non-array) group-by path", () => {
|
|
84
|
+
const matches: Array<PerSeriesCriteriaMatch> =
|
|
85
|
+
IncomingRequestIncidentGrouping.collectFiringMatches({
|
|
86
|
+
dataToProcess: makeRequest({
|
|
87
|
+
commonLabels: { alertname: "Disk Full" },
|
|
88
|
+
}),
|
|
89
|
+
criteriaInstance: makeCriteria({
|
|
90
|
+
groupByJSONPath: "requestBody.commonLabels.alertname",
|
|
91
|
+
}),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(matches).toHaveLength(1);
|
|
95
|
+
expect(matches[0]!.labels).toEqual({ alertname: "Disk Full" });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("returns [] when grouping is not configured (legacy behaviour)", () => {
|
|
99
|
+
const matches: Array<PerSeriesCriteriaMatch> =
|
|
100
|
+
IncomingRequestIncidentGrouping.collectFiringMatches({
|
|
101
|
+
dataToProcess: makeRequest(firingAlertmanagerPayload()),
|
|
102
|
+
criteriaInstance: makeCriteria(undefined),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(matches).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("returns [] on the heartbeat-timeout re-evaluation (no fresh body)", () => {
|
|
109
|
+
const matches: Array<PerSeriesCriteriaMatch> =
|
|
110
|
+
IncomingRequestIncidentGrouping.collectFiringMatches({
|
|
111
|
+
dataToProcess: makeRequest(firingAlertmanagerPayload(), {
|
|
112
|
+
onlyCheckForIncomingRequestReceivedAt: true,
|
|
113
|
+
}),
|
|
114
|
+
criteriaInstance: makeCriteria({
|
|
115
|
+
groupByJSONPath: "requestBody.alerts[*].labels.alertname",
|
|
116
|
+
}),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(matches).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("parses a raw JSON string body", () => {
|
|
123
|
+
const matches: Array<PerSeriesCriteriaMatch> =
|
|
124
|
+
IncomingRequestIncidentGrouping.collectFiringMatches({
|
|
125
|
+
dataToProcess: makeRequest(
|
|
126
|
+
JSON.stringify({ commonLabels: { alertname: "Stringified" } }),
|
|
127
|
+
),
|
|
128
|
+
criteriaInstance: makeCriteria({
|
|
129
|
+
groupByJSONPath: "requestBody.commonLabels.alertname",
|
|
130
|
+
}),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(matches).toHaveLength(1);
|
|
134
|
+
expect(matches[0]!.labels).toEqual({ alertname: "Stringified" });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("excludes elements classified as resolved (per-element status)", () => {
|
|
138
|
+
const payload: JSONObject = {
|
|
139
|
+
status: "firing",
|
|
140
|
+
alerts: [
|
|
141
|
+
{ status: "firing", labels: { alertname: "Still Firing" } },
|
|
142
|
+
{ status: "resolved", labels: { alertname: "Recovered" } },
|
|
143
|
+
],
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const matches: Array<PerSeriesCriteriaMatch> =
|
|
147
|
+
IncomingRequestIncidentGrouping.collectFiringMatches({
|
|
148
|
+
dataToProcess: makeRequest(payload),
|
|
149
|
+
criteriaInstance: makeCriteria({
|
|
150
|
+
groupByJSONPath: "requestBody.alerts[*].labels.alertname",
|
|
151
|
+
resolvedWhenJSONPath: "requestBody.alerts[*].status",
|
|
152
|
+
resolvedWhenValue: "resolved",
|
|
153
|
+
}),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
expect(matches).toHaveLength(1);
|
|
157
|
+
expect(matches[0]!.labels).toEqual({ alertname: "Still Firing" });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("excludes everything when the whole payload is resolved (payload-level status)", () => {
|
|
161
|
+
const payload: JSONObject = {
|
|
162
|
+
status: "resolved",
|
|
163
|
+
commonLabels: { alertname: "High CPU Usage" },
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const matches: Array<PerSeriesCriteriaMatch> =
|
|
167
|
+
IncomingRequestIncidentGrouping.collectFiringMatches({
|
|
168
|
+
dataToProcess: makeRequest(payload),
|
|
169
|
+
criteriaInstance: makeCriteria({
|
|
170
|
+
groupByJSONPath: "requestBody.commonLabels.alertname",
|
|
171
|
+
resolvedWhenJSONPath: "requestBody.status",
|
|
172
|
+
resolvedWhenValue: "resolved",
|
|
173
|
+
}),
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
expect(matches).toEqual([]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("dedupes the same key appearing twice in one payload", () => {
|
|
180
|
+
const payload: JSONObject = {
|
|
181
|
+
alerts: [
|
|
182
|
+
{ labels: { alertname: "Dup" } },
|
|
183
|
+
{ labels: { alertname: "Dup" } },
|
|
184
|
+
],
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const matches: Array<PerSeriesCriteriaMatch> =
|
|
188
|
+
IncomingRequestIncidentGrouping.collectFiringMatches({
|
|
189
|
+
dataToProcess: makeRequest(payload),
|
|
190
|
+
criteriaInstance: makeCriteria({
|
|
191
|
+
groupByJSONPath: "requestBody.alerts[*].labels.alertname",
|
|
192
|
+
}),
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
expect(matches).toHaveLength(1);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("caps the number of keys at maxKeysPerPayload", () => {
|
|
199
|
+
const payload: JSONObject = {
|
|
200
|
+
alerts: Array.from({ length: 10 }, (_unused: unknown, i: number) => {
|
|
201
|
+
return { labels: { alertname: `alert-${i}` } };
|
|
202
|
+
}),
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const matches: Array<PerSeriesCriteriaMatch> =
|
|
206
|
+
IncomingRequestIncidentGrouping.collectFiringMatches({
|
|
207
|
+
dataToProcess: makeRequest(payload),
|
|
208
|
+
criteriaInstance: makeCriteria({
|
|
209
|
+
groupByJSONPath: "requestBody.alerts[*].labels.alertname",
|
|
210
|
+
maxKeysPerPayload: 3,
|
|
211
|
+
}),
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
expect(matches).toHaveLength(3);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("collectResolvedFingerprints", () => {
|
|
219
|
+
it("returns the fingerprints the payload marks resolved", () => {
|
|
220
|
+
const payload: JSONObject = {
|
|
221
|
+
alerts: [
|
|
222
|
+
{ status: "firing", labels: { alertname: "Still Firing" } },
|
|
223
|
+
{ status: "resolved", labels: { alertname: "Recovered" } },
|
|
224
|
+
],
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const grouping: IncidentGroupingConfig = {
|
|
228
|
+
groupByJSONPath: "requestBody.alerts[*].labels.alertname",
|
|
229
|
+
resolvedWhenJSONPath: "requestBody.alerts[*].status",
|
|
230
|
+
resolvedWhenValue: "resolved",
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const resolved: Array<string> =
|
|
234
|
+
IncomingRequestIncidentGrouping.collectResolvedFingerprints({
|
|
235
|
+
dataToProcess: makeRequest(payload),
|
|
236
|
+
criteriaInstances: [makeCriteria(grouping)],
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
expect(resolved).toHaveLength(1);
|
|
240
|
+
|
|
241
|
+
/*
|
|
242
|
+
* The resolved fingerprint must equal the firing fingerprint for the
|
|
243
|
+
* same key — otherwise resolution would target the wrong incident.
|
|
244
|
+
*/
|
|
245
|
+
const firingForRecovered: Array<PerSeriesCriteriaMatch> =
|
|
246
|
+
IncomingRequestIncidentGrouping.collectFiringMatches({
|
|
247
|
+
dataToProcess: makeRequest({
|
|
248
|
+
alerts: [{ status: "firing", labels: { alertname: "Recovered" } }],
|
|
249
|
+
}),
|
|
250
|
+
criteriaInstance: makeCriteria(grouping),
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
expect(resolved[0]).toBe(firingForRecovered[0]!.fingerprint);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("returns [] when no resolve classifier is configured", () => {
|
|
257
|
+
const resolved: Array<string> =
|
|
258
|
+
IncomingRequestIncidentGrouping.collectResolvedFingerprints({
|
|
259
|
+
dataToProcess: makeRequest({ status: "resolved" }),
|
|
260
|
+
criteriaInstances: [
|
|
261
|
+
makeCriteria({
|
|
262
|
+
groupByJSONPath: "requestBody.commonLabels.alertname",
|
|
263
|
+
}),
|
|
264
|
+
],
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
expect(resolved).toEqual([]);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe("isGroupingConfigured", () => {
|
|
272
|
+
it("is true only when a group-by path is set", () => {
|
|
273
|
+
expect(
|
|
274
|
+
IncomingRequestIncidentGrouping.isGroupingConfigured(
|
|
275
|
+
makeCriteria({
|
|
276
|
+
groupByJSONPath: "requestBody.commonLabels.alertname",
|
|
277
|
+
}),
|
|
278
|
+
),
|
|
279
|
+
).toBe(true);
|
|
280
|
+
expect(
|
|
281
|
+
IncomingRequestIncidentGrouping.isGroupingConfigured(
|
|
282
|
+
makeCriteria(undefined),
|
|
283
|
+
),
|
|
284
|
+
).toBe(false);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
describe("array-of-scalars fan-out (`prefix[*]`)", () => {
|
|
289
|
+
it("fans out one match per scalar array element", () => {
|
|
290
|
+
const matches: Array<PerSeriesCriteriaMatch> =
|
|
291
|
+
IncomingRequestIncidentGrouping.collectFiringMatches({
|
|
292
|
+
dataToProcess: makeRequest({ tags: ["RAM", "CPU", "Disk"] }),
|
|
293
|
+
criteriaInstance: makeCriteria({
|
|
294
|
+
groupByJSONPath: "requestBody.tags[*]",
|
|
295
|
+
}),
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
expect(matches).toHaveLength(3);
|
|
299
|
+
expect(
|
|
300
|
+
matches.map((m: PerSeriesCriteriaMatch) => {
|
|
301
|
+
return m.labels;
|
|
302
|
+
}),
|
|
303
|
+
).toEqual([{ tags: "RAM" }, { tags: "CPU" }, { tags: "Disk" }]);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
describe("requestBody-rooted convention (matches incident templates)", () => {
|
|
308
|
+
it("accepts the `{{requestBody. … }}` template wrapper", () => {
|
|
309
|
+
const matches: Array<PerSeriesCriteriaMatch> =
|
|
310
|
+
IncomingRequestIncidentGrouping.collectFiringMatches({
|
|
311
|
+
dataToProcess: makeRequest({
|
|
312
|
+
commonLabels: { alertname: "Disk Full" },
|
|
313
|
+
}),
|
|
314
|
+
criteriaInstance: makeCriteria({
|
|
315
|
+
groupByJSONPath: "{{requestBody.commonLabels.alertname}}",
|
|
316
|
+
}),
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
expect(matches).toHaveLength(1);
|
|
320
|
+
expect(matches[0]!.labels).toEqual({ alertname: "Disk Full" });
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("ignores a bare path that is not rooted at requestBody (not supported)", () => {
|
|
324
|
+
const matches: Array<PerSeriesCriteriaMatch> =
|
|
325
|
+
IncomingRequestIncidentGrouping.collectFiringMatches({
|
|
326
|
+
dataToProcess: makeRequest(firingAlertmanagerPayload()),
|
|
327
|
+
criteriaInstance: makeCriteria({
|
|
328
|
+
groupByJSONPath: "alerts[*].labels.alertname",
|
|
329
|
+
}),
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
expect(matches).toEqual([]);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("the plain and `{{ }}`-wrapped forms produce identical fingerprints", () => {
|
|
336
|
+
const plain: Array<PerSeriesCriteriaMatch> =
|
|
337
|
+
IncomingRequestIncidentGrouping.collectFiringMatches({
|
|
338
|
+
dataToProcess: makeRequest(firingAlertmanagerPayload()),
|
|
339
|
+
criteriaInstance: makeCriteria({
|
|
340
|
+
groupByJSONPath: "requestBody.alerts[*].labels.alertname",
|
|
341
|
+
}),
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const wrapped: Array<PerSeriesCriteriaMatch> =
|
|
345
|
+
IncomingRequestIncidentGrouping.collectFiringMatches({
|
|
346
|
+
dataToProcess: makeRequest(firingAlertmanagerPayload()),
|
|
347
|
+
criteriaInstance: makeCriteria({
|
|
348
|
+
groupByJSONPath: "{{requestBody.alerts[*].labels.alertname}}",
|
|
349
|
+
}),
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
expect(
|
|
353
|
+
wrapped.map((m: PerSeriesCriteriaMatch) => {
|
|
354
|
+
return m.fingerprint;
|
|
355
|
+
}),
|
|
356
|
+
).toEqual(
|
|
357
|
+
plain.map((m: PerSeriesCriteriaMatch) => {
|
|
358
|
+
return m.fingerprint;
|
|
359
|
+
}),
|
|
360
|
+
);
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
describe("mixed-mode resolve config (resolve has [*], group-by does not)", () => {
|
|
365
|
+
it("does not silently mis-scope a [*] resolve path onto a top-level field", () => {
|
|
366
|
+
/*
|
|
367
|
+
* Self-contradictory config: group-by is non-array but resolve uses [*].
|
|
368
|
+
* The top-level `status: resolved` must NOT be treated as a per-element
|
|
369
|
+
* resolve — otherwise this single key would be wrongly classified.
|
|
370
|
+
*/
|
|
371
|
+
const matches: Array<PerSeriesCriteriaMatch> =
|
|
372
|
+
IncomingRequestIncidentGrouping.collectFiringMatches({
|
|
373
|
+
dataToProcess: makeRequest({
|
|
374
|
+
status: "resolved",
|
|
375
|
+
commonLabels: { alertname: "High CPU Usage" },
|
|
376
|
+
}),
|
|
377
|
+
criteriaInstance: makeCriteria({
|
|
378
|
+
groupByJSONPath: "requestBody.commonLabels.alertname",
|
|
379
|
+
resolvedWhenJSONPath: "requestBody.alerts[*].status",
|
|
380
|
+
resolvedWhenValue: "resolved",
|
|
381
|
+
}),
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
// Treated as firing (not mis-scoped to top-level status) ⇒ stays open.
|
|
385
|
+
expect(matches).toHaveLength(1);
|
|
386
|
+
expect(matches[0]!.labels).toEqual({ alertname: "High CPU Usage" });
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import MonitorIncident from "../../../../Server/Utils/Monitor/MonitorIncident";
|
|
2
|
+
import MonitorAlert from "../../../../Server/Utils/Monitor/MonitorAlert";
|
|
3
|
+
import Incident from "../../../../Models/DatabaseModels/Incident";
|
|
4
|
+
import Alert from "../../../../Models/DatabaseModels/Alert";
|
|
5
|
+
import MonitorCriteriaInstance from "../../../../Types/Monitor/MonitorCriteriaInstance";
|
|
6
|
+
import Dictionary from "../../../../Types/Dictionary";
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
* Regression tests for the event-driven (incoming-request / webhook)
|
|
10
|
+
* absence-resolution guard. A per-key incident/alert (one carrying a
|
|
11
|
+
* seriesFingerprint, created by a grouped incoming-request criteria) must
|
|
12
|
+
* NEVER be auto-resolved by absence — only explicitly via
|
|
13
|
+
* resolveSeries{Incidents,Alerts}ByFingerprint when the payload reports the
|
|
14
|
+
* key as recovered. shouldClose{Incident,Alert} is private; we exercise it
|
|
15
|
+
* via the documented `as any` escape hatch since it is the single decision
|
|
16
|
+
* point both the criteria-met and no-criteria-met resolve paths funnel
|
|
17
|
+
* through.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
function incident(fingerprint: string | undefined): Incident {
|
|
21
|
+
const i: Incident = new Incident();
|
|
22
|
+
i.createdCriteriaId = "criteria-A";
|
|
23
|
+
i.createdIncidentTemplateId = "template-1";
|
|
24
|
+
if (fingerprint) {
|
|
25
|
+
i.seriesFingerprint = fingerprint;
|
|
26
|
+
}
|
|
27
|
+
return i;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function alert(fingerprint: string | undefined): Alert {
|
|
31
|
+
const a: Alert = new Alert();
|
|
32
|
+
a.createdCriteriaId = "criteria-A";
|
|
33
|
+
if (fingerprint) {
|
|
34
|
+
a.seriesFingerprint = fingerprint;
|
|
35
|
+
}
|
|
36
|
+
return a;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function criteria(id: string): MonitorCriteriaInstance {
|
|
40
|
+
const c: MonitorCriteriaInstance = new MonitorCriteriaInstance();
|
|
41
|
+
c.data!.id = id;
|
|
42
|
+
return c;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const INCIDENT_AUTO_RESOLVE: Dictionary<Array<string>> = {
|
|
46
|
+
"criteria-A": ["template-1"],
|
|
47
|
+
};
|
|
48
|
+
const ALERT_AUTO_RESOLVE: Dictionary<Array<string>> = {
|
|
49
|
+
"criteria-A": ["template-1"],
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function shouldCloseIncident(input: Record<string, unknown>): boolean {
|
|
53
|
+
return (MonitorIncident as any).shouldCloseIncident(input);
|
|
54
|
+
}
|
|
55
|
+
function shouldCloseAlert(input: Record<string, unknown>): boolean {
|
|
56
|
+
return (MonitorAlert as any).shouldCloseAlert(input);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe("Series absence-resolution guard (incoming-request grouping)", () => {
|
|
60
|
+
describe("MonitorIncident.shouldCloseIncident", () => {
|
|
61
|
+
it("does NOT absence-resolve a per-key incident on the no-criteria-met path when the guard is on (HIGH)", () => {
|
|
62
|
+
expect(
|
|
63
|
+
shouldCloseIncident({
|
|
64
|
+
openIncident: incident("fp-RAM"),
|
|
65
|
+
autoResolveCriteriaInstanceIdIncidentIdsDictionary:
|
|
66
|
+
INCIDENT_AUTO_RESOLVE,
|
|
67
|
+
criteriaInstance: null, // no criteria met (e.g. heartbeat cron tick)
|
|
68
|
+
breachingSeriesFingerprints: undefined,
|
|
69
|
+
disableSeriesAbsenceResolution: true,
|
|
70
|
+
}),
|
|
71
|
+
).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("WOULD absence-resolve it without the guard — proving the guard is load-bearing", () => {
|
|
75
|
+
expect(
|
|
76
|
+
shouldCloseIncident({
|
|
77
|
+
openIncident: incident("fp-RAM"),
|
|
78
|
+
autoResolveCriteriaInstanceIdIncidentIdsDictionary:
|
|
79
|
+
INCIDENT_AUTO_RESOLVE,
|
|
80
|
+
criteriaInstance: null,
|
|
81
|
+
breachingSeriesFingerprints: undefined,
|
|
82
|
+
disableSeriesAbsenceResolution: false,
|
|
83
|
+
}),
|
|
84
|
+
).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("does NOT cross-criteria absence-resolve a per-key incident when the guard is on (MEDIUM)", () => {
|
|
88
|
+
expect(
|
|
89
|
+
shouldCloseIncident({
|
|
90
|
+
openIncident: incident("fp-RAM"), // created by criteria-A
|
|
91
|
+
autoResolveCriteriaInstanceIdIncidentIdsDictionary:
|
|
92
|
+
INCIDENT_AUTO_RESOLVE,
|
|
93
|
+
criteriaInstance: criteria("criteria-B"), // a DIFFERENT criteria fired
|
|
94
|
+
breachingSeriesFingerprints: undefined,
|
|
95
|
+
disableSeriesAbsenceResolution: true,
|
|
96
|
+
}),
|
|
97
|
+
).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("still resolves a NON-grouped incoming incident (no seriesFingerprint) — guard is inert", () => {
|
|
101
|
+
expect(
|
|
102
|
+
shouldCloseIncident({
|
|
103
|
+
openIncident: incident(undefined),
|
|
104
|
+
autoResolveCriteriaInstanceIdIncidentIdsDictionary:
|
|
105
|
+
INCIDENT_AUTO_RESOLVE,
|
|
106
|
+
criteriaInstance: null,
|
|
107
|
+
breachingSeriesFingerprints: undefined,
|
|
108
|
+
disableSeriesAbsenceResolution: true,
|
|
109
|
+
}),
|
|
110
|
+
).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("does not change metric per-series behaviour (guard off, snapshot absence-resolve still works)", () => {
|
|
114
|
+
// Fingerprint not in the still-breaching set + auto-resolve configured ⇒ resolve.
|
|
115
|
+
expect(
|
|
116
|
+
shouldCloseIncident({
|
|
117
|
+
openIncident: incident("fp-host-2"),
|
|
118
|
+
autoResolveCriteriaInstanceIdIncidentIdsDictionary:
|
|
119
|
+
INCIDENT_AUTO_RESOLVE,
|
|
120
|
+
criteriaInstance: criteria("criteria-A"),
|
|
121
|
+
breachingSeriesFingerprints: new Set<string>(["fp-host-1"]),
|
|
122
|
+
disableSeriesAbsenceResolution: false,
|
|
123
|
+
}),
|
|
124
|
+
).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("MonitorAlert.shouldCloseAlert", () => {
|
|
129
|
+
it("does NOT absence-resolve a per-key alert on the no-criteria-met path when the guard is on", () => {
|
|
130
|
+
expect(
|
|
131
|
+
shouldCloseAlert({
|
|
132
|
+
openAlert: alert("fp-RAM"),
|
|
133
|
+
autoResolveCriteriaInstanceIdAlertIdsDictionary: ALERT_AUTO_RESOLVE,
|
|
134
|
+
criteriaInstance: null,
|
|
135
|
+
breachingSeriesFingerprints: undefined,
|
|
136
|
+
disableSeriesAbsenceResolution: true,
|
|
137
|
+
}),
|
|
138
|
+
).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("WOULD absence-resolve it without the guard", () => {
|
|
142
|
+
expect(
|
|
143
|
+
shouldCloseAlert({
|
|
144
|
+
openAlert: alert("fp-RAM"),
|
|
145
|
+
autoResolveCriteriaInstanceIdAlertIdsDictionary: ALERT_AUTO_RESOLVE,
|
|
146
|
+
criteriaInstance: null,
|
|
147
|
+
breachingSeriesFingerprints: undefined,
|
|
148
|
+
disableSeriesAbsenceResolution: false,
|
|
149
|
+
}),
|
|
150
|
+
).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("does NOT cross-criteria absence-resolve a per-key alert when the guard is on", () => {
|
|
154
|
+
expect(
|
|
155
|
+
shouldCloseAlert({
|
|
156
|
+
openAlert: alert("fp-RAM"),
|
|
157
|
+
autoResolveCriteriaInstanceIdAlertIdsDictionary: ALERT_AUTO_RESOLVE,
|
|
158
|
+
criteriaInstance: criteria("criteria-B"),
|
|
159
|
+
breachingSeriesFingerprints: undefined,
|
|
160
|
+
disableSeriesAbsenceResolution: true,
|
|
161
|
+
}),
|
|
162
|
+
).toBe(false);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("still resolves a NON-grouped incoming alert (no seriesFingerprint) — guard is inert", () => {
|
|
166
|
+
expect(
|
|
167
|
+
shouldCloseAlert({
|
|
168
|
+
openAlert: alert(undefined),
|
|
169
|
+
autoResolveCriteriaInstanceIdAlertIdsDictionary: ALERT_AUTO_RESOLVE,
|
|
170
|
+
criteriaInstance: null,
|
|
171
|
+
breachingSeriesFingerprints: undefined,
|
|
172
|
+
disableSeriesAbsenceResolution: true,
|
|
173
|
+
}),
|
|
174
|
+
).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
});
|