@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.
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 +6 -1
  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 +7 -1
  58. package/build/dist/UI/Components/MasterPage/MasterPage.js.map +1 -1
  59. package/package.json +1 -1
@@ -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", () => {