@oneuptime/common 11.5.11 → 11.5.13

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 (142) hide show
  1. package/Models/AnalyticsModels/NetworkFlow.ts +12 -1
  2. package/Models/DatabaseModels/AIInsight.ts +30 -0
  3. package/Models/DatabaseModels/Project.ts +30 -0
  4. package/Models/DatabaseModels/TelemetryException.ts +128 -0
  5. package/Server/API/AIAgentDataAPI.ts +129 -5
  6. package/Server/API/AIInvestigationAPI.ts +87 -0
  7. package/Server/API/StatusPageAPI.ts +14 -1
  8. package/Server/Infrastructure/Postgres/SchemaMigrations/1784640000000-AddExceptionTriageAndPrivacyColumns.ts +56 -0
  9. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -2
  10. package/Server/Services/AIRunService.ts +8 -0
  11. package/Server/Services/IncidentService.ts +62 -67
  12. package/Server/Services/MonitorGroupService.ts +15 -2
  13. package/Server/Services/MonitorService.ts +91 -3
  14. package/Server/Services/MonitorStatusTimelineService.ts +285 -100
  15. package/Server/Services/StatusPageService.ts +59 -34
  16. package/Server/Services/TelemetryExceptionService.ts +320 -24
  17. package/Server/Utils/AI/SRE/Insights/Detectors/ExceptionSpikeDetector.ts +15 -4
  18. package/Server/Utils/AI/SRE/Insights/Detectors/NewExceptionDetector.ts +15 -4
  19. package/Server/Utils/AI/SRE/Insights/InsightScanner.ts +222 -24
  20. package/Server/Utils/AI/SRE/Insights/InsightTriageRunner.ts +225 -9
  21. package/Server/Utils/AI/SRE/SubjectCodeFixRun.ts +39 -0
  22. package/Server/Utils/AI/SRE/TelemetryImprovementTaskTrigger.ts +114 -0
  23. package/Server/Utils/Monitor/MonitorResource.ts +43 -14
  24. package/Server/Utils/Monitor/MonitorStatusTimeline.ts +32 -7
  25. package/Server/Utils/Monitor/MonitorStatusTimelineReconciler.ts +362 -0
  26. package/Server/Utils/Telemetry/ExceptionSanitizer.ts +222 -0
  27. package/Tests/Server/Services/MonitorStatusTimelineService.test.ts +235 -0
  28. package/Tests/Server/Services/TelemetryExceptionCodeFixRun.test.ts +205 -1
  29. package/Tests/Server/Utils/AI/Insights/InsightScanner.test.ts +150 -141
  30. package/Tests/Server/Utils/AI/Insights/InsightTriageRunner.test.ts +277 -1
  31. package/Tests/Server/Utils/ExceptionSanitizer.test.ts +70 -0
  32. package/Tests/Server/Utils/InsightTriageClassification.test.ts +57 -0
  33. package/Tests/Server/Utils/Monitor/MonitorStatusTimelineReconciler.test.ts +555 -0
  34. package/Tests/Server/Utils/TelemetryImprovementTaskTrigger.test.ts +141 -0
  35. package/Tests/Types/Database/CompareOperatorWireSerialization.test.ts +216 -0
  36. package/Tests/Types/Database/GreaterThan.test.ts +5 -1
  37. package/Tests/Types/Database/GreaterThanOrEqual.test.ts +5 -1
  38. package/Tests/Types/Database/GreaterThanOrNull.test.ts +24 -1
  39. package/Tests/Types/Database/LessThan.test.ts +5 -1
  40. package/Tests/Types/Database/LessThanOrEqual.test.ts +24 -1
  41. package/Tests/Types/Database/LessThanOrNull.test.ts +5 -1
  42. package/Tests/UI/Components/ModelTableSelectFromColumns.test.ts +259 -0
  43. package/Tests/Utils/Uptime/UptimeUtil.test.ts +726 -0
  44. package/Types/AI/CodeFixTaskContext.ts +6 -0
  45. package/Types/AI/CodeFixTaskType.ts +38 -1
  46. package/Types/AI/ExceptionAIClassification.ts +27 -0
  47. package/Types/BaseDatabase/GreaterThan.ts +8 -1
  48. package/Types/BaseDatabase/GreaterThanOrEqual.ts +8 -1
  49. package/Types/BaseDatabase/GreaterThanOrNull.ts +9 -1
  50. package/Types/BaseDatabase/LessThan.ts +8 -1
  51. package/Types/BaseDatabase/LessThanOrEqual.ts +9 -1
  52. package/Types/BaseDatabase/LessThanOrNull.ts +8 -1
  53. package/Types/Log/LogScrubPatternType.ts +7 -0
  54. package/Types/Trace/TraceScrubPatternType.ts +7 -0
  55. package/UI/Components/ModelTable/BaseModelTable.tsx +15 -46
  56. package/UI/Components/ModelTable/SelectFromColumns.ts +129 -0
  57. package/Utils/StatusPage/ResourceUptime.ts +11 -1
  58. package/Utils/Uptime/UptimeUtil.ts +142 -36
  59. package/build/dist/Models/AnalyticsModels/NetworkFlow.js +12 -1
  60. package/build/dist/Models/AnalyticsModels/NetworkFlow.js.map +1 -1
  61. package/build/dist/Models/DatabaseModels/AIInsight.js +31 -0
  62. package/build/dist/Models/DatabaseModels/AIInsight.js.map +1 -1
  63. package/build/dist/Models/DatabaseModels/Project.js +31 -0
  64. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  65. package/build/dist/Models/DatabaseModels/TelemetryException.js +134 -0
  66. package/build/dist/Models/DatabaseModels/TelemetryException.js.map +1 -1
  67. package/build/dist/Server/API/AIAgentDataAPI.js +97 -8
  68. package/build/dist/Server/API/AIAgentDataAPI.js.map +1 -1
  69. package/build/dist/Server/API/AIInvestigationAPI.js +55 -0
  70. package/build/dist/Server/API/AIInvestigationAPI.js.map +1 -1
  71. package/build/dist/Server/API/StatusPageAPI.js +12 -2
  72. package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
  73. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784640000000-AddExceptionTriageAndPrivacyColumns.js +33 -0
  74. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784640000000-AddExceptionTriageAndPrivacyColumns.js.map +1 -0
  75. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -2
  76. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  77. package/build/dist/Server/Services/AIRunService.js +9 -2
  78. package/build/dist/Server/Services/AIRunService.js.map +1 -1
  79. package/build/dist/Server/Services/IncidentService.js +47 -56
  80. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  81. package/build/dist/Server/Services/MonitorGroupService.js +15 -2
  82. package/build/dist/Server/Services/MonitorGroupService.js.map +1 -1
  83. package/build/dist/Server/Services/MonitorService.js +66 -3
  84. package/build/dist/Server/Services/MonitorService.js.map +1 -1
  85. package/build/dist/Server/Services/MonitorStatusTimelineService.js +244 -102
  86. package/build/dist/Server/Services/MonitorStatusTimelineService.js.map +1 -1
  87. package/build/dist/Server/Services/StatusPageService.js +57 -33
  88. package/build/dist/Server/Services/StatusPageService.js.map +1 -1
  89. package/build/dist/Server/Services/TelemetryExceptionService.js +263 -22
  90. package/build/dist/Server/Services/TelemetryExceptionService.js.map +1 -1
  91. package/build/dist/Server/Utils/AI/SRE/Insights/Detectors/ExceptionSpikeDetector.js +12 -4
  92. package/build/dist/Server/Utils/AI/SRE/Insights/Detectors/ExceptionSpikeDetector.js.map +1 -1
  93. package/build/dist/Server/Utils/AI/SRE/Insights/Detectors/NewExceptionDetector.js +12 -4
  94. package/build/dist/Server/Utils/AI/SRE/Insights/Detectors/NewExceptionDetector.js.map +1 -1
  95. package/build/dist/Server/Utils/AI/SRE/Insights/InsightScanner.js +193 -22
  96. package/build/dist/Server/Utils/AI/SRE/Insights/InsightScanner.js.map +1 -1
  97. package/build/dist/Server/Utils/AI/SRE/Insights/InsightTriageRunner.js +178 -9
  98. package/build/dist/Server/Utils/AI/SRE/Insights/InsightTriageRunner.js.map +1 -1
  99. package/build/dist/Server/Utils/AI/SRE/SubjectCodeFixRun.js +37 -0
  100. package/build/dist/Server/Utils/AI/SRE/SubjectCodeFixRun.js.map +1 -1
  101. package/build/dist/Server/Utils/AI/SRE/TelemetryImprovementTaskTrigger.js +98 -0
  102. package/build/dist/Server/Utils/AI/SRE/TelemetryImprovementTaskTrigger.js.map +1 -0
  103. package/build/dist/Server/Utils/Monitor/MonitorResource.js +34 -12
  104. package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
  105. package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js +25 -8
  106. package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js.map +1 -1
  107. package/build/dist/Server/Utils/Monitor/MonitorStatusTimelineReconciler.js +268 -0
  108. package/build/dist/Server/Utils/Monitor/MonitorStatusTimelineReconciler.js.map +1 -0
  109. package/build/dist/Server/Utils/Telemetry/ExceptionSanitizer.js +149 -0
  110. package/build/dist/Server/Utils/Telemetry/ExceptionSanitizer.js.map +1 -0
  111. package/build/dist/Types/AI/CodeFixTaskType.js +38 -1
  112. package/build/dist/Types/AI/CodeFixTaskType.js.map +1 -1
  113. package/build/dist/Types/AI/ExceptionAIClassification.js +28 -0
  114. package/build/dist/Types/AI/ExceptionAIClassification.js.map +1 -0
  115. package/build/dist/Types/BaseDatabase/GreaterThan.js +8 -1
  116. package/build/dist/Types/BaseDatabase/GreaterThan.js.map +1 -1
  117. package/build/dist/Types/BaseDatabase/GreaterThanOrEqual.js +8 -1
  118. package/build/dist/Types/BaseDatabase/GreaterThanOrEqual.js.map +1 -1
  119. package/build/dist/Types/BaseDatabase/GreaterThanOrNull.js +9 -1
  120. package/build/dist/Types/BaseDatabase/GreaterThanOrNull.js.map +1 -1
  121. package/build/dist/Types/BaseDatabase/LessThan.js +8 -1
  122. package/build/dist/Types/BaseDatabase/LessThan.js.map +1 -1
  123. package/build/dist/Types/BaseDatabase/LessThanOrEqual.js +9 -1
  124. package/build/dist/Types/BaseDatabase/LessThanOrEqual.js.map +1 -1
  125. package/build/dist/Types/BaseDatabase/LessThanOrNull.js +8 -1
  126. package/build/dist/Types/BaseDatabase/LessThanOrNull.js.map +1 -1
  127. package/build/dist/Types/Log/LogScrubPatternType.js +7 -0
  128. package/build/dist/Types/Log/LogScrubPatternType.js.map +1 -1
  129. package/build/dist/Types/Trace/TraceScrubPatternType.js +7 -0
  130. package/build/dist/Types/Trace/TraceScrubPatternType.js.map +1 -1
  131. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +12 -37
  132. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  133. package/build/dist/UI/Components/ModelTable/SelectFromColumns.js +82 -0
  134. package/build/dist/UI/Components/ModelTable/SelectFromColumns.js.map +1 -0
  135. package/build/dist/Utils/StatusPage/ResourceUptime.js +4 -1
  136. package/build/dist/Utils/StatusPage/ResourceUptime.js.map +1 -1
  137. package/build/dist/Utils/Uptime/UptimeUtil.js +86 -33
  138. package/build/dist/Utils/Uptime/UptimeUtil.js.map +1 -1
  139. package/package.json +1 -1
  140. package/Server/Infrastructure/Postgres/SchemaMigrations/1784401962564-MigrationName.ts +0 -101
  141. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784401962564-MigrationName.js +0 -40
  142. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784401962564-MigrationName.js.map +0 -1
@@ -18,6 +18,25 @@ import MonitorFeedService from "./MonitorFeedService";
18
18
  import { MonitorFeedEventType } from "../../Models/DatabaseModels/MonitorFeed";
19
19
  import MonitorStatus from "../../Models/DatabaseModels/MonitorStatus";
20
20
  import MonitorStatusService from "./MonitorStatusService";
21
+ import ServerException from "../../Types/Exception/ServerException";
22
+
23
+ /*
24
+ * Thrown by onBeforeCreate when the incoming status is the same as the status of
25
+ * the row immediately before it. Probe ingest call sites (Utils/Monitor/MonitorStatusTimeline
26
+ * and Utils/Monitor/MonitorResource) match on this exact message to treat the
27
+ * duplicate as an idempotent no-op, so the text must not change.
28
+ */
29
+ export const MONITOR_STATUS_SAME_AS_PREVIOUS_ERROR_MESSAGE: string =
30
+ "Monitor Status cannot be same as previous status.";
31
+
32
+ /*
33
+ * Thrown by create() when the per-monitor mutex cannot be acquired. The timeline
34
+ * write is refused rather than performed unlocked - see the comment on the lock
35
+ * acquisition below. Probe ingest call sites match on this to log and skip
36
+ * instead of failing the whole ingest run.
37
+ */
38
+ export const MONITOR_STATUS_TIMELINE_LOCK_ERROR_MESSAGE: string =
39
+ "Could not acquire the monitor status timeline lock for this monitor.";
21
40
 
22
41
  export class Service extends DatabaseService<MonitorStatusTimeline> {
23
42
  public constructor() {
@@ -25,13 +44,35 @@ export class Service extends DatabaseService<MonitorStatusTimeline> {
25
44
  }
26
45
 
27
46
  @CaptureSpan()
28
- protected override async onBeforeCreate(
47
+ public override async create(
29
48
  createBy: CreateBy<MonitorStatusTimeline>,
30
- ): Promise<OnCreate<MonitorStatusTimeline>> {
31
- if (!createBy.data.monitorId) {
32
- throw new BadDataException("monitorId is null");
49
+ ): Promise<MonitorStatusTimeline> {
50
+ /*
51
+ * The per-monitor mutex is owned here, around the whole create, rather than
52
+ * inside onBeforeCreate. DatabaseService.create() invokes onBeforeCreate and
53
+ * then runs validation, permission checks and the INSERT before it ever
54
+ * reaches onCreateSuccess, all OUTSIDE any try/catch this service can hook
55
+ * (onCreateError never fires for a throw raised before the INSERT). So a
56
+ * mutex acquired in onBeforeCreate and released in onCreateSuccess leaks on
57
+ * any throw in between, and a leaked redis-semaphore mutex never expires - it
58
+ * keeps refreshing its own Redis key for the life of the process, which would
59
+ * block every later create for that monitor until acquireTimeout. Holding it
60
+ * in a try/finally here releases it on every path.
61
+ *
62
+ * The critical section that must be serialized per monitor spans reading the
63
+ * predecessor row (onBeforeCreate) through closing it (onCreateSuccess), so
64
+ * the lock is held across the entire super.create(), not just one hook.
65
+ */
66
+ if (createBy.props.ignoreHooks || !createBy.data.monitorId) {
67
+ // No predecessor bookkeeping runs on these paths, so no serialization is needed.
68
+ return await super.create(createBy);
33
69
  }
34
70
 
71
+ const logAttributes: LogAttributes = {
72
+ projectId: createBy.data.projectId?.toString(),
73
+ monitorId: createBy.data.monitorId?.toString(),
74
+ } as LogAttributes;
75
+
35
76
  let mutex: SemaphoreMutex | null = null;
36
77
 
37
78
  try {
@@ -40,12 +81,79 @@ export class Service extends DatabaseService<MonitorStatusTimeline> {
40
81
  namespace: "MonitorStatusTimeline.create",
41
82
  });
42
83
  } catch (e) {
43
- logger.error(e, {
44
- projectId: createBy.data.projectId?.toString(),
45
- monitorId: createBy.data.monitorId?.toString(),
46
- } as LogAttributes);
84
+ /*
85
+ * Fail closed. This used to fall through and INSERT UNLOCKED, which let two
86
+ * concurrent writers resolve the same predecessor row, both pass the
87
+ * same-as-previous check, and both INSERT a status row milliseconds apart.
88
+ * Only the later row is ever closed (the next writer resolves its
89
+ * predecessor with ORDER BY startsAt DESC LIMIT 1), so the earlier row is
90
+ * orphaned with endsAt = NULL permanently and is read back as unbounded
91
+ * downtime. Refusing the write is strictly safer: the monitor keeps its
92
+ * current status and the next probe result for the same monitor
93
+ * re-evaluates the same criteria and recreates the status change.
94
+ */
95
+ logger.error(e, logAttributes);
96
+ throw new ServerException(MONITOR_STATUS_TIMELINE_LOCK_ERROR_MESSAGE);
97
+ }
98
+
99
+ let createdItem: MonitorStatusTimeline;
100
+
101
+ try {
102
+ createdItem = await super.create(createBy);
103
+ } finally {
104
+ await this.releaseMutex(mutex, logAttributes);
47
105
  }
48
106
 
107
+ /*
108
+ * The feed item and its workspace notification run AFTER the mutex is
109
+ * released: they can involve third-party HTTP (Slack/Teams) with unbounded
110
+ * latency, and holding the per-monitor lock across them would block every
111
+ * concurrent status write for this monitor until acquireTimeout - turning
112
+ * one slow webhook into refused status transitions. Only the predecessor
113
+ * read -> INSERT -> predecessor close needs the lock, and all of that has
114
+ * completed by this point. (The pre-fail-closed code released the lock at
115
+ * this same boundary, before the feed block.)
116
+ */
117
+ await this.createStatusChangeFeedItem(createdItem, createBy);
118
+
119
+ return createdItem;
120
+ }
121
+
122
+ @CaptureSpan()
123
+ protected override async onBeforeCreate(
124
+ createBy: CreateBy<MonitorStatusTimeline>,
125
+ ): Promise<OnCreate<MonitorStatusTimeline>> {
126
+ if (!createBy.data.monitorId) {
127
+ throw new BadDataException("monitorId is null");
128
+ }
129
+
130
+ const logAttributes: LogAttributes = {
131
+ projectId: createBy.data.projectId?.toString(),
132
+ monitorId: createBy.data.monitorId?.toString(),
133
+ } as LogAttributes;
134
+
135
+ /*
136
+ * The per-monitor mutex that serializes this read-modify-write is acquired
137
+ * and released in create() (see the comment there); it is held for the whole
138
+ * duration of this hook.
139
+ */
140
+ return await this.buildOnCreate(
141
+ createBy,
142
+ createBy.data.monitorId,
143
+ logAttributes,
144
+ );
145
+ }
146
+
147
+ /*
148
+ * Body of onBeforeCreate, split out to keep the null-narrowing of monitorId in
149
+ * one place: it is passed in already narrowed so it can never reach a query as
150
+ * undefined, which would widen the query to every monitor.
151
+ */
152
+ private async buildOnCreate(
153
+ createBy: CreateBy<MonitorStatusTimeline>,
154
+ monitorId: ObjectID,
155
+ logAttributes: LogAttributes,
156
+ ): Promise<OnCreate<MonitorStatusTimeline>> {
49
157
  if (!createBy.data.startsAt) {
50
158
  createBy.data.startsAt = OneUptimeDate.getCurrentDate();
51
159
  }
@@ -85,7 +193,7 @@ export class Service extends DatabaseService<MonitorStatusTimeline> {
85
193
 
86
194
  const stateBeforeThis: MonitorStatusTimeline | null = await this.findOneBy({
87
195
  query: {
88
- monitorId: createBy.data.monitorId,
196
+ monitorId: monitorId,
89
197
  startsAt: QueryHelper.lessThanEqualTo(createBy.data.startsAt),
90
198
  },
91
199
  sort: {
@@ -101,14 +209,8 @@ export class Service extends DatabaseService<MonitorStatusTimeline> {
101
209
  },
102
210
  });
103
211
 
104
- logger.debug("State Before this", {
105
- projectId: createBy.data.projectId?.toString(),
106
- monitorId: createBy.data.monitorId?.toString(),
107
- } as LogAttributes);
108
- logger.debug(stateBeforeThis, {
109
- projectId: createBy.data.projectId?.toString(),
110
- monitorId: createBy.data.monitorId?.toString(),
111
- } as LogAttributes);
212
+ logger.debug("State Before this", logAttributes);
213
+ logger.debug(stateBeforeThis, logAttributes);
112
214
 
113
215
  // If this is the first state, then do not notify the owner.
114
216
  if (!stateBeforeThis) {
@@ -126,15 +228,26 @@ export class Service extends DatabaseService<MonitorStatusTimeline> {
126
228
  stateBeforeThis.monitorStatusId.toString() ===
127
229
  monitorStatusId.toString()
128
230
  ) {
231
+ /*
232
+ * Logged above debug on purpose. This is the exact point at which an
233
+ * orphaned (endsAt = NULL) row would have been created had the mutex
234
+ * fallen through unlocked, so it is the signal to alert on: a sustained
235
+ * rate here means two writers are racing for the same monitor.
236
+ */
237
+ logger.warn(
238
+ `MonitorStatusTimeline: rejecting duplicate status ${monitorStatusId.toString()} for monitor ${monitorId.toString()}. The preceding status starting at ${stateBeforeThis.startsAt?.toString()} is already this status.`,
239
+ logAttributes,
240
+ );
241
+
129
242
  throw new BadDataException(
130
- "Monitor Status cannot be same as previous status.",
243
+ MONITOR_STATUS_SAME_AS_PREVIOUS_ERROR_MESSAGE,
131
244
  );
132
245
  }
133
246
  }
134
247
 
135
248
  const stateAfterThis: MonitorStatusTimeline | null = await this.findOneBy({
136
249
  query: {
137
- monitorId: createBy.data.monitorId,
250
+ monitorId: monitorId,
138
251
  startsAt: QueryHelper.greaterThan(createBy.data.startsAt),
139
252
  },
140
253
  sort: {
@@ -170,30 +283,108 @@ export class Service extends DatabaseService<MonitorStatusTimeline> {
170
283
  }
171
284
  }
172
285
 
173
- logger.debug("State After this", {
174
- projectId: createBy.data.projectId?.toString(),
175
- monitorId: createBy.data.monitorId?.toString(),
176
- } as LogAttributes);
177
- logger.debug(stateAfterThis, {
178
- projectId: createBy.data.projectId?.toString(),
179
- monitorId: createBy.data.monitorId?.toString(),
180
- } as LogAttributes);
286
+ logger.debug("State After this", logAttributes);
287
+ logger.debug(stateAfterThis, logAttributes);
181
288
 
182
289
  return {
183
290
  createBy,
184
291
  carryForward: {
185
292
  statusTimelineBeforeThisStatus: stateBeforeThis || null,
186
293
  statusTimelineAfterThisStatus: stateAfterThis || null,
187
- mutex: mutex,
188
294
  },
189
295
  };
190
296
  }
191
297
 
298
+ /*
299
+ * Releases the per-monitor mutex. Never throws: a failed release must not mask
300
+ * the error we are already unwinding, and must not fail an otherwise successful
301
+ * create. Semaphore.release stops the refresh interval before it talks to
302
+ * Redis, so even if the Redis call fails the key expires on its own lockTimeout.
303
+ */
304
+ private async releaseMutex(
305
+ mutex: SemaphoreMutex | null | undefined,
306
+ logAttributes: LogAttributes,
307
+ ): Promise<void> {
308
+ if (!mutex) {
309
+ return;
310
+ }
311
+
312
+ try {
313
+ await Semaphore.release(mutex);
314
+ } catch (err) {
315
+ logger.error(err, logAttributes);
316
+ }
317
+ }
318
+
319
+ /*
320
+ * Closes the status timeline row that precedes the row we just created.
321
+ *
322
+ * The update is conditional instead of a blind updateOneById: it only touches
323
+ * the row if it starts at or before the new row (startsAt <= endsAt) and is
324
+ * either still open or currently closed at or after the new row's startsAt
325
+ * (endsAt >= :endsAt OR endsAt IS NULL). That keeps a concurrent or
326
+ * out-of-order writer from extending a row forward over a gap it does not own,
327
+ * and keeps a backfilled row from being closed at a time earlier than the row
328
+ * that actually follows it.
329
+ *
330
+ * startsAt uses <= and not <: on an exact startsAt tie (a backfill inserted at
331
+ * the same timestamp as the open predecessor) the predecessor must still be
332
+ * closed - at zero duration, which is harmless. With a strict < the tie would
333
+ * leave BOTH rows open forever: the reconciler deliberately never closes
334
+ * startsAt ties (its successor predicate is strictly later), so nothing would
335
+ * ever repair it, and the pair would read back as unbounded downtime.
336
+ *
337
+ * Note this closes only the single immediately-preceding row. If a monitor has
338
+ * more than one open row (the orphans this bug produced), the older ones are
339
+ * deliberately left alone: closing them here at the new row's startsAt would
340
+ * turn a near-zero-duration orphan into months of recorded downtime, which is
341
+ * worse than the current state and not repairable from the read path. Those
342
+ * rows must be closed at the startsAt of the row that actually follows each of
343
+ * them, which is the reconciler job's responsibility, not this write path's.
344
+ */
345
+ private async closePrecedingStatusTimeline(data: {
346
+ precedingStatusTimelineId: ObjectID;
347
+ endsAt: Date;
348
+ logAttributes: LogAttributes;
349
+ }): Promise<void> {
350
+ const updatedCount: number = await this.updateOneBy({
351
+ query: {
352
+ _id: data.precedingStatusTimelineId.toString(),
353
+ startsAt: QueryHelper.lessThanEqualTo(data.endsAt),
354
+ endsAt: QueryHelper.greaterThanEqualToOrNull(data.endsAt),
355
+ },
356
+ data: {
357
+ endsAt: data.endsAt,
358
+ },
359
+ props: {
360
+ isRoot: true,
361
+ },
362
+ });
363
+
364
+ if (updatedCount === 0) {
365
+ /*
366
+ * The preceding row moved under us between onBeforeCreate reading it and
367
+ * this update - it was already closed at an earlier time, or it no longer
368
+ * starts before the new row. Leaving it as-is is correct; surface it so the
369
+ * reconciler has a signal to look at this monitor.
370
+ */
371
+ logger.warn(
372
+ `MonitorStatusTimeline: did not close preceding status timeline ${data.precedingStatusTimelineId.toString()} at ${data.endsAt.toString()}; it is no longer open and older than the new status.`,
373
+ data.logAttributes,
374
+ );
375
+ }
376
+ }
377
+
192
378
  @CaptureSpan()
193
379
  protected override async onCreateSuccess(
194
380
  onCreate: OnCreate<MonitorStatusTimeline>,
195
381
  createdItem: MonitorStatusTimeline,
196
382
  ): Promise<MonitorStatusTimeline> {
383
+ const logAttributes: LogAttributes = {
384
+ projectId: createdItem.projectId?.toString(),
385
+ monitorId: createdItem.monitorId?.toString(),
386
+ } as LogAttributes;
387
+
197
388
  if (!createdItem.monitorId) {
198
389
  throw new BadDataException("monitorId is null");
199
390
  }
@@ -202,34 +393,28 @@ export class Service extends DatabaseService<MonitorStatusTimeline> {
202
393
  throw new BadDataException("monitorStatusId is null");
203
394
  }
204
395
 
396
+ /*
397
+ * Everything below runs while the per-monitor mutex acquired in create() is
398
+ * still held, so the read of the predecessor in onBeforeCreate and the close
399
+ * of it here cannot interleave with another writer for the same monitor.
400
+ */
401
+
205
402
  // update the last status as ended.
206
403
 
207
- logger.debug("Status Timeline Before this", {
208
- projectId: createdItem.projectId?.toString(),
209
- monitorId: createdItem.monitorId?.toString(),
210
- } as LogAttributes);
211
- logger.debug(onCreate.carryForward.statusTimelineBeforeThisStatus, {
212
- projectId: createdItem.projectId?.toString(),
213
- monitorId: createdItem.monitorId?.toString(),
214
- } as LogAttributes);
404
+ logger.debug("Status Timeline Before this", logAttributes);
405
+ logger.debug(
406
+ onCreate.carryForward.statusTimelineBeforeThisStatus,
407
+ logAttributes,
408
+ );
215
409
 
216
- logger.debug("Status Timeline After this", {
217
- projectId: createdItem.projectId?.toString(),
218
- monitorId: createdItem.monitorId?.toString(),
219
- } as LogAttributes);
220
- logger.debug(onCreate.carryForward.statusTimelineAfterThisStatus, {
221
- projectId: createdItem.projectId?.toString(),
222
- monitorId: createdItem.monitorId?.toString(),
223
- } as LogAttributes);
410
+ logger.debug("Status Timeline After this", logAttributes);
411
+ logger.debug(
412
+ onCreate.carryForward.statusTimelineAfterThisStatus,
413
+ logAttributes,
414
+ );
224
415
 
225
- logger.debug("Created Item", {
226
- projectId: createdItem.projectId?.toString(),
227
- monitorId: createdItem.monitorId?.toString(),
228
- } as LogAttributes);
229
- logger.debug(createdItem, {
230
- projectId: createdItem.projectId?.toString(),
231
- monitorId: createdItem.monitorId?.toString(),
232
- } as LogAttributes);
416
+ logger.debug("Created Item", logAttributes);
417
+ logger.debug(createdItem, logAttributes);
233
418
 
234
419
  /*
235
420
  * now there are three cases.
@@ -237,57 +422,49 @@ export class Service extends DatabaseService<MonitorStatusTimeline> {
237
422
  */
238
423
  if (!onCreate.carryForward.statusTimelineBeforeThisStatus) {
239
424
  // This is the first status, no need to update previous status.
240
- logger.debug("This is the first status.", {
241
- projectId: createdItem.projectId?.toString(),
242
- monitorId: createdItem.monitorId?.toString(),
243
- } as LogAttributes);
425
+ logger.debug("This is the first status.", logAttributes);
244
426
  } else if (!onCreate.carryForward.statusTimelineAfterThisStatus) {
245
427
  /*
246
428
  * 2. This is the last status.
247
429
  * Update the previous status to end at the start of this status.
248
430
  */
249
- await this.updateOneById({
250
- id: onCreate.carryForward.statusTimelineBeforeThisStatus.id!,
251
- data: {
252
- endsAt: createdItem.startsAt!,
253
- },
254
- props: {
255
- isRoot: true,
256
- },
431
+ await this.closePrecedingStatusTimeline({
432
+ precedingStatusTimelineId:
433
+ onCreate.carryForward.statusTimelineBeforeThisStatus.id!,
434
+ endsAt: createdItem.startsAt!,
435
+ logAttributes: logAttributes,
257
436
  });
258
- logger.debug("This is the last status.", {
259
- projectId: createdItem.projectId?.toString(),
260
- monitorId: createdItem.monitorId?.toString(),
261
- } as LogAttributes);
437
+ logger.debug("This is the last status.", logAttributes);
262
438
  } else {
263
439
  /*
264
440
  * 3. This is in the middle.
265
441
  * Update the previous status to end at the start of this status.
266
442
  */
267
- await this.updateOneById({
268
- id: onCreate.carryForward.statusTimelineBeforeThisStatus.id!,
269
- data: {
270
- endsAt: createdItem.startsAt!,
271
- },
272
- props: {
273
- isRoot: true,
274
- },
443
+ await this.closePrecedingStatusTimeline({
444
+ precedingStatusTimelineId:
445
+ onCreate.carryForward.statusTimelineBeforeThisStatus.id!,
446
+ endsAt: createdItem.startsAt!,
447
+ logAttributes: logAttributes,
275
448
  });
276
449
 
277
- // Update the next status to start at the end of this status.
278
- await this.updateOneById({
279
- id: onCreate.carryForward.statusTimelineAfterThisStatus.id!,
280
- data: {
281
- startsAt: createdItem.endsAt!,
282
- },
283
- props: {
284
- isRoot: true,
285
- },
286
- });
287
- logger.debug("This status is in the middle.", {
288
- projectId: createdItem.projectId?.toString(),
289
- monitorId: createdItem.monitorId?.toString(),
290
- } as LogAttributes);
450
+ /*
451
+ * Update the next status to start at the end of this status. endsAt was
452
+ * set to the next row's startsAt in onBeforeCreate, so this is normally a
453
+ * no-op; it is guarded because writing an empty startsAt onto the next row
454
+ * would corrupt it.
455
+ */
456
+ if (createdItem.endsAt) {
457
+ await this.updateOneById({
458
+ id: onCreate.carryForward.statusTimelineAfterThisStatus.id!,
459
+ data: {
460
+ startsAt: createdItem.endsAt,
461
+ },
462
+ props: {
463
+ isRoot: true,
464
+ },
465
+ });
466
+ }
467
+ logger.debug("This status is in the middle.", logAttributes);
291
468
  }
292
469
 
293
470
  if (!createdItem.endsAt) {
@@ -303,10 +480,22 @@ export class Service extends DatabaseService<MonitorStatusTimeline> {
303
480
  props: onCreate.createBy.props,
304
481
  });
305
482
  }
483
+ return createdItem;
484
+ }
306
485
 
307
- if (onCreate.carryForward.mutex) {
308
- const mutex: SemaphoreMutex = onCreate.carryForward.mutex;
309
- await Semaphore.release(mutex);
486
+ /*
487
+ * Writes the monitor feed item (and its workspace notification, which can be
488
+ * third-party HTTP to Slack/Teams) for a status change. Called from create()
489
+ * AFTER the per-monitor mutex has been released - see the comment there. Kept
490
+ * out of onCreateSuccess on purpose: everything in onCreateSuccess runs while
491
+ * the mutex is held.
492
+ */
493
+ private async createStatusChangeFeedItem(
494
+ createdItem: MonitorStatusTimeline,
495
+ createBy: CreateBy<MonitorStatusTimeline>,
496
+ ): Promise<void> {
497
+ if (!createdItem.monitorId || !createdItem.monitorStatusId) {
498
+ return;
310
499
  }
311
500
 
312
501
  const monitorStatus: MonitorStatus | null =
@@ -354,19 +543,15 @@ export class Service extends DatabaseService<MonitorStatusTimeline> {
354
543
  ` Changed Monitor **[${monitorName}](${(await MonitorService.getMonitorLinkInDashboard(projectId!, monitorId!)).toString()}) State** to **` +
355
544
  stateName +
356
545
  "**",
357
- moreInformationInMarkdown: `**Cause:**
546
+ moreInformationInMarkdown: `**Cause:**
358
547
  ${createdItem.rootCause}`,
359
- userId: createdItem.createdByUserId || onCreate.createBy.props.userId,
548
+ userId: createdItem.createdByUserId || createBy.props.userId,
360
549
  workspaceNotification: {
361
550
  sendWorkspaceNotification: true,
362
551
  notifyUserId:
363
- createdItem.createdByUserId ||
364
- onCreate.createBy.props.userId ||
365
- undefined,
552
+ createdItem.createdByUserId || createBy.props.userId || undefined,
366
553
  },
367
554
  });
368
-
369
- return createdItem;
370
555
  }
371
556
 
372
557
  @CaptureSpan()
@@ -65,7 +65,7 @@ import IncidentService from "./IncidentService";
65
65
  import MonitorStatusTimeline from "../../Models/DatabaseModels/MonitorStatusTimeline";
66
66
  import MonitorStatusTimelineService from "./MonitorStatusTimelineService";
67
67
  import SortOrder from "../../Types/BaseDatabase/SortOrder";
68
- import UptimeUtil from "../../Utils/Uptime/UptimeUtil";
68
+ import UptimeUtil, { UptimeWindow } from "../../Utils/Uptime/UptimeUtil";
69
69
  import UptimePrecision from "../../Types/StatusPage/UptimePrecision";
70
70
  import IP from "../../Types/IP/IP";
71
71
  import NotAuthenticatedException from "../../Types/Exception/NotAuthenticatedException";
@@ -708,24 +708,51 @@ export class Service extends DatabaseService<StatusPage> {
708
708
  let monitorStatusTimelines: Array<MonitorStatusTimeline> = [];
709
709
 
710
710
  if (data.monitorIds.length > 0) {
711
+ /*
712
+ * Select every row that actually OVERLAPS [startDate, endDate]:
713
+ * it started on or before the window ends, and it either ended on or after the window
714
+ * started or is still open (endsAt IS NULL).
715
+ *
716
+ * This used to be two queries that were concatenated - one keyed on
717
+ * `endsAt BETWEEN startDate AND endDate`, and one on `endsAt IS NULL` with NO date bound
718
+ * at all. That had two bugs. The unbounded open-row query pulled orphaned rows from
719
+ * months (or years) before the window into the report, and the endsAt-only window
720
+ * excluded any event that spanned the ENTIRE window but closed after it, so a total
721
+ * outage could render as a silent 100% uptime.
722
+ *
723
+ * `greaterThanEqualToOrNull` emits `(endsAt >= :date or endsAt IS NULL)`. Note that
724
+ * `greaterThanOrNull` emits exactly the same `>=` SQL despite its name, so the explicit
725
+ * name is used here to keep the intent readable.
726
+ *
727
+ * Supported by the (monitorId, startsAt) and (endsAt) indexes.
728
+ */
711
729
  monitorStatusTimelines = await MonitorStatusTimelineService.findBy({
712
730
  query: {
713
731
  monitorId: QueryHelper.any(data.monitorIds),
714
- endsAt: QueryHelper.inBetween(startDate, endDate),
732
+ startsAt: QueryHelper.lessThanEqualTo(endDate),
733
+ endsAt: QueryHelper.greaterThanEqualToOrNull(startDate),
715
734
  },
716
735
  select: {
717
736
  monitorId: true,
718
737
  createdAt: true,
719
738
  endsAt: true,
720
739
  startsAt: true,
740
+ /*
741
+ * `_id` is selected explicitly because UptimeUtil reads `monitorStatus.id` (a getter
742
+ * over `_id`) to match events against the downtime statuses. It works today only
743
+ * because SelectUtil.sanitizeSelect force injects `_id: true` into every relation
744
+ * select - state that dependency here rather than rely on it implicitly. Note `_id`
745
+ * is the decorated primary column; `id` itself has no TableColumnMetadata.
746
+ */
721
747
  monitorStatus: {
748
+ _id: true,
722
749
  name: true,
723
750
  color: true,
724
751
  priority: true,
725
752
  } as any,
726
753
  },
727
754
  sort: {
728
- createdAt: SortOrder.Descending,
755
+ startsAt: SortOrder.Descending,
729
756
  },
730
757
  skip: 0,
731
758
  limit: LIMIT_MAX, // This can be optimized.
@@ -734,42 +761,19 @@ export class Service extends DatabaseService<StatusPage> {
734
761
  },
735
762
  });
736
763
 
737
- monitorStatusTimelines = monitorStatusTimelines.concat(
738
- await MonitorStatusTimelineService.findBy({
739
- query: {
740
- monitorId: QueryHelper.any(data.monitorIds),
741
- endsAt: QueryHelper.isNull(),
742
- },
743
- select: {
744
- monitorId: true,
745
- createdAt: true,
746
- endsAt: true,
747
- startsAt: true,
748
- monitorStatus: {
749
- name: true,
750
- color: true,
751
- priority: true,
752
- } as any,
753
- },
754
- sort: {
755
- createdAt: SortOrder.Descending,
756
- },
757
- skip: 0,
758
- limit: LIMIT_MAX, // This can be optimized.
759
- props: {
760
- isRoot: true,
761
- },
762
- }),
763
- );
764
-
765
- // sort monitorStatusTimelines by createdAt.
764
+ /*
765
+ * Sort by startsAt and NOT by createdAt. createdAt is generated DB side with now(),
766
+ * while startsAt is generated on the worker pod with moment(). Those are different
767
+ * clocks with measured skew, so ordering by createdAt does not reliably reproduce the
768
+ * real chronological order of the timeline.
769
+ */
766
770
  monitorStatusTimelines = monitorStatusTimelines.sort(
767
771
  (a: MonitorStatusTimeline, b: MonitorStatusTimeline) => {
768
- if (!a.createdAt || !b.createdAt) {
772
+ if (!a.startsAt || !b.startsAt) {
769
773
  return 0;
770
774
  }
771
775
 
772
- return b.createdAt!.getTime() - a.createdAt!.getTime();
776
+ return b.startsAt!.getTime() - a.startsAt!.getTime();
773
777
  },
774
778
  );
775
779
  }
@@ -1201,6 +1205,18 @@ export class Service extends DatabaseService<StatusPage> {
1201
1205
  const startDate: Date = OneUptimeDate.getSomeDaysAgo(numberOfDays);
1202
1206
  const startAndEndDate: string = `${numberOfDays} days (${OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(startDate, true)} - ${OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(endDate, true)})`;
1203
1207
 
1208
+ /*
1209
+ * The report is explicitly "the last N days", so this window is what every uptime number
1210
+ * below has to be measured against. It used to be computed only to fetch the timeline and
1211
+ * was then discarded, which let an event that started before the window contribute its
1212
+ * whole duration to the downtime total, and made the denominator "first event -> now"
1213
+ * instead of the window - so the reported percentage drifted upwards every day.
1214
+ */
1215
+ const reportWindow: UptimeWindow = {
1216
+ startDate: startDate,
1217
+ endDate: endDate,
1218
+ };
1219
+
1204
1220
  if (statusPageResources.length === 0) {
1205
1221
  return {
1206
1222
  reportDates: startAndEndDate,
@@ -1260,6 +1276,7 @@ export class Service extends DatabaseService<StatusPage> {
1260
1276
  timelineForThisResource,
1261
1277
  resource.uptimePercentPrecision || UptimePrecision.TWO_DECIMAL,
1262
1278
  statusPage.downtimeMonitorStatuses!,
1279
+ reportWindow,
1263
1280
  );
1264
1281
  const downtime: {
1265
1282
  totalDowntimeInSeconds: number;
@@ -1267,6 +1284,7 @@ export class Service extends DatabaseService<StatusPage> {
1267
1284
  } = UptimeUtil.getTotalDowntimeInSeconds(
1268
1285
  timelineForThisResource,
1269
1286
  statusPage.downtimeMonitorStatuses!,
1287
+ reportWindow,
1270
1288
  );
1271
1289
 
1272
1290
  const reportItem: StatusPageReportItem = {
@@ -1299,6 +1317,7 @@ export class Service extends DatabaseService<StatusPage> {
1299
1317
  } = UptimeUtil.getTotalDowntimeInSeconds(
1300
1318
  timeline,
1301
1319
  statusPage.downtimeMonitorStatuses!,
1320
+ reportWindow,
1302
1321
  );
1303
1322
 
1304
1323
  return {
@@ -1462,6 +1481,12 @@ export class Service extends DatabaseService<StatusPage> {
1462
1481
  },
1463
1482
  monitorGroupId: true,
1464
1483
  order: true,
1484
+ /*
1485
+ * needed so the emailed report rounds to the same precision the status page renders
1486
+ * with. Without it `resource.uptimePercentPrecision` is always undefined and the
1487
+ * report silently falls back to two decimals.
1488
+ */
1489
+ uptimePercentPrecision: true,
1465
1490
  },
1466
1491
  skip: 0,
1467
1492
  limit: LIMIT_PER_PROJECT,