@oneuptime/common 11.3.5 → 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.
Files changed (59) hide show
  1. package/Server/Infrastructure/Queue.ts +103 -16
  2. package/Server/Services/AnalyticsDatabaseService.ts +9 -5
  3. package/Server/Services/TeamMemberService.ts +38 -1
  4. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +31 -0
  5. package/Server/Utils/Monitor/IncomingRequestIncidentGrouping.ts +428 -0
  6. package/Server/Utils/Monitor/MonitorAlert.ts +158 -8
  7. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +26 -0
  8. package/Server/Utils/Monitor/MonitorIncident.ts +147 -2
  9. package/Server/Utils/Monitor/MonitorResource.ts +74 -0
  10. package/Tests/Server/Utils/AnalyticsDatabase/ClusterAwareSchema.test.ts +2 -1
  11. package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +32 -0
  12. package/Tests/Server/Utils/Monitor/IncomingRequestIncidentGrouping.test.ts +389 -0
  13. package/Tests/Server/Utils/Monitor/SeriesAbsenceResolutionGuard.test.ts +177 -0
  14. package/Tests/Types/Events/Recurring.test.ts +475 -0
  15. package/Tests/Types/Monitor/CriteriaFilter.test.ts +348 -0
  16. package/Tests/Types/OnCallDutyPolicy/RestrictionTimes.test.ts +331 -0
  17. package/Tests/UI/Components/Alert.test.tsx +23 -15
  18. package/Tests/UI/Components/Breadcrumbs.test.tsx +18 -6
  19. package/Tests/UI/Components/Button.test.tsx +3 -3
  20. package/Tests/UI/Components/DictionaryFilterOperator.test.ts +174 -0
  21. package/Tests/UI/Components/EmptyState/EmptyState.test.tsx +4 -3
  22. package/Tests/UI/Components/Input.test.tsx +3 -2
  23. package/Tests/UI/Components/Pill.test.tsx +4 -2
  24. package/Tests/UI/Components/SideMenuItem.test.tsx +18 -8
  25. package/Tests/UI/Components/TextArea.test.tsx +6 -4
  26. package/Types/Monitor/IncomingMonitor/IncidentGroupingConfig.ts +74 -0
  27. package/Types/Monitor/MonitorCriteriaInstance.ts +22 -0
  28. package/UI/Components/Dictionary/Dictionary.tsx +123 -50
  29. package/UI/Components/Dictionary/DictionaryFilterOperator.ts +96 -1
  30. package/UI/Components/MasterPage/MasterPage.tsx +5 -3
  31. package/build/dist/Server/Infrastructure/Queue.js +83 -15
  32. package/build/dist/Server/Infrastructure/Queue.js.map +1 -1
  33. package/build/dist/Server/Services/AnalyticsDatabaseService.js +9 -5
  34. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  35. package/build/dist/Server/Services/TeamMemberService.js +37 -1
  36. package/build/dist/Server/Services/TeamMemberService.js.map +1 -1
  37. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +26 -0
  38. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  39. package/build/dist/Server/Utils/Monitor/IncomingRequestIncidentGrouping.js +288 -0
  40. package/build/dist/Server/Utils/Monitor/IncomingRequestIncidentGrouping.js.map +1 -0
  41. package/build/dist/Server/Utils/Monitor/MonitorAlert.js +119 -8
  42. package/build/dist/Server/Utils/Monitor/MonitorAlert.js.map +1 -1
  43. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +24 -0
  44. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  45. package/build/dist/Server/Utils/Monitor/MonitorIncident.js +103 -2
  46. package/build/dist/Server/Utils/Monitor/MonitorIncident.js.map +1 -1
  47. package/build/dist/Server/Utils/Monitor/MonitorResource.js +62 -0
  48. package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
  49. package/build/dist/Types/Monitor/IncomingMonitor/IncidentGroupingConfig.js +8 -0
  50. package/build/dist/Types/Monitor/IncomingMonitor/IncidentGroupingConfig.js.map +1 -0
  51. package/build/dist/Types/Monitor/MonitorCriteriaInstance.js +10 -0
  52. package/build/dist/Types/Monitor/MonitorCriteriaInstance.js.map +1 -1
  53. package/build/dist/UI/Components/Dictionary/Dictionary.js +60 -9
  54. package/build/dist/UI/Components/Dictionary/Dictionary.js.map +1 -1
  55. package/build/dist/UI/Components/Dictionary/DictionaryFilterOperator.js +69 -0
  56. package/build/dist/UI/Components/Dictionary/DictionaryFilterOperator.js.map +1 -1
  57. package/build/dist/UI/Components/MasterPage/MasterPage.js +5 -3
  58. package/build/dist/UI/Components/MasterPage/MasterPage.js.map +1 -1
  59. 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 && input.matchesPerSeries.length > 0
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
- await AlertStateTimelineService.create({
576
- data: alertStateTimeline,
577
- props: {
578
- isRoot: true,
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 &&