@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,428 @@
|
|
|
1
|
+
import logger from "../Logger";
|
|
2
|
+
import VMUtil from "../VM/VMAPI";
|
|
3
|
+
import MetricSeriesFingerprint from "../../../Utils/Metrics/MetricSeriesFingerprint";
|
|
4
|
+
import DataToProcess from "./DataToProcess";
|
|
5
|
+
import IncomingMonitorRequest from "../../../Types/Monitor/IncomingMonitor/IncomingMonitorRequest";
|
|
6
|
+
import IncidentGroupingConfig from "../../../Types/Monitor/IncomingMonitor/IncidentGroupingConfig";
|
|
7
|
+
import MonitorCriteriaInstance from "../../../Types/Monitor/MonitorCriteriaInstance";
|
|
8
|
+
import { PerSeriesCriteriaMatch } from "../../../Types/Probe/ProbeApiIngestResponse";
|
|
9
|
+
import { JSONObject, JSONValue } from "../../../Types/JSON";
|
|
10
|
+
import Typeof from "../../../Types/Typeof";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One key extracted from an incoming webhook payload by an
|
|
14
|
+
* {@link IncidentGroupingConfig}.
|
|
15
|
+
*/
|
|
16
|
+
export interface IncomingRequestGroupingItem {
|
|
17
|
+
/** Stable dedupe key derived from the extracted value. */
|
|
18
|
+
fingerprint: string;
|
|
19
|
+
/** Label map exposed to incident title/description templates. */
|
|
20
|
+
labels: JSONObject;
|
|
21
|
+
/** The raw extracted value (e.g. the Grafana alert name). */
|
|
22
|
+
keyValue: string;
|
|
23
|
+
/** True when the payload classifies this key as resolved/recovered. */
|
|
24
|
+
isResolved: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const DEFAULT_MAX_KEYS_PER_PAYLOAD: number = 100;
|
|
28
|
+
const ARRAY_WILDCARD: string = "[*]";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Turns an incoming-request / webhook payload into per-key incident
|
|
32
|
+
* matches, so a single monitor can hold multiple concurrent incidents —
|
|
33
|
+
* one per distinct value (Grafana alert name, fingerprint, etc.). This
|
|
34
|
+
* reuses the same `seriesFingerprint` mechanism metric monitors use for
|
|
35
|
+
* per-series incidents; see {@link IncidentGroupingConfig}.
|
|
36
|
+
*/
|
|
37
|
+
export default class IncomingRequestIncidentGrouping {
|
|
38
|
+
/**
|
|
39
|
+
* Parse the request body into a JSON object. Bodies arrive either
|
|
40
|
+
* pre-parsed (JSONObject) or as a raw JSON string depending on the
|
|
41
|
+
* ingestion path; both are handled. Non-JSON / non-object bodies
|
|
42
|
+
* (e.g. a bare string heartbeat) return null — grouping is a no-op.
|
|
43
|
+
*/
|
|
44
|
+
public static getRequestBodyObject(
|
|
45
|
+
dataToProcess: DataToProcess,
|
|
46
|
+
): JSONObject | null {
|
|
47
|
+
const body: string | JSONObject | undefined = (
|
|
48
|
+
dataToProcess as IncomingMonitorRequest
|
|
49
|
+
).requestBody;
|
|
50
|
+
|
|
51
|
+
if (!body) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (typeof body === Typeof.String) {
|
|
56
|
+
try {
|
|
57
|
+
const parsed: unknown = JSON.parse(body as string);
|
|
58
|
+
return parsed &&
|
|
59
|
+
typeof parsed === Typeof.Object &&
|
|
60
|
+
!Array.isArray(parsed)
|
|
61
|
+
? (parsed as JSONObject)
|
|
62
|
+
: null;
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (typeof body === Typeof.Object && !Array.isArray(body)) {
|
|
69
|
+
return body as JSONObject;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Firing matches for one criteria — used by the criteria evaluator to
|
|
77
|
+
* fan a matched incoming-request criteria into one incident per key.
|
|
78
|
+
* Returns an empty array when grouping is not configured (preserving
|
|
79
|
+
* the legacy single-incident behaviour) or when there is no usable
|
|
80
|
+
* payload (e.g. the heartbeat-timeout re-evaluation cron, which carries
|
|
81
|
+
* no fresh body).
|
|
82
|
+
*/
|
|
83
|
+
public static collectFiringMatches(input: {
|
|
84
|
+
dataToProcess: DataToProcess;
|
|
85
|
+
criteriaInstance: MonitorCriteriaInstance;
|
|
86
|
+
rootCause?: string | undefined;
|
|
87
|
+
}): Array<PerSeriesCriteriaMatch> {
|
|
88
|
+
const grouping: IncidentGroupingConfig | undefined =
|
|
89
|
+
input.criteriaInstance.data?.incidentGrouping;
|
|
90
|
+
|
|
91
|
+
if (!grouping?.groupByJSONPath) {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (
|
|
96
|
+
(input.dataToProcess as IncomingMonitorRequest)
|
|
97
|
+
.onlyCheckForIncomingRequestReceivedAt
|
|
98
|
+
) {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const criteriaId: string | undefined = input.criteriaInstance.data?.id;
|
|
103
|
+
if (!criteriaId) {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const body: JSONObject | null = this.getRequestBodyObject(
|
|
108
|
+
input.dataToProcess,
|
|
109
|
+
);
|
|
110
|
+
if (!body) {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const matches: Array<PerSeriesCriteriaMatch> = [];
|
|
115
|
+
|
|
116
|
+
for (const item of this.extractItems({ requestBody: body, grouping })) {
|
|
117
|
+
if (item.isResolved) {
|
|
118
|
+
// A resolved event never opens an incident.
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
matches.push({
|
|
123
|
+
criteriaMetId: criteriaId,
|
|
124
|
+
fingerprint: item.fingerprint,
|
|
125
|
+
labels: item.labels,
|
|
126
|
+
rootCause:
|
|
127
|
+
input.rootCause ||
|
|
128
|
+
`Incoming request matched grouping key \`${item.keyValue}\`.`,
|
|
129
|
+
metricContext: undefined,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return matches;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Fingerprints the payload explicitly classifies as resolved, across
|
|
138
|
+
* every grouping-enabled criteria on the monitor's step. The caller
|
|
139
|
+
* resolves the matching open incidents (event-driven resolution: only
|
|
140
|
+
* keys the payload says recovered are closed — never by absence).
|
|
141
|
+
*/
|
|
142
|
+
public static collectResolvedFingerprints(input: {
|
|
143
|
+
dataToProcess: DataToProcess;
|
|
144
|
+
criteriaInstances: Array<MonitorCriteriaInstance>;
|
|
145
|
+
}): Array<string> {
|
|
146
|
+
if (
|
|
147
|
+
(input.dataToProcess as IncomingMonitorRequest)
|
|
148
|
+
.onlyCheckForIncomingRequestReceivedAt
|
|
149
|
+
) {
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const body: JSONObject | null = this.getRequestBodyObject(
|
|
154
|
+
input.dataToProcess,
|
|
155
|
+
);
|
|
156
|
+
if (!body) {
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const fingerprints: Set<string> = new Set<string>();
|
|
161
|
+
|
|
162
|
+
for (const criteriaInstance of input.criteriaInstances) {
|
|
163
|
+
const grouping: IncidentGroupingConfig | undefined =
|
|
164
|
+
criteriaInstance.data?.incidentGrouping;
|
|
165
|
+
|
|
166
|
+
if (!grouping?.groupByJSONPath) {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
for (const item of this.extractItems({ requestBody: body, grouping })) {
|
|
171
|
+
if (item.isResolved) {
|
|
172
|
+
fingerprints.add(item.fingerprint);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return Array.from(fingerprints);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Whether any criteria on the step has grouping configured. Used to
|
|
182
|
+
* force per-series mode so a grouped criteria never falls back to a
|
|
183
|
+
* single whole-monitor incident on a payload that yields no firing key.
|
|
184
|
+
*/
|
|
185
|
+
public static isGroupingConfigured(
|
|
186
|
+
criteriaInstance: MonitorCriteriaInstance | undefined,
|
|
187
|
+
): boolean {
|
|
188
|
+
return Boolean(criteriaInstance?.data?.incidentGrouping?.groupByJSONPath);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Extract all distinct keys from a payload for one grouping config,
|
|
193
|
+
* classifying each as firing or resolved. Deduplicates by fingerprint
|
|
194
|
+
* and caps the count at `maxKeysPerPayload` to bound cardinality.
|
|
195
|
+
*/
|
|
196
|
+
public static extractItems(input: {
|
|
197
|
+
requestBody: JSONObject;
|
|
198
|
+
grouping: IncidentGroupingConfig;
|
|
199
|
+
}): Array<IncomingRequestGroupingItem> {
|
|
200
|
+
const { requestBody, grouping } = input;
|
|
201
|
+
|
|
202
|
+
const groupByPath: string = this.normalizePath(grouping.groupByJSONPath);
|
|
203
|
+
if (!groupByPath) {
|
|
204
|
+
return [];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const labelKey: string = this.labelKeyFromPath(groupByPath);
|
|
208
|
+
const maxKeys: number =
|
|
209
|
+
grouping.maxKeysPerPayload && grouping.maxKeysPerPayload > 0
|
|
210
|
+
? grouping.maxKeysPerPayload
|
|
211
|
+
: DEFAULT_MAX_KEYS_PER_PAYLOAD;
|
|
212
|
+
|
|
213
|
+
const items: Array<IncomingRequestGroupingItem> = [];
|
|
214
|
+
const seenFingerprints: Set<string> = new Set<string>();
|
|
215
|
+
|
|
216
|
+
const addItem: (keyValue: string, isResolved: boolean) => void = (
|
|
217
|
+
keyValue: string,
|
|
218
|
+
isResolved: boolean,
|
|
219
|
+
): void => {
|
|
220
|
+
const labels: JSONObject = { [labelKey]: keyValue };
|
|
221
|
+
const fingerprint: string =
|
|
222
|
+
MetricSeriesFingerprint.computeFingerprint(labels);
|
|
223
|
+
|
|
224
|
+
if (seenFingerprints.has(fingerprint)) {
|
|
225
|
+
// Same key twice in one payload — keep the first occurrence.
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
seenFingerprints.add(fingerprint);
|
|
230
|
+
items.push({ fingerprint, labels, keyValue, isResolved });
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const wildcardIndex: number = groupByPath.indexOf(ARRAY_WILDCARD);
|
|
234
|
+
|
|
235
|
+
// Simple (non-array) path: a single key for the whole payload.
|
|
236
|
+
if (wildcardIndex === -1) {
|
|
237
|
+
const keyValue: string | null = this.toScalarString(
|
|
238
|
+
VMUtil.deepFind(requestBody, groupByPath),
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
if (keyValue === null) {
|
|
242
|
+
return [];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
addItem(
|
|
246
|
+
keyValue,
|
|
247
|
+
this.isResolvedForScope(requestBody, requestBody, grouping, false),
|
|
248
|
+
);
|
|
249
|
+
return items;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Array fan-out: one key per element of the array at the `[*]` prefix.
|
|
253
|
+
const prefixPath: string = groupByPath
|
|
254
|
+
.slice(0, wildcardIndex)
|
|
255
|
+
.replace(/\.$/, "");
|
|
256
|
+
const elementSuffix: string = this.stripLeadingDot(
|
|
257
|
+
groupByPath.slice(wildcardIndex + ARRAY_WILDCARD.length),
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
const arrayValue: JSONValue | undefined = prefixPath
|
|
261
|
+
? VMUtil.deepFind(requestBody, prefixPath)
|
|
262
|
+
: (requestBody as unknown as JSONValue);
|
|
263
|
+
|
|
264
|
+
if (!Array.isArray(arrayValue)) {
|
|
265
|
+
return [];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
for (const element of arrayValue) {
|
|
269
|
+
if (items.length >= maxKeys) {
|
|
270
|
+
logger.warn(
|
|
271
|
+
`IncomingRequestIncidentGrouping: payload produced more than ${maxKeys} keys for path "${groupByPath}"; remaining keys ignored.`,
|
|
272
|
+
);
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const isObjectElement: boolean =
|
|
277
|
+
Boolean(element) && typeof element === Typeof.Object;
|
|
278
|
+
|
|
279
|
+
let keyValue: string | null;
|
|
280
|
+
|
|
281
|
+
if (elementSuffix) {
|
|
282
|
+
// `prefix[*].sub.path` — element must be an object to dig into.
|
|
283
|
+
if (!isObjectElement) {
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
keyValue = this.toScalarString(
|
|
287
|
+
VMUtil.deepFind(element as JSONObject, elementSuffix),
|
|
288
|
+
);
|
|
289
|
+
} else {
|
|
290
|
+
// `prefix[*]` — the array element itself is the key (array of scalars).
|
|
291
|
+
keyValue = this.toScalarString(element as JSONValue);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (keyValue === null) {
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/*
|
|
299
|
+
* Per-element resolve classification only applies when the element
|
|
300
|
+
* is an object (so `[*].status` can be read off it). For scalar
|
|
301
|
+
* elements there is no per-element field, so fall back to root scope.
|
|
302
|
+
*/
|
|
303
|
+
addItem(
|
|
304
|
+
keyValue,
|
|
305
|
+
this.isResolvedForScope(
|
|
306
|
+
isObjectElement ? (element as JSONObject) : requestBody,
|
|
307
|
+
requestBody,
|
|
308
|
+
grouping,
|
|
309
|
+
isObjectElement,
|
|
310
|
+
),
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return items;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Classify whether the configured resolve condition holds. When the
|
|
319
|
+
* resolve path uses `[*]` AND we are evaluating a specific array element
|
|
320
|
+
* (`isElementContext`), it is read off that element (`elementScope`).
|
|
321
|
+
* Otherwise it is evaluated against the whole payload (`rootScope`).
|
|
322
|
+
*
|
|
323
|
+
* If the resolve path uses `[*]` but the group-by path does NOT (so we
|
|
324
|
+
* are not in element context — a self-contradictory config), the path is
|
|
325
|
+
* evaluated verbatim against the root: `deepFind` does not interpret
|
|
326
|
+
* `[*]`, so it returns undefined (not resolved) rather than silently
|
|
327
|
+
* rebinding to a same-named top-level field.
|
|
328
|
+
*/
|
|
329
|
+
private static isResolvedForScope(
|
|
330
|
+
elementScope: JSONObject,
|
|
331
|
+
rootScope: JSONObject,
|
|
332
|
+
grouping: IncidentGroupingConfig,
|
|
333
|
+
isElementContext: boolean,
|
|
334
|
+
): boolean {
|
|
335
|
+
if (
|
|
336
|
+
!grouping.resolvedWhenJSONPath ||
|
|
337
|
+
grouping.resolvedWhenValue === undefined
|
|
338
|
+
) {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const resolvedPath: string = this.normalizePath(
|
|
343
|
+
grouping.resolvedWhenJSONPath,
|
|
344
|
+
);
|
|
345
|
+
const wildcardIndex: number = resolvedPath.indexOf(ARRAY_WILDCARD);
|
|
346
|
+
|
|
347
|
+
let scope: JSONObject = rootScope;
|
|
348
|
+
let path: string = resolvedPath;
|
|
349
|
+
|
|
350
|
+
if (wildcardIndex !== -1 && isElementContext) {
|
|
351
|
+
scope = elementScope;
|
|
352
|
+
path = this.stripLeadingDot(
|
|
353
|
+
resolvedPath.slice(wildcardIndex + ARRAY_WILDCARD.length),
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (!path) {
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const value: string | null = this.toScalarString(
|
|
362
|
+
VMUtil.deepFind(scope, path),
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
return value !== null && value === grouping.resolvedWhenValue;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Derive a human-friendly label key from a JSON path — the last named
|
|
370
|
+
* segment, with array/wildcard syntax stripped. `alerts[*].labels.alertname`
|
|
371
|
+
* → `alertname`. Used as the template variable name (so titles can say
|
|
372
|
+
* `{{alertname}}`) and as the fingerprint label key.
|
|
373
|
+
*/
|
|
374
|
+
private static labelKeyFromPath(path: string): string {
|
|
375
|
+
const cleaned: string = path
|
|
376
|
+
.replace(/\[\*\]/g, "")
|
|
377
|
+
.replace(/\[[^\]]*\]/g, "");
|
|
378
|
+
const parts: Array<string> = cleaned.split(".").filter(Boolean);
|
|
379
|
+
return parts.length > 0 ? parts[parts.length - 1]! : "key";
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private static stripLeadingDot(path: string): string {
|
|
383
|
+
return path.startsWith(".") ? path.slice(1) : path;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Resolve the `requestBody`-rooted path users write — the same convention
|
|
388
|
+
* incident templates and JavaScript-expression filters use — into a path
|
|
389
|
+
* relative to the body (the actual scope `deepFind` resolves against).
|
|
390
|
+
* Both of these forms are accepted and resolve identically:
|
|
391
|
+
*
|
|
392
|
+
* `{{requestBody.alerts[*].labels.alertname}}` (template wrapper)
|
|
393
|
+
* `requestBody.alerts[*].labels.alertname`
|
|
394
|
+
*
|
|
395
|
+
* The `requestBody` root is REQUIRED — a path not rooted there resolves
|
|
396
|
+
* to nothing, so the field has exactly one convention.
|
|
397
|
+
*/
|
|
398
|
+
private static normalizePath(rawPath: string): string {
|
|
399
|
+
let path: string = rawPath.trim();
|
|
400
|
+
|
|
401
|
+
if (path.startsWith("{{") && path.endsWith("}}")) {
|
|
402
|
+
path = path.slice(2, -2).trim();
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (path.startsWith("requestBody.")) {
|
|
406
|
+
return path.slice("requestBody.".length);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return "";
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Coerce an extracted value to a non-empty scalar string. Objects and
|
|
414
|
+
* arrays cannot be a grouping key and return null (the item is skipped).
|
|
415
|
+
*/
|
|
416
|
+
private static toScalarString(value: JSONValue | undefined): string | null {
|
|
417
|
+
if (value === undefined || value === null) {
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (typeof value === Typeof.Object) {
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const stringValue: string = String(value);
|
|
426
|
+
return stringValue.length > 0 ? stringValue : null;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
@@ -47,6 +47,14 @@ export default class MonitorAlert {
|
|
|
47
47
|
dataToProcess: DataToProcess;
|
|
48
48
|
evaluationSummary?: MonitorEvaluationSummary | undefined;
|
|
49
49
|
breachingSeriesFingerprints?: Set<string> | undefined;
|
|
50
|
+
/**
|
|
51
|
+
* Event-driven (incoming-request / webhook) mode. When true, an open
|
|
52
|
+
* alert carrying a seriesFingerprint is never auto-resolved here —
|
|
53
|
+
* webhooks resolve per-key only via resolveSeriesAlertsByFingerprint,
|
|
54
|
+
* never by absence. Passed on BOTH the criteria-met and no-criteria-met
|
|
55
|
+
* code paths for grouped incoming-request monitors.
|
|
56
|
+
*/
|
|
57
|
+
disableSeriesAbsenceResolution?: boolean | undefined;
|
|
50
58
|
}): Promise<Array<Alert>> {
|
|
51
59
|
// check active alerts and if there are open alerts, do not create another alert.
|
|
52
60
|
const openAlerts: Array<Alert> = await AlertService.findBy({
|
|
@@ -83,6 +91,7 @@ export default class MonitorAlert {
|
|
|
83
91
|
input.autoResolveCriteriaInstanceIdAlertIdsDictionary,
|
|
84
92
|
criteriaInstance: input.criteriaInstance,
|
|
85
93
|
breachingSeriesFingerprints: input.breachingSeriesFingerprints,
|
|
94
|
+
disableSeriesAbsenceResolution: input.disableSeriesAbsenceResolution,
|
|
86
95
|
});
|
|
87
96
|
|
|
88
97
|
if (shouldClose) {
|
|
@@ -110,6 +119,95 @@ export default class MonitorAlert {
|
|
|
110
119
|
return openAlerts;
|
|
111
120
|
}
|
|
112
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Event-driven (incoming-request / webhook) resolution: resolve the open
|
|
124
|
+
* alerts for the given payload-derived fingerprints — and only those —
|
|
125
|
+
* when the criteria that created them has auto-resolve enabled. Mirrors
|
|
126
|
+
* MonitorIncident.resolveSeriesIncidentsByFingerprint; never resolves by
|
|
127
|
+
* absence (a webhook describes only the keys in its payload).
|
|
128
|
+
*/
|
|
129
|
+
@CaptureSpan()
|
|
130
|
+
public static async resolveSeriesAlertsByFingerprint(input: {
|
|
131
|
+
monitor: Monitor;
|
|
132
|
+
fingerprints: Array<string>;
|
|
133
|
+
rootCause: string;
|
|
134
|
+
dataToProcess: DataToProcess;
|
|
135
|
+
autoResolveCriteriaInstanceIdAlertIdsDictionary: Dictionary<Array<string>>;
|
|
136
|
+
evaluationSummary?: MonitorEvaluationSummary | undefined;
|
|
137
|
+
}): Promise<void> {
|
|
138
|
+
if (!input.fingerprints || input.fingerprints.length === 0) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const fingerprintSet: Set<string> = new Set<string>(input.fingerprints);
|
|
143
|
+
|
|
144
|
+
const openAlerts: Array<Alert> = await AlertService.findBy({
|
|
145
|
+
query: {
|
|
146
|
+
monitor: input.monitor.id!,
|
|
147
|
+
currentAlertState: {
|
|
148
|
+
isResolvedState: false,
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
skip: 0,
|
|
152
|
+
limit: LIMIT_PER_PROJECT,
|
|
153
|
+
select: {
|
|
154
|
+
_id: true,
|
|
155
|
+
title: true,
|
|
156
|
+
createdCriteriaId: true,
|
|
157
|
+
projectId: true,
|
|
158
|
+
alertNumber: true,
|
|
159
|
+
alertNumberWithPrefix: true,
|
|
160
|
+
seriesFingerprint: true,
|
|
161
|
+
},
|
|
162
|
+
props: {
|
|
163
|
+
isRoot: true,
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
for (const openAlert of openAlerts) {
|
|
168
|
+
const fingerprint: string | undefined =
|
|
169
|
+
openAlert.seriesFingerprint || undefined;
|
|
170
|
+
|
|
171
|
+
if (!fingerprint || !fingerprintSet.has(fingerprint)) {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const createdCriteriaId: string | undefined =
|
|
176
|
+
openAlert.createdCriteriaId?.toString();
|
|
177
|
+
|
|
178
|
+
if (!createdCriteriaId) {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Only auto-resolve when the creating criteria opted into it.
|
|
183
|
+
const autoResolveTemplates: Array<string> | undefined =
|
|
184
|
+
input.autoResolveCriteriaInstanceIdAlertIdsDictionary[
|
|
185
|
+
createdCriteriaId
|
|
186
|
+
];
|
|
187
|
+
|
|
188
|
+
if (!autoResolveTemplates || autoResolveTemplates.length === 0) {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
await this.resolveOpenAlert({
|
|
193
|
+
openAlert: openAlert,
|
|
194
|
+
rootCause: input.rootCause,
|
|
195
|
+
dataToProcess: input.dataToProcess,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
input.evaluationSummary?.events.push({
|
|
199
|
+
type: "alert-resolved",
|
|
200
|
+
title: `Alert resolved: ${openAlert.id?.toString()}`,
|
|
201
|
+
message:
|
|
202
|
+
"Alert auto-resolved because the incoming payload reported this key as resolved.",
|
|
203
|
+
relatedAlertId: openAlert.id?.toString(),
|
|
204
|
+
relatedAlertNumber: openAlert.alertNumber,
|
|
205
|
+
relatedAlertNumberWithPrefix: openAlert.alertNumberWithPrefix,
|
|
206
|
+
at: OneUptimeDate.getCurrentDate(),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
113
211
|
@CaptureSpan()
|
|
114
212
|
public static async criteriaMetCreateAlertsAndUpdateMonitorStatus(input: {
|
|
115
213
|
criteriaInstance: MonitorCriteriaInstance;
|
|
@@ -129,6 +227,16 @@ export default class MonitorAlert {
|
|
|
129
227
|
* evaluating. See MonitorMaintenanceSuppression.
|
|
130
228
|
*/
|
|
131
229
|
suppressedSeriesFingerprints?: Set<string> | undefined;
|
|
230
|
+
/**
|
|
231
|
+
* Event-driven monitors (incoming-request / webhook fan-out) must not
|
|
232
|
+
* use the metric snapshot model where a series absent from this tick's
|
|
233
|
+
* breaching set is auto-resolved — a single webhook only describes the
|
|
234
|
+
* keys in that payload, so absence is not recovery. When true, the
|
|
235
|
+
* per-series absence-resolve pass is skipped; those alerts are resolved
|
|
236
|
+
* explicitly elsewhere (see IncomingRequestIncidentGrouping +
|
|
237
|
+
* resolveSeriesAlertsByFingerprint). Per-key create + dedupe still run.
|
|
238
|
+
*/
|
|
239
|
+
disableSeriesAbsenceResolution?: boolean | undefined;
|
|
132
240
|
}): Promise<void> {
|
|
133
241
|
const alertLogAttributes: LogAttributes = {
|
|
134
242
|
projectId: input.monitor.projectId?.toString(),
|
|
@@ -141,7 +249,7 @@ export default class MonitorAlert {
|
|
|
141
249
|
);
|
|
142
250
|
|
|
143
251
|
const breachingSeriesFingerprints: Set<string> | undefined =
|
|
144
|
-
input.matchesPerSeries
|
|
252
|
+
input.matchesPerSeries && !input.disableSeriesAbsenceResolution
|
|
145
253
|
? new Set<string>(
|
|
146
254
|
input.matchesPerSeries.map((m: PerSeriesCriteriaMatch) => {
|
|
147
255
|
return m.fingerprint;
|
|
@@ -160,6 +268,7 @@ export default class MonitorAlert {
|
|
|
160
268
|
dataToProcess: input.dataToProcess,
|
|
161
269
|
evaluationSummary: input.evaluationSummary,
|
|
162
270
|
breachingSeriesFingerprints,
|
|
271
|
+
disableSeriesAbsenceResolution: input.disableSeriesAbsenceResolution,
|
|
163
272
|
});
|
|
164
273
|
|
|
165
274
|
if (!input.criteriaInstance.data?.createAlerts) {
|
|
@@ -179,8 +288,15 @@ export default class MonitorAlert {
|
|
|
179
288
|
monitor: input.monitor,
|
|
180
289
|
});
|
|
181
290
|
|
|
291
|
+
/*
|
|
292
|
+
* `undefined` matchesPerSeries → legacy single-alert path. A defined
|
|
293
|
+
* (even empty) array → per-series mode: iterate exactly the matches.
|
|
294
|
+
* An empty array therefore creates nothing — used by grouped
|
|
295
|
+
* incoming-request criteria on a payload with no firing key so they
|
|
296
|
+
* don't fall back to a single whole-monitor alert.
|
|
297
|
+
*/
|
|
182
298
|
const seriesToProcess: Array<PerSeriesCriteriaMatch | undefined> =
|
|
183
|
-
input.matchesPerSeries
|
|
299
|
+
input.matchesPerSeries !== undefined
|
|
184
300
|
? input.matchesPerSeries
|
|
185
301
|
: [undefined];
|
|
186
302
|
|
|
@@ -572,12 +688,33 @@ export default class MonitorAlert {
|
|
|
572
688
|
);
|
|
573
689
|
}
|
|
574
690
|
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
691
|
+
try {
|
|
692
|
+
await AlertStateTimelineService.create({
|
|
693
|
+
data: alertStateTimeline,
|
|
694
|
+
props: {
|
|
695
|
+
isRoot: true,
|
|
696
|
+
},
|
|
697
|
+
});
|
|
698
|
+
} catch (err) {
|
|
699
|
+
/*
|
|
700
|
+
* Idempotent concurrency race: two evaluations for the same monitor
|
|
701
|
+
* (e.g. the explicit per-key resolveSeriesAlertsByFingerprint path and
|
|
702
|
+
* the checkOpenAlertsAndCloseIfResolved path) can both decide to resolve
|
|
703
|
+
* the same open alert near-simultaneously. The loser's onBeforeCreate
|
|
704
|
+
* dedupe throws this exact BadDataException. Treat as a no-op at debug
|
|
705
|
+
* level instead of failing the queue job. Mirrors resolveOpenIncident.
|
|
706
|
+
*/
|
|
707
|
+
if (
|
|
708
|
+
err instanceof BadDataException &&
|
|
709
|
+
err.message === "Alert state cannot be same as previous state."
|
|
710
|
+
) {
|
|
711
|
+
logger.debug(
|
|
712
|
+
`${input.openAlert.id?.toString()} - Alert already in resolved state; skipping duplicate state timeline (concurrent race).`,
|
|
713
|
+
);
|
|
714
|
+
} else {
|
|
715
|
+
throw err;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
581
718
|
}
|
|
582
719
|
|
|
583
720
|
private static shouldCloseAlert(input: {
|
|
@@ -585,10 +722,23 @@ export default class MonitorAlert {
|
|
|
585
722
|
autoResolveCriteriaInstanceIdAlertIdsDictionary: Dictionary<Array<string>>;
|
|
586
723
|
criteriaInstance: MonitorCriteriaInstance | null; // null if no criteia met.
|
|
587
724
|
breachingSeriesFingerprints?: Set<string> | undefined;
|
|
725
|
+
disableSeriesAbsenceResolution?: boolean | undefined;
|
|
588
726
|
}): boolean {
|
|
589
727
|
const openSeriesFingerprint: string | undefined =
|
|
590
728
|
input.openAlert.seriesFingerprint || undefined;
|
|
591
729
|
|
|
730
|
+
/*
|
|
731
|
+
* Event-driven (incoming-request / webhook) per-key alerts must NEVER
|
|
732
|
+
* be resolved by absence — only explicitly, via
|
|
733
|
+
* resolveSeriesAlertsByFingerprint, when the payload reports the key as
|
|
734
|
+
* recovered. Mirrors MonitorIncident.shouldCloseIncident. Without this
|
|
735
|
+
* guard, a heartbeat-timeout cron tick or a rejected webhook would
|
|
736
|
+
* bulk-resolve all open per-key alerts by absence.
|
|
737
|
+
*/
|
|
738
|
+
if (input.disableSeriesAbsenceResolution && openSeriesFingerprint) {
|
|
739
|
+
return false;
|
|
740
|
+
}
|
|
741
|
+
|
|
592
742
|
/*
|
|
593
743
|
* Per-series auto-resolve: when a breaching-series set is given and
|
|
594
744
|
* this alert has a fingerprint, resolve whenever the fingerprint is
|
|
@@ -5,6 +5,7 @@ import APIRequestCriteria from "./Criteria/APIRequestCriteria";
|
|
|
5
5
|
import CustomCodeMonitoringCriteria from "./Criteria/CustomCodeMonitorCriteria";
|
|
6
6
|
import IncomingEmailCriteria from "./Criteria/IncomingEmailCriteria";
|
|
7
7
|
import IncomingRequestCriteria from "./Criteria/IncomingRequestCriteria";
|
|
8
|
+
import IncomingRequestIncidentGrouping from "./IncomingRequestIncidentGrouping";
|
|
8
9
|
import SSLMonitorCriteria from "./Criteria/SSLMonitorCriteria";
|
|
9
10
|
import ServerMonitorCriteria from "./Criteria/ServerMonitorCriteria";
|
|
10
11
|
import SyntheticMonitoringCriteria from "./Criteria/SyntheticMonitor";
|
|
@@ -215,6 +216,17 @@ ${contextBlock}
|
|
|
215
216
|
|
|
216
217
|
if (perSeriesMatches.length > 0) {
|
|
217
218
|
input.probeApiIngestResponse.perSeriesMatches = perSeriesMatches;
|
|
219
|
+
} else if (
|
|
220
|
+
input.monitor.monitorType === MonitorType.IncomingRequest &&
|
|
221
|
+
IncomingRequestIncidentGrouping.isGroupingConfigured(criteriaInstance)
|
|
222
|
+
) {
|
|
223
|
+
/*
|
|
224
|
+
* Grouped incoming-request criteria that produced no firing key
|
|
225
|
+
* (e.g. a pure "resolved" webhook). Force per-series mode with an
|
|
226
|
+
* empty set so the create path does NOT fall back to opening a
|
|
227
|
+
* single whole-monitor incident.
|
|
228
|
+
*/
|
|
229
|
+
input.probeApiIngestResponse.perSeriesMatches = [];
|
|
218
230
|
}
|
|
219
231
|
|
|
220
232
|
break;
|
|
@@ -238,6 +250,20 @@ ${contextBlock}
|
|
|
238
250
|
monitorStep: MonitorStep;
|
|
239
251
|
criteriaInstance: MonitorCriteriaInstance;
|
|
240
252
|
}): Promise<Array<PerSeriesCriteriaMatch>> {
|
|
253
|
+
/*
|
|
254
|
+
* Incoming Request / webhook monitors fan out per payload-derived key
|
|
255
|
+
* (e.g. one incident per Grafana alert name) when incidentGrouping is
|
|
256
|
+
* configured. No-op (returns []) when grouping is not configured, so
|
|
257
|
+
* existing incoming-request monitors keep their single-incident
|
|
258
|
+
* behaviour.
|
|
259
|
+
*/
|
|
260
|
+
if (input.monitor.monitorType === MonitorType.IncomingRequest) {
|
|
261
|
+
return IncomingRequestIncidentGrouping.collectFiringMatches({
|
|
262
|
+
dataToProcess: input.dataToProcess,
|
|
263
|
+
criteriaInstance: input.criteriaInstance,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
241
267
|
if (
|
|
242
268
|
input.monitor.monitorType !== MonitorType.Metrics &&
|
|
243
269
|
input.monitor.monitorType !== MonitorType.Kubernetes &&
|