@oneuptime/common 11.3.5 → 11.3.7

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 (65) hide show
  1. package/Server/Infrastructure/Queue.ts +103 -16
  2. package/Server/Services/AlertService.ts +302 -202
  3. package/Server/Services/AnalyticsDatabaseService.ts +9 -5
  4. package/Server/Services/IncidentService.ts +410 -306
  5. package/Server/Services/TeamMemberService.ts +38 -1
  6. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +31 -0
  7. package/Server/Utils/Monitor/IncomingRequestIncidentGrouping.ts +428 -0
  8. package/Server/Utils/Monitor/MonitorAlert.ts +158 -8
  9. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +26 -0
  10. package/Server/Utils/Monitor/MonitorIncident.ts +147 -2
  11. package/Server/Utils/Monitor/MonitorResource.ts +74 -0
  12. package/Tests/Server/Utils/AnalyticsDatabase/ClusterAwareSchema.test.ts +2 -1
  13. package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +32 -0
  14. package/Tests/Server/Utils/Monitor/IncomingRequestIncidentGrouping.test.ts +389 -0
  15. package/Tests/Server/Utils/Monitor/SeriesAbsenceResolutionGuard.test.ts +177 -0
  16. package/Tests/Types/Events/Recurring.test.ts +475 -0
  17. package/Tests/Types/Monitor/CriteriaFilter.test.ts +348 -0
  18. package/Tests/Types/OnCallDutyPolicy/RestrictionTimes.test.ts +331 -0
  19. package/Tests/UI/Components/Alert.test.tsx +23 -15
  20. package/Tests/UI/Components/Breadcrumbs.test.tsx +18 -6
  21. package/Tests/UI/Components/Button.test.tsx +3 -3
  22. package/Tests/UI/Components/DictionaryFilterOperator.test.ts +174 -0
  23. package/Tests/UI/Components/EmptyState/EmptyState.test.tsx +4 -3
  24. package/Tests/UI/Components/Input.test.tsx +3 -2
  25. package/Tests/UI/Components/Pill.test.tsx +4 -2
  26. package/Tests/UI/Components/SideMenuItem.test.tsx +18 -8
  27. package/Tests/UI/Components/TextArea.test.tsx +6 -4
  28. package/Types/Monitor/IncomingMonitor/IncidentGroupingConfig.ts +74 -0
  29. package/Types/Monitor/MonitorCriteriaInstance.ts +22 -0
  30. package/UI/Components/Dictionary/Dictionary.tsx +123 -50
  31. package/UI/Components/Dictionary/DictionaryFilterOperator.ts +96 -1
  32. package/UI/Components/MasterPage/MasterPage.tsx +5 -3
  33. package/build/dist/Server/Infrastructure/Queue.js +83 -15
  34. package/build/dist/Server/Infrastructure/Queue.js.map +1 -1
  35. package/build/dist/Server/Services/AlertService.js +255 -161
  36. package/build/dist/Server/Services/AlertService.js.map +1 -1
  37. package/build/dist/Server/Services/AnalyticsDatabaseService.js +9 -5
  38. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  39. package/build/dist/Server/Services/IncidentService.js +322 -236
  40. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  41. package/build/dist/Server/Services/TeamMemberService.js +37 -1
  42. package/build/dist/Server/Services/TeamMemberService.js.map +1 -1
  43. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +26 -0
  44. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  45. package/build/dist/Server/Utils/Monitor/IncomingRequestIncidentGrouping.js +288 -0
  46. package/build/dist/Server/Utils/Monitor/IncomingRequestIncidentGrouping.js.map +1 -0
  47. package/build/dist/Server/Utils/Monitor/MonitorAlert.js +119 -8
  48. package/build/dist/Server/Utils/Monitor/MonitorAlert.js.map +1 -1
  49. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +24 -0
  50. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  51. package/build/dist/Server/Utils/Monitor/MonitorIncident.js +103 -2
  52. package/build/dist/Server/Utils/Monitor/MonitorIncident.js.map +1 -1
  53. package/build/dist/Server/Utils/Monitor/MonitorResource.js +62 -0
  54. package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
  55. package/build/dist/Types/Monitor/IncomingMonitor/IncidentGroupingConfig.js +8 -0
  56. package/build/dist/Types/Monitor/IncomingMonitor/IncidentGroupingConfig.js.map +1 -0
  57. package/build/dist/Types/Monitor/MonitorCriteriaInstance.js +10 -0
  58. package/build/dist/Types/Monitor/MonitorCriteriaInstance.js.map +1 -1
  59. package/build/dist/UI/Components/Dictionary/Dictionary.js +60 -9
  60. package/build/dist/UI/Components/Dictionary/Dictionary.js.map +1 -1
  61. package/build/dist/UI/Components/Dictionary/DictionaryFilterOperator.js +69 -0
  62. package/build/dist/UI/Components/Dictionary/DictionaryFilterOperator.js.map +1 -1
  63. package/build/dist/UI/Components/MasterPage/MasterPage.js +5 -3
  64. package/build/dist/UI/Components/MasterPage/MasterPage.js.map +1 -1
  65. package/package.json +1 -1
@@ -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 &&
@@ -72,6 +72,14 @@ export default class MonitorIncident {
72
72
  * incidents are treated like any other for dedupe/resolve.
73
73
  */
74
74
  breachingSeriesFingerprints?: Set<string> | undefined;
75
+ /**
76
+ * Event-driven (incoming-request / webhook) mode. When true, an open
77
+ * incident carrying a seriesFingerprint is never auto-resolved here —
78
+ * webhooks resolve per-key only via resolveSeriesIncidentsByFingerprint,
79
+ * never by absence. Must be passed on BOTH the criteria-met and the
80
+ * no-criteria-met code paths for grouped incoming-request monitors.
81
+ */
82
+ disableSeriesAbsenceResolution?: boolean | undefined;
75
83
  }): Promise<Array<Incident>> {
76
84
  // check active incidents and if there are open incidents, do not create another incident.
77
85
  const openIncidents: Array<Incident> = await IncidentService.findBy({
@@ -109,6 +117,7 @@ export default class MonitorIncident {
109
117
  input.autoResolveCriteriaInstanceIdIncidentIdsDictionary,
110
118
  criteriaInstance: input.criteriaInstance,
111
119
  breachingSeriesFingerprints: input.breachingSeriesFingerprints,
120
+ disableSeriesAbsenceResolution: input.disableSeriesAbsenceResolution,
112
121
  });
113
122
 
114
123
  if (shouldClose) {
@@ -137,6 +146,107 @@ export default class MonitorIncident {
137
146
  return openIncidents;
138
147
  }
139
148
 
149
+ /**
150
+ * Event-driven (incoming-request / webhook) resolution: resolve the
151
+ * open incidents for the given payload-derived fingerprints — and only
152
+ * those — when the criteria that created them has auto-resolve enabled.
153
+ *
154
+ * Unlike the metric snapshot model, this never resolves an incident by
155
+ * absence: a webhook describes only the keys in its payload, so a
156
+ * missing key is not a recovery signal. The caller passes exactly the
157
+ * fingerprints the payload explicitly classified as resolved (see
158
+ * IncomingRequestIncidentGrouping.collectResolvedFingerprints).
159
+ */
160
+ @CaptureSpan()
161
+ public static async resolveSeriesIncidentsByFingerprint(input: {
162
+ monitor: Monitor;
163
+ fingerprints: Array<string>;
164
+ rootCause: string;
165
+ dataToProcess: DataToProcess;
166
+ autoResolveCriteriaInstanceIdIncidentIdsDictionary: Dictionary<
167
+ Array<string>
168
+ >;
169
+ evaluationSummary?: MonitorEvaluationSummary | undefined;
170
+ }): Promise<void> {
171
+ if (!input.fingerprints || input.fingerprints.length === 0) {
172
+ return;
173
+ }
174
+
175
+ const fingerprintSet: Set<string> = new Set<string>(input.fingerprints);
176
+
177
+ const openIncidents: Array<Incident> = await IncidentService.findBy({
178
+ query: {
179
+ monitors: [input.monitor.id!],
180
+ currentIncidentState: {
181
+ isResolvedState: false,
182
+ },
183
+ },
184
+ skip: 0,
185
+ limit: LIMIT_PER_PROJECT,
186
+ select: {
187
+ _id: true,
188
+ title: true,
189
+ createdCriteriaId: true,
190
+ createdIncidentTemplateId: true,
191
+ projectId: true,
192
+ incidentNumber: true,
193
+ incidentNumberWithPrefix: true,
194
+ seriesFingerprint: true,
195
+ },
196
+ props: {
197
+ isRoot: true,
198
+ },
199
+ });
200
+
201
+ for (const openIncident of openIncidents) {
202
+ const fingerprint: string | undefined =
203
+ openIncident.seriesFingerprint || undefined;
204
+
205
+ if (!fingerprint || !fingerprintSet.has(fingerprint)) {
206
+ continue;
207
+ }
208
+
209
+ const createdCriteriaId: string | undefined =
210
+ openIncident.createdCriteriaId?.toString();
211
+ const createdIncidentTemplateId: string | undefined =
212
+ openIncident.createdIncidentTemplateId?.toString();
213
+
214
+ // Only auto-resolve when the creating criteria opted into it.
215
+ if (!createdCriteriaId || !createdIncidentTemplateId) {
216
+ continue;
217
+ }
218
+
219
+ const autoResolveTemplates: Array<string> | undefined =
220
+ input.autoResolveCriteriaInstanceIdIncidentIdsDictionary[
221
+ createdCriteriaId
222
+ ];
223
+
224
+ if (
225
+ !autoResolveTemplates ||
226
+ !autoResolveTemplates.includes(createdIncidentTemplateId)
227
+ ) {
228
+ continue;
229
+ }
230
+
231
+ await this.resolveOpenIncident({
232
+ openIncident: openIncident,
233
+ rootCause: input.rootCause,
234
+ dataToProcess: input.dataToProcess,
235
+ });
236
+
237
+ input.evaluationSummary?.events.push({
238
+ type: "incident-resolved",
239
+ title: `Incident resolved: ${openIncident.id?.toString()}`,
240
+ message:
241
+ "Incident auto-resolved because the incoming payload reported this key as resolved.",
242
+ relatedIncidentId: openIncident.id?.toString(),
243
+ relatedIncidentNumber: openIncident.incidentNumber,
244
+ relatedIncidentNumberWithPrefix: openIncident.incidentNumberWithPrefix,
245
+ at: OneUptimeDate.getCurrentDate(),
246
+ });
247
+ }
248
+ }
249
+
140
250
  @CaptureSpan()
141
251
  public static async criteriaMetCreateIncidentsAndUpdateMonitorStatus(input: {
142
252
  criteriaInstance: MonitorCriteriaInstance;
@@ -165,6 +275,17 @@ export default class MonitorIncident {
165
275
  * are suppressed at creation time. See MonitorMaintenanceSuppression.
166
276
  */
167
277
  suppressedSeriesFingerprints?: Set<string> | undefined;
278
+ /**
279
+ * Event-driven monitors (incoming-request / webhook fan-out) must not
280
+ * use the metric snapshot model where a series absent from this tick's
281
+ * breaching set is auto-resolved — a single webhook only describes the
282
+ * keys in that payload, not the full firing set, so absence is not
283
+ * recovery. When true, the per-series absence-resolve pass is skipped;
284
+ * those incidents are resolved explicitly elsewhere (see
285
+ * IncomingRequestIncidentGrouping + resolveSeriesIncidentsByFingerprint).
286
+ * Per-key create + dedupe still happen via matchesPerSeries.
287
+ */
288
+ disableSeriesAbsenceResolution?: boolean | undefined;
168
289
  }): Promise<void> {
169
290
  const incidentLogAttributes: LogAttributes = {
170
291
  projectId: input.monitor.projectId?.toString(),
@@ -182,7 +303,7 @@ export default class MonitorIncident {
182
303
  * dedupe decisions below match the post-resolve state.
183
304
  */
184
305
  const breachingSeriesFingerprints: Set<string> | undefined =
185
- input.matchesPerSeries
306
+ input.matchesPerSeries && !input.disableSeriesAbsenceResolution
186
307
  ? new Set<string>(
187
308
  input.matchesPerSeries.map((m: PerSeriesCriteriaMatch) => {
188
309
  return m.fingerprint;
@@ -201,6 +322,7 @@ export default class MonitorIncident {
201
322
  dataToProcess: input.dataToProcess,
202
323
  evaluationSummary: input.evaluationSummary,
203
324
  breachingSeriesFingerprints,
325
+ disableSeriesAbsenceResolution: input.disableSeriesAbsenceResolution,
204
326
  });
205
327
 
206
328
  if (!input.criteriaInstance.data?.createIncidents) {
@@ -224,8 +346,15 @@ export default class MonitorIncident {
224
346
  * Series-less path: one incident per criteriaIncident template as
225
347
  * before. Series-aware path: one incident per (series × template).
226
348
  */
349
+ /*
350
+ * `undefined` matchesPerSeries → legacy single-incident path. A
351
+ * defined (even empty) array → per-series mode: iterate exactly the
352
+ * matches. An empty array therefore creates nothing — used by grouped
353
+ * incoming-request criteria on a payload with no firing key so they
354
+ * don't fall back to a single whole-monitor incident.
355
+ */
227
356
  const seriesToProcess: Array<PerSeriesCriteriaMatch | undefined> =
228
- input.matchesPerSeries && input.matchesPerSeries.length > 0
357
+ input.matchesPerSeries !== undefined
229
358
  ? input.matchesPerSeries
230
359
  : [undefined];
231
360
 
@@ -981,10 +1110,26 @@ export default class MonitorIncident {
981
1110
  >;
982
1111
  criteriaInstance: MonitorCriteriaInstance | null; // null if no criteia met.
983
1112
  breachingSeriesFingerprints?: Set<string> | undefined;
1113
+ disableSeriesAbsenceResolution?: boolean | undefined;
984
1114
  }): boolean {
985
1115
  const openSeriesFingerprint: string | undefined =
986
1116
  input.openIncident.seriesFingerprint || undefined;
987
1117
 
1118
+ /*
1119
+ * Event-driven (incoming-request / webhook) per-key incidents must
1120
+ * NEVER be resolved by absence — only explicitly, via
1121
+ * resolveSeriesIncidentsByFingerprint, when the payload reports the
1122
+ * key as recovered. A single webhook describes only the keys in its
1123
+ * own payload, so neither the per-series breaching-set path nor the
1124
+ * legacy cross-criteria path below may close a series incident here.
1125
+ * Without this guard, a heartbeat-timeout cron tick or a webhook that
1126
+ * the grouping criteria rejects would bulk-resolve all open per-key
1127
+ * incidents by absence.
1128
+ */
1129
+ if (input.disableSeriesAbsenceResolution && openSeriesFingerprint) {
1130
+ return false;
1131
+ }
1132
+
988
1133
  /*
989
1134
  * Per-series auto-resolve: when the monitor emits a breaching-
990
1135
  * series set and this open incident has a fingerprint, resolve
@@ -35,6 +35,7 @@ import ExceptionMonitorResponse from "../../../Types/Monitor/ExceptionMonitor/Ex
35
35
  import { TelemetryQuery } from "../../../Types/Telemetry/TelemetryQuery";
36
36
  import MonitorIncident from "./MonitorIncident";
37
37
  import MonitorAlert from "./MonitorAlert";
38
+ import IncomingRequestIncidentGrouping from "./IncomingRequestIncidentGrouping";
38
39
  import MonitorMaintenanceSuppression from "./MonitorMaintenanceSuppression";
39
40
  import MonitorStatusTimelineUtil from "./MonitorStatusTimeline";
40
41
  import CaptureSpan from "../Telemetry/CaptureSpan";
@@ -659,6 +660,49 @@ export default class MonitorResourceUtil {
659
660
  }
660
661
  }
661
662
 
663
+ /*
664
+ * Incoming Request / webhook grouped resolution (event-driven). A
665
+ * payload can explicitly mark keys as resolved (e.g. Grafana
666
+ * status=resolved). Resolve exactly those keys' incidents — never by
667
+ * absence. Runs regardless of whether a firing criteria matched, so a
668
+ * pure "resolved" webhook still closes the right incident. No-op
669
+ * unless a criteria has incidentGrouping configured.
670
+ */
671
+ if (
672
+ monitor.monitorType === MonitorType.IncomingRequest &&
673
+ criteriaInstances.some((criteriaInstance: MonitorCriteriaInstance) => {
674
+ return IncomingRequestIncidentGrouping.isGroupingConfigured(
675
+ criteriaInstance,
676
+ );
677
+ })
678
+ ) {
679
+ const resolvedFingerprints: Array<string> =
680
+ IncomingRequestIncidentGrouping.collectResolvedFingerprints({
681
+ dataToProcess: dataToProcess,
682
+ criteriaInstances: criteriaInstances,
683
+ });
684
+
685
+ if (resolvedFingerprints.length > 0) {
686
+ await MonitorIncident.resolveSeriesIncidentsByFingerprint({
687
+ monitor: monitor,
688
+ fingerprints: resolvedFingerprints,
689
+ rootCause: "Incoming request reported this key as resolved.",
690
+ dataToProcess: dataToProcess,
691
+ autoResolveCriteriaInstanceIdIncidentIdsDictionary,
692
+ evaluationSummary: evaluationSummary,
693
+ });
694
+
695
+ await MonitorAlert.resolveSeriesAlertsByFingerprint({
696
+ monitor: monitor,
697
+ fingerprints: resolvedFingerprints,
698
+ rootCause: "Incoming request reported this key as resolved.",
699
+ dataToProcess: dataToProcess,
700
+ autoResolveCriteriaInstanceIdAlertIdsDictionary,
701
+ evaluationSummary: evaluationSummary,
702
+ });
703
+ }
704
+ }
705
+
662
706
  if (response.criteriaMetId && response.rootCause) {
663
707
  logger.debug(
664
708
  `${dataToProcess.monitorId.toString()} - Criteria met: ${
@@ -796,6 +840,15 @@ export default class MonitorResourceUtil {
796
840
  },
797
841
  matchesPerSeries: response.perSeriesMatches,
798
842
  suppressedSeriesFingerprints,
843
+ /*
844
+ * Incoming-request grouping is event-driven: a webhook describes
845
+ * only the keys in its payload, so absence from this tick is not
846
+ * recovery. Skip the snapshot absence-resolve pass; grouped
847
+ * incidents are resolved explicitly via the resolution block
848
+ * above. Per-key create + dedupe still run from matchesPerSeries.
849
+ */
850
+ disableSeriesAbsenceResolution:
851
+ monitor.monitorType === MonitorType.IncomingRequest,
799
852
  });
800
853
 
801
854
  await MonitorAlert.criteriaMetCreateAlertsAndUpdateMonitorStatus({
@@ -810,6 +863,14 @@ export default class MonitorResourceUtil {
810
863
  },
811
864
  matchesPerSeries: response.perSeriesMatches,
812
865
  suppressedSeriesFingerprints,
866
+ /*
867
+ * Incoming-request grouping is event-driven (see the incident
868
+ * create call above): skip the snapshot absence-resolve pass for
869
+ * webhooks; grouped alerts are resolved explicitly via the
870
+ * resolution block above. Per-key create + dedupe still run.
871
+ */
872
+ disableSeriesAbsenceResolution:
873
+ monitor.monitorType === MonitorType.IncomingRequest,
813
874
  });
814
875
  } else if (
815
876
  !response.criteriaMetId &&
@@ -828,6 +889,17 @@ export default class MonitorResourceUtil {
828
889
  criteriaInstance: null, // no criteria met!
829
890
  dataToProcess: dataToProcess,
830
891
  evaluationSummary: evaluationSummary,
892
+ /*
893
+ * Event-driven grouping: for incoming-request monitors, never
894
+ * absence-resolve per-key (seriesFingerprint) incidents on the
895
+ * no-criteria-met path — a heartbeat tick or a rejected webhook
896
+ * must not bulk-close grouped incidents. They resolve only via
897
+ * the explicit resolution block above. Non-grouped incoming
898
+ * incidents have no seriesFingerprint, so they still resolve
899
+ * normally; non-incoming-request types are unaffected (flag false).
900
+ */
901
+ disableSeriesAbsenceResolution:
902
+ monitor.monitorType === MonitorType.IncomingRequest,
831
903
  });
832
904
 
833
905
  await MonitorAlert.checkOpenAlertsAndCloseIfResolved({
@@ -837,6 +909,8 @@ export default class MonitorResourceUtil {
837
909
  criteriaInstance: null, // no criteria met!
838
910
  dataToProcess: dataToProcess,
839
911
  evaluationSummary: evaluationSummary,
912
+ disableSeriesAbsenceResolution:
913
+ monitor.monitorType === MonitorType.IncomingRequest,
840
914
  });
841
915
 
842
916
  // get last monitor status timeline.
@@ -256,7 +256,8 @@ describe("ClickHouse cluster-aware schema (always-on)", () => {
256
256
  expect(dropColumn).toContain("SpanItemV3Local");
257
257
  expect(dropColumn).toContain("ON CLUSTER 'oneuptime'");
258
258
 
259
- const dropIndex: string = spanGen.toDropSkipIndexStatement("idx_trace_id");
259
+ const dropIndex: string =
260
+ spanGen.toDropSkipIndexStatement("idx_trace_id");
260
261
  expect(dropIndex).toContain("SpanItemV3Local");
261
262
  expect(dropIndex).toContain("ON CLUSTER 'oneuptime'");
262
263
  });
@@ -19,6 +19,7 @@ import IsNull from "../../../../Types/BaseDatabase/IsNull";
19
19
  import NotNull from "../../../../Types/BaseDatabase/NotNull";
20
20
  import GreaterThan from "../../../../Types/BaseDatabase/GreaterThan";
21
21
  import Includes from "../../../../Types/BaseDatabase/Includes";
22
+ import IncludesNone from "../../../../Types/BaseDatabase/IncludesNone";
22
23
  import Search from "../../../../Types/BaseDatabase/Search";
23
24
  import StartsWith from "../../../../Types/BaseDatabase/StartsWith";
24
25
 
@@ -363,6 +364,37 @@ describe("StatementGenerator", () => {
363
364
  expect(statement.query).toBe("");
364
365
  expect(statement.query_params).toStrictEqual({});
365
366
  });
367
+
368
+ test("emits direct map subscript NOT IN(...) for IncludesNone wrapper", () => {
369
+ const statement: Statement = mapGenerator.toWhereStatement({
370
+ attributes: {
371
+ "k8s.cluster.name": new IncludesNone(["prod-east", "prod-west"]),
372
+ },
373
+ } as any);
374
+ /*
375
+ * "is none of" emits the same O(1) Map subscript fast path as
376
+ * IN, negated. Missing keys (subscript returns '') pass NOT IN,
377
+ * matching NotEqual's map semantics.
378
+ */
379
+ expect(statement.query).toBe(
380
+ "AND {p0:Identifier}[{p1:String}] NOT IN {p2:Array(String)}",
381
+ );
382
+ expect(statement.query_params).toStrictEqual({
383
+ p0: "attributes",
384
+ p1: "k8s.cluster.name",
385
+ p2: ["prod-east", "prod-west"],
386
+ });
387
+ });
388
+
389
+ test("drops empty IncludesNone wrapper instead of producing NOT IN ()", () => {
390
+ const statement: Statement = mapGenerator.toWhereStatement({
391
+ attributes: {
392
+ "k8s.cluster.name": new IncludesNone([]),
393
+ },
394
+ } as any);
395
+ expect(statement.query).toBe("");
396
+ expect(statement.query_params).toStrictEqual({});
397
+ });
366
398
  });
367
399
 
368
400
  describe("ArrayText columns", () => {