@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
@@ -0,0 +1,726 @@
1
+ import UptimeUtil, { UptimeWindow } from "../../../Utils/Uptime/UptimeUtil";
2
+ import Event from "../../../Utils/Uptime/Event";
3
+ import MonitorEvent from "../../../Utils/Uptime/MonitorEvent";
4
+ import { Green, Red } from "../../../Types/BrandColors";
5
+ import ObjectID from "../../../Types/ObjectID";
6
+ import MonitorStatus from "../../../Models/DatabaseModels/MonitorStatus";
7
+ import MonitorStatusTimeline from "../../../Models/DatabaseModels/MonitorStatusTimeline";
8
+ import UptimePrecision from "../../../Types/StatusPage/UptimePrecision";
9
+
10
+ /*
11
+ * Every assertion in this file is relative to a pinned "now" so the numbers are stable. The bug this
12
+ * file guards against is precisely that the uptime percentage used to drift with wall clock time.
13
+ */
14
+ const NOW: Date = new Date("2026-07-19T00:00:00.000Z");
15
+
16
+ const MONITOR_ID: ObjectID = new ObjectID(
17
+ "11111111-1111-4111-8111-111111111111",
18
+ );
19
+ const OTHER_MONITOR_ID: ObjectID = new ObjectID(
20
+ "44444444-4444-4444-8444-444444444444",
21
+ );
22
+ const OFFLINE_STATUS_ID: ObjectID = new ObjectID(
23
+ "22222222-2222-4222-8222-222222222222",
24
+ );
25
+ const OPERATIONAL_STATUS_ID: ObjectID = new ObjectID(
26
+ "33333333-3333-4333-8333-333333333333",
27
+ );
28
+
29
+ const SECONDS_IN_DAY: number = 86400;
30
+
31
+ const offlineStatus: MonitorStatus = new MonitorStatus();
32
+ offlineStatus.id = OFFLINE_STATUS_ID;
33
+ offlineStatus.name = "Offline";
34
+ offlineStatus.priority = 2;
35
+ offlineStatus.color = Red;
36
+
37
+ const downtimeStatuses: Array<MonitorStatus> = [offlineStatus];
38
+
39
+ type CreateTimelineFunction = (data: {
40
+ statusId: ObjectID;
41
+ name: string;
42
+ priority: number;
43
+ startsAt: string;
44
+ endsAt?: string | undefined;
45
+ monitorId?: ObjectID | undefined;
46
+ }) => MonitorStatusTimeline;
47
+
48
+ const createTimeline: CreateTimelineFunction = (data: {
49
+ statusId: ObjectID;
50
+ name: string;
51
+ priority: number;
52
+ startsAt: string;
53
+ endsAt?: string | undefined;
54
+ monitorId?: ObjectID | undefined;
55
+ }): MonitorStatusTimeline => {
56
+ const monitorStatus: MonitorStatus = new MonitorStatus();
57
+ monitorStatus.id = data.statusId;
58
+ monitorStatus.name = data.name;
59
+ monitorStatus.priority = data.priority;
60
+ monitorStatus.color = data.name === "Offline" ? Red : Green;
61
+
62
+ const timeline: MonitorStatusTimeline = new MonitorStatusTimeline();
63
+ timeline.monitorId = data.monitorId || MONITOR_ID;
64
+ timeline.monitorStatusId = data.statusId;
65
+ timeline.monitorStatus = monitorStatus;
66
+ timeline.startsAt = new Date(data.startsAt);
67
+
68
+ // endsAt is left unset for open rows, which is what the orphaned rows look like in the database.
69
+ if (data.endsAt) {
70
+ timeline.endsAt = new Date(data.endsAt);
71
+ }
72
+
73
+ return timeline;
74
+ };
75
+
76
+ type StatusTimelineFunction = (
77
+ startsAt: string,
78
+ endsAt?: string | undefined,
79
+ monitorId?: ObjectID | undefined,
80
+ ) => MonitorStatusTimeline;
81
+
82
+ const offline: StatusTimelineFunction = (
83
+ startsAt: string,
84
+ endsAt?: string | undefined,
85
+ monitorId?: ObjectID | undefined,
86
+ ): MonitorStatusTimeline => {
87
+ return createTimeline({
88
+ statusId: OFFLINE_STATUS_ID,
89
+ name: "Offline",
90
+ priority: 2,
91
+ startsAt,
92
+ endsAt,
93
+ monitorId,
94
+ });
95
+ };
96
+
97
+ const operational: StatusTimelineFunction = (
98
+ startsAt: string,
99
+ endsAt?: string | undefined,
100
+ monitorId?: ObjectID | undefined,
101
+ ): MonitorStatusTimeline => {
102
+ return createTimeline({
103
+ statusId: OPERATIONAL_STATUS_ID,
104
+ name: "Operational",
105
+ priority: 1,
106
+ startsAt,
107
+ endsAt,
108
+ monitorId,
109
+ });
110
+ };
111
+
112
+ describe("UptimeUtil", () => {
113
+ beforeAll(() => {
114
+ jest.useFakeTimers({ now: NOW });
115
+ });
116
+
117
+ afterAll(() => {
118
+ jest.useRealTimers();
119
+ });
120
+
121
+ describe("the production incident: an orphaned endsAt = null row from months ago", () => {
122
+ /*
123
+ * Two rows for the same monitor, both with endsAt = null. The Offline row was orphaned by a race
124
+ * in MonitorStatusTimelineService and never closed. The customer report window is a 31 day window
125
+ * five months later, and it rendered 113 days of downtime and 43.76% uptime.
126
+ */
127
+ const timelines: Array<MonitorStatusTimeline> = [
128
+ offline("2025-12-11T18:03:00.955Z"),
129
+ operational("2026-04-04T00:19:20.189Z"),
130
+ ];
131
+
132
+ const window: UptimeWindow = {
133
+ startDate: new Date("2026-05-31T00:00:00.000Z"),
134
+ endDate: new Date("2026-07-01T00:00:00.000Z"),
135
+ };
136
+
137
+ it("reproduces the bug when no window is supplied", () => {
138
+ const { totalDowntimeInSeconds, totalSecondsInTimePeriod } =
139
+ UptimeUtil.getTotalDowntimeInSeconds(timelines, downtimeStatuses);
140
+
141
+ /*
142
+ * The orphaned Offline row swallows everything up to the next event, which is 113 days,
143
+ * 6 hours and 16 minutes later. This is the number the customer saw on their report.
144
+ */
145
+ expect(totalDowntimeInSeconds).toBe(9785779.234);
146
+ expect(Math.floor(totalDowntimeInSeconds / SECONDS_IN_DAY)).toBe(113);
147
+
148
+ // and the denominator is "first event -> now", which is why the number drifted daily.
149
+ expect(totalSecondsInTimePeriod).toBe(18943019.045);
150
+
151
+ const uptimePercentage: number = UptimeUtil.calculateUptimePercentage(
152
+ timelines,
153
+ UptimePrecision.TWO_DECIMAL,
154
+ downtimeStatuses,
155
+ );
156
+
157
+ expect(uptimePercentage).toBe(48.34);
158
+ expect(uptimePercentage).toBeLessThan(100);
159
+ });
160
+
161
+ it("reports zero downtime and 100% uptime when the window is supplied", () => {
162
+ const { totalDowntimeInSeconds, totalSecondsInTimePeriod } =
163
+ UptimeUtil.getTotalDowntimeInSeconds(
164
+ timelines,
165
+ downtimeStatuses,
166
+ window,
167
+ );
168
+
169
+ expect(totalDowntimeInSeconds).toBe(0);
170
+
171
+ // the denominator is the 31 day window, not "first event -> now".
172
+ expect(totalSecondsInTimePeriod).toBe(31 * SECONDS_IN_DAY);
173
+
174
+ expect(
175
+ UptimeUtil.calculateUptimePercentage(
176
+ timelines,
177
+ UptimePrecision.TWO_DECIMAL,
178
+ downtimeStatuses,
179
+ window,
180
+ ),
181
+ ).toBe(100);
182
+ });
183
+
184
+ it("drops the orphaned row and clips the surviving event to the window", () => {
185
+ const events: Array<MonitorEvent> = UptimeUtil.getMonitorEvents(
186
+ timelines,
187
+ window,
188
+ );
189
+
190
+ expect(events).toHaveLength(1);
191
+ expect(events[0]!.label).toBe("Operational");
192
+ expect(events[0]!.startDate.toISOString()).toBe(
193
+ window.startDate.toISOString(),
194
+ );
195
+ expect(events[0]!.endDate.toISOString()).toBe(
196
+ window.endDate.toISOString(),
197
+ );
198
+ });
199
+
200
+ it("does not drift as wall clock time moves forward", () => {
201
+ const uptimeToday: number = UptimeUtil.calculateUptimePercentage(
202
+ timelines,
203
+ UptimePrecision.THREE_DECIMAL,
204
+ downtimeStatuses,
205
+ window,
206
+ );
207
+
208
+ jest.setSystemTime(new Date("2026-09-19T00:00:00.000Z"));
209
+
210
+ const uptimeTwoMonthsLater: number = UptimeUtil.calculateUptimePercentage(
211
+ timelines,
212
+ UptimePrecision.THREE_DECIMAL,
213
+ downtimeStatuses,
214
+ window,
215
+ );
216
+
217
+ jest.setSystemTime(NOW);
218
+
219
+ expect(uptimeTwoMonthsLater).toBe(uptimeToday);
220
+ });
221
+ });
222
+
223
+ describe("clipping events to the window", () => {
224
+ const window: UptimeWindow = {
225
+ startDate: new Date("2026-05-31T00:00:00.000Z"),
226
+ endDate: new Date("2026-07-01T00:00:00.000Z"),
227
+ };
228
+
229
+ it("counts an event that spans the whole window as full downtime", () => {
230
+ const timelines: Array<MonitorStatusTimeline> = [
231
+ offline("2026-01-01T00:00:00.000Z", "2026-12-01T00:00:00.000Z"),
232
+ ];
233
+
234
+ const { totalDowntimeInSeconds, totalSecondsInTimePeriod } =
235
+ UptimeUtil.getTotalDowntimeInSeconds(
236
+ timelines,
237
+ downtimeStatuses,
238
+ window,
239
+ );
240
+
241
+ expect(totalDowntimeInSeconds).toBe(31 * SECONDS_IN_DAY);
242
+ expect(totalSecondsInTimePeriod).toBe(31 * SECONDS_IN_DAY);
243
+
244
+ expect(
245
+ UptimeUtil.calculateUptimePercentage(
246
+ timelines,
247
+ UptimePrecision.TWO_DECIMAL,
248
+ downtimeStatuses,
249
+ window,
250
+ ),
251
+ ).toBe(0);
252
+ });
253
+
254
+ it("clips an event that overlaps the start of the window", () => {
255
+ const timelines: Array<MonitorStatusTimeline> = [
256
+ offline("2026-05-20T00:00:00.000Z", "2026-06-05T00:00:00.000Z"),
257
+ ];
258
+
259
+ const events: Array<MonitorEvent> = UptimeUtil.getMonitorEvents(
260
+ timelines,
261
+ window,
262
+ );
263
+
264
+ expect(events).toHaveLength(1);
265
+ expect(events[0]!.startDate.toISOString()).toBe(
266
+ window.startDate.toISOString(),
267
+ );
268
+ expect(events[0]!.endDate.toISOString()).toBe("2026-06-05T00:00:00.000Z");
269
+
270
+ // 31 May -> 5 June is 5 days.
271
+ expect(
272
+ UptimeUtil.getTotalDowntimeInSeconds(
273
+ timelines,
274
+ downtimeStatuses,
275
+ window,
276
+ ).totalDowntimeInSeconds,
277
+ ).toBe(5 * SECONDS_IN_DAY);
278
+ });
279
+
280
+ it("clips an event that overlaps the end of the window", () => {
281
+ const timelines: Array<MonitorStatusTimeline> = [
282
+ offline("2026-06-25T00:00:00.000Z", "2026-07-10T00:00:00.000Z"),
283
+ ];
284
+
285
+ const events: Array<MonitorEvent> = UptimeUtil.getMonitorEvents(
286
+ timelines,
287
+ window,
288
+ );
289
+
290
+ expect(events).toHaveLength(1);
291
+ expect(events[0]!.startDate.toISOString()).toBe(
292
+ "2026-06-25T00:00:00.000Z",
293
+ );
294
+ expect(events[0]!.endDate.toISOString()).toBe(
295
+ window.endDate.toISOString(),
296
+ );
297
+
298
+ // 25 June -> 1 July is 6 days.
299
+ expect(
300
+ UptimeUtil.getTotalDowntimeInSeconds(
301
+ timelines,
302
+ downtimeStatuses,
303
+ window,
304
+ ).totalDowntimeInSeconds,
305
+ ).toBe(6 * SECONDS_IN_DAY);
306
+ });
307
+
308
+ it("drops an event that ends before the window starts", () => {
309
+ const timelines: Array<MonitorStatusTimeline> = [
310
+ offline("2026-01-01T00:00:00.000Z", "2026-01-02T00:00:00.000Z"),
311
+ ];
312
+
313
+ expect(UptimeUtil.getMonitorEvents(timelines, window)).toHaveLength(0);
314
+
315
+ const { totalDowntimeInSeconds, totalSecondsInTimePeriod } =
316
+ UptimeUtil.getTotalDowntimeInSeconds(
317
+ timelines,
318
+ downtimeStatuses,
319
+ window,
320
+ );
321
+
322
+ expect(totalDowntimeInSeconds).toBe(0);
323
+ expect(totalSecondsInTimePeriod).toBe(31 * SECONDS_IN_DAY);
324
+ });
325
+
326
+ it("drops an event that starts after the window ends", () => {
327
+ const timelines: Array<MonitorStatusTimeline> = [
328
+ offline("2026-08-01T00:00:00.000Z", "2026-08-02T00:00:00.000Z"),
329
+ ];
330
+
331
+ expect(UptimeUtil.getMonitorEvents(timelines, window)).toHaveLength(0);
332
+ expect(
333
+ UptimeUtil.getTotalDowntimeInSeconds(
334
+ timelines,
335
+ downtimeStatuses,
336
+ window,
337
+ ).totalDowntimeInSeconds,
338
+ ).toBe(0);
339
+ });
340
+
341
+ it("drops an event that only touches the window edge", () => {
342
+ const timelines: Array<MonitorStatusTimeline> = [
343
+ offline("2026-05-01T00:00:00.000Z", "2026-05-31T00:00:00.000Z"),
344
+ ];
345
+
346
+ expect(UptimeUtil.getMonitorEvents(timelines, window)).toHaveLength(0);
347
+ });
348
+
349
+ it("clips events for every monitor and keeps them attributed correctly", () => {
350
+ const timelines: Array<MonitorStatusTimeline> = [
351
+ offline("2026-01-01T00:00:00.000Z", "2026-06-02T00:00:00.000Z"),
352
+ offline(
353
+ "2026-06-20T00:00:00.000Z",
354
+ "2026-09-01T00:00:00.000Z",
355
+ OTHER_MONITOR_ID,
356
+ ),
357
+ ];
358
+
359
+ const events: Array<MonitorEvent> = UptimeUtil.getMonitorEvents(
360
+ timelines,
361
+ window,
362
+ );
363
+
364
+ expect(events).toHaveLength(2);
365
+
366
+ const firstEvent: MonitorEvent = events[0]!;
367
+ const secondEvent: MonitorEvent = events[1]!;
368
+
369
+ expect(firstEvent.monitorId.toString()).toBe(MONITOR_ID.toString());
370
+ expect(firstEvent.startDate.toISOString()).toBe(
371
+ window.startDate.toISOString(),
372
+ );
373
+ expect(firstEvent.endDate.toISOString()).toBe("2026-06-02T00:00:00.000Z");
374
+
375
+ expect(secondEvent.monitorId.toString()).toBe(
376
+ OTHER_MONITOR_ID.toString(),
377
+ );
378
+ expect(secondEvent.endDate.toISOString()).toBe(
379
+ window.endDate.toISOString(),
380
+ );
381
+ });
382
+
383
+ it("clips correctly no matter what order the rows arrive in", () => {
384
+ const inOrder: Array<MonitorStatusTimeline> = [
385
+ offline("2026-06-01T00:00:00.000Z", "2026-06-03T00:00:00.000Z"),
386
+ operational("2026-06-03T00:00:00.000Z", "2026-06-10T00:00:00.000Z"),
387
+ ];
388
+
389
+ const outOfOrder: Array<MonitorStatusTimeline> = [
390
+ inOrder[1]!,
391
+ inOrder[0]!,
392
+ ];
393
+
394
+ expect(
395
+ UptimeUtil.getTotalDowntimeInSeconds(inOrder, downtimeStatuses, window)
396
+ .totalDowntimeInSeconds,
397
+ ).toBe(
398
+ UptimeUtil.getTotalDowntimeInSeconds(
399
+ outOfOrder,
400
+ downtimeStatuses,
401
+ window,
402
+ ).totalDowntimeInSeconds,
403
+ );
404
+
405
+ expect(
406
+ UptimeUtil.getTotalDowntimeInSeconds(inOrder, downtimeStatuses, window)
407
+ .totalDowntimeInSeconds,
408
+ ).toBe(2 * SECONDS_IN_DAY);
409
+ });
410
+ });
411
+
412
+ describe("open (endsAt = null) rows", () => {
413
+ it("runs a single open row up to now and no further", () => {
414
+ // window ends in the future. now is 19 July.
415
+ const window: UptimeWindow = {
416
+ startDate: new Date("2026-07-01T00:00:00.000Z"),
417
+ endDate: new Date("2026-08-01T00:00:00.000Z"),
418
+ };
419
+
420
+ const timelines: Array<MonitorStatusTimeline> = [
421
+ operational("2026-06-01T00:00:00.000Z", "2026-07-17T00:00:00.000Z"),
422
+ offline("2026-07-17T00:00:00.000Z"),
423
+ ];
424
+
425
+ const { totalDowntimeInSeconds, totalSecondsInTimePeriod } =
426
+ UptimeUtil.getTotalDowntimeInSeconds(
427
+ timelines,
428
+ downtimeStatuses,
429
+ window,
430
+ );
431
+
432
+ // 17 July -> now (19 July) is 2 days. The open row must not run to the window end.
433
+ expect(totalDowntimeInSeconds).toBe(2 * SECONDS_IN_DAY);
434
+
435
+ /*
436
+ * the denominator is clipped to now too, so it is 1 July -> 19 July. (The operational
437
+ * row covers the window start, so the first-event clamp does not move it.)
438
+ */
439
+ expect(totalSecondsInTimePeriod).toBe(18 * SECONDS_IN_DAY);
440
+
441
+ expect(
442
+ UptimeUtil.calculateUptimePercentage(
443
+ timelines,
444
+ UptimePrecision.TWO_DECIMAL,
445
+ downtimeStatuses,
446
+ window,
447
+ ),
448
+ ).toBe(88.88);
449
+ });
450
+
451
+ it("measures a monitor younger than the window from its first event, not the window start", () => {
452
+ /*
453
+ * A monitor created on 17 July that has been Offline its whole life, reported over a
454
+ * 31 day window. If the denominator were the whole window, the 16 days before the
455
+ * monitor existed would count as uptime and this would report ~88.9% for a monitor
456
+ * that has NEVER been up. The window start is clamped forward to the first event, so
457
+ * both the numerator and the denominator are "17 July -> now" and the answer is 0% -
458
+ * the same answer the windowless "first event -> now" denominator gives.
459
+ */
460
+ const window: UptimeWindow = {
461
+ startDate: new Date("2026-07-01T00:00:00.000Z"),
462
+ endDate: new Date("2026-08-01T00:00:00.000Z"),
463
+ };
464
+
465
+ const timelines: Array<MonitorStatusTimeline> = [
466
+ offline("2026-07-17T00:00:00.000Z"),
467
+ ];
468
+
469
+ const { totalDowntimeInSeconds, totalSecondsInTimePeriod } =
470
+ UptimeUtil.getTotalDowntimeInSeconds(
471
+ timelines,
472
+ downtimeStatuses,
473
+ window,
474
+ );
475
+
476
+ expect(totalDowntimeInSeconds).toBe(2 * SECONDS_IN_DAY);
477
+ expect(totalSecondsInTimePeriod).toBe(2 * SECONDS_IN_DAY);
478
+
479
+ expect(
480
+ UptimeUtil.calculateUptimePercentage(
481
+ timelines,
482
+ UptimePrecision.TWO_DECIMAL,
483
+ downtimeStatuses,
484
+ window,
485
+ ),
486
+ ).toBe(0);
487
+ });
488
+
489
+ it("closes an open row at the start of the next row for the same monitor", () => {
490
+ const window: UptimeWindow = {
491
+ startDate: new Date("2026-06-01T00:00:00.000Z"),
492
+ endDate: new Date("2026-07-01T00:00:00.000Z"),
493
+ };
494
+
495
+ const timelines: Array<MonitorStatusTimeline> = [
496
+ offline("2026-06-05T00:00:00.000Z"),
497
+ operational("2026-06-08T00:00:00.000Z"),
498
+ ];
499
+
500
+ expect(
501
+ UptimeUtil.getTotalDowntimeInSeconds(
502
+ timelines,
503
+ downtimeStatuses,
504
+ window,
505
+ ).totalDowntimeInSeconds,
506
+ ).toBe(3 * SECONDS_IN_DAY);
507
+ });
508
+ });
509
+
510
+ describe("degenerate inputs", () => {
511
+ const window: UptimeWindow = {
512
+ startDate: new Date("2026-05-31T00:00:00.000Z"),
513
+ endDate: new Date("2026-07-01T00:00:00.000Z"),
514
+ };
515
+
516
+ it("handles an empty timeline with and without a window", () => {
517
+ expect(UptimeUtil.getMonitorEvents([])).toHaveLength(0);
518
+ expect(UptimeUtil.getMonitorEvents([], window)).toHaveLength(0);
519
+ expect(
520
+ UptimeUtil.getNonOverlappingMonitorEvents([], window),
521
+ ).toHaveLength(0);
522
+
523
+ expect(
524
+ UptimeUtil.getTotalDowntimeInSeconds([], downtimeStatuses),
525
+ ).toEqual({
526
+ totalDowntimeInSeconds: 0,
527
+ totalSecondsInTimePeriod: 1,
528
+ });
529
+
530
+ expect(
531
+ UptimeUtil.getTotalDowntimeInSeconds([], downtimeStatuses, window),
532
+ ).toEqual({
533
+ totalDowntimeInSeconds: 0,
534
+ totalSecondsInTimePeriod: 31 * SECONDS_IN_DAY,
535
+ });
536
+
537
+ expect(
538
+ UptimeUtil.calculateUptimePercentage(
539
+ [],
540
+ UptimePrecision.TWO_DECIMAL,
541
+ downtimeStatuses,
542
+ window,
543
+ ),
544
+ ).toBe(100);
545
+ });
546
+
547
+ it("never returns a zero or negative denominator", () => {
548
+ const timelines: Array<MonitorStatusTimeline> = [
549
+ offline("2026-06-05T00:00:00.000Z", "2026-06-06T00:00:00.000Z"),
550
+ ];
551
+
552
+ const zeroLengthWindow: UptimeWindow = {
553
+ startDate: new Date("2026-06-01T00:00:00.000Z"),
554
+ endDate: new Date("2026-06-01T00:00:00.000Z"),
555
+ };
556
+
557
+ const invertedWindow: UptimeWindow = {
558
+ startDate: new Date("2026-07-01T00:00:00.000Z"),
559
+ endDate: new Date("2026-06-01T00:00:00.000Z"),
560
+ };
561
+
562
+ const futureWindow: UptimeWindow = {
563
+ startDate: new Date("2027-01-01T00:00:00.000Z"),
564
+ endDate: new Date("2027-02-01T00:00:00.000Z"),
565
+ };
566
+
567
+ for (const badWindow of [
568
+ zeroLengthWindow,
569
+ invertedWindow,
570
+ futureWindow,
571
+ ]) {
572
+ for (const items of [timelines, []]) {
573
+ const { totalSecondsInTimePeriod } =
574
+ UptimeUtil.getTotalDowntimeInSeconds(
575
+ items,
576
+ downtimeStatuses,
577
+ badWindow,
578
+ );
579
+
580
+ expect(totalSecondsInTimePeriod).toBeGreaterThan(0);
581
+ }
582
+ }
583
+ });
584
+
585
+ it("never returns an uptime percentage outside [0, 100]", () => {
586
+ /*
587
+ * A row closed in the future (2026-07-31) against a window that also ends in the future
588
+ * (2026-08-01), evaluated at now = 2026-07-19. Both the numerator (the clipped event) and the
589
+ * denominator (the window) are capped at now, so they agree: the event covers the whole
590
+ * elapsed period, downtime equals the period, and uptime is a correct 0% - not a value the
591
+ * clamp had to rescue. The [0, 100] clamp remains in the code as defence in depth.
592
+ */
593
+ const window: UptimeWindow = {
594
+ startDate: new Date("2026-07-01T00:00:00.000Z"),
595
+ endDate: new Date("2026-08-01T00:00:00.000Z"),
596
+ };
597
+
598
+ const timelines: Array<MonitorStatusTimeline> = [
599
+ offline("2026-07-01T00:00:00.000Z", "2026-07-31T00:00:00.000Z"),
600
+ ];
601
+
602
+ const { totalDowntimeInSeconds, totalSecondsInTimePeriod } =
603
+ UptimeUtil.getTotalDowntimeInSeconds(
604
+ timelines,
605
+ downtimeStatuses,
606
+ window,
607
+ );
608
+
609
+ /*
610
+ * Numerator is clipped to now, so it can never exceed the denominator (also capped at now).
611
+ * Here the offline row covers the entire elapsed period, so the two are equal.
612
+ */
613
+ expect(totalDowntimeInSeconds).toBeLessThanOrEqual(
614
+ totalSecondsInTimePeriod,
615
+ );
616
+ expect(totalDowntimeInSeconds).toBe(totalSecondsInTimePeriod);
617
+
618
+ for (const precision of [
619
+ UptimePrecision.NO_DECIMAL,
620
+ UptimePrecision.ONE_DECIMAL,
621
+ UptimePrecision.TWO_DECIMAL,
622
+ UptimePrecision.THREE_DECIMAL,
623
+ ]) {
624
+ const uptimePercentage: number = UptimeUtil.calculateUptimePercentage(
625
+ timelines,
626
+ precision,
627
+ downtimeStatuses,
628
+ window,
629
+ );
630
+
631
+ expect(uptimePercentage).toBeGreaterThanOrEqual(0);
632
+ expect(uptimePercentage).toBeLessThanOrEqual(100);
633
+ }
634
+
635
+ expect(
636
+ UptimeUtil.calculateUptimePercentage(
637
+ timelines,
638
+ UptimePrecision.TWO_DECIMAL,
639
+ downtimeStatuses,
640
+ window,
641
+ ),
642
+ ).toBe(0);
643
+ });
644
+ });
645
+
646
+ describe("behaviour without a window is unchanged", () => {
647
+ it("keeps counting closed events from the first event to now", () => {
648
+ const timelines: Array<MonitorStatusTimeline> = [
649
+ operational("2026-07-09T00:00:00.000Z", "2026-07-17T00:00:00.000Z"),
650
+ offline("2026-07-17T00:00:00.000Z", "2026-07-18T00:00:00.000Z"),
651
+ operational("2026-07-18T00:00:00.000Z"),
652
+ ];
653
+
654
+ const { totalDowntimeInSeconds, totalSecondsInTimePeriod } =
655
+ UptimeUtil.getTotalDowntimeInSeconds(timelines, downtimeStatuses);
656
+
657
+ // 9 July -> now (19 July) is 10 days, of which 1 day is downtime.
658
+ expect(totalDowntimeInSeconds).toBe(SECONDS_IN_DAY);
659
+ expect(totalSecondsInTimePeriod).toBe(10 * SECONDS_IN_DAY);
660
+
661
+ expect(
662
+ UptimeUtil.calculateUptimePercentage(
663
+ timelines,
664
+ UptimePrecision.NO_DECIMAL,
665
+ downtimeStatuses,
666
+ ),
667
+ ).toBe(90);
668
+ });
669
+
670
+ it("still returns a period of 1 second when every event is in the future", () => {
671
+ const timelines: Array<MonitorStatusTimeline> = [
672
+ offline("2026-08-01T00:00:00.000Z", "2026-08-02T00:00:00.000Z"),
673
+ ];
674
+
675
+ expect(
676
+ UptimeUtil.getTotalDowntimeInSeconds(timelines, downtimeStatuses),
677
+ ).toEqual({
678
+ totalDowntimeInSeconds: 0,
679
+ totalSecondsInTimePeriod: 1,
680
+ });
681
+ });
682
+
683
+ it("splits overlapping events by priority the same way as before", () => {
684
+ const timelines: Array<MonitorStatusTimeline> = [
685
+ operational("2026-07-10T00:00:00.000Z", "2026-07-16T00:00:00.000Z"),
686
+ offline(
687
+ "2026-07-12T00:00:00.000Z",
688
+ "2026-07-14T00:00:00.000Z",
689
+ OTHER_MONITOR_ID,
690
+ ),
691
+ ];
692
+
693
+ const events: Array<Event> =
694
+ UptimeUtil.getNonOverlappingMonitorEvents(timelines);
695
+
696
+ expect(
697
+ events.map((event: Event) => {
698
+ return event.label;
699
+ }),
700
+ ).toEqual(["Operational", "Offline", "Operational"]);
701
+
702
+ expect(
703
+ UptimeUtil.getTotalDowntimeInSeconds(timelines, downtimeStatuses)
704
+ .totalDowntimeInSeconds,
705
+ ).toBe(2 * SECONDS_IN_DAY);
706
+ });
707
+ });
708
+
709
+ describe("roundToPrecision", () => {
710
+ it("floors to the requested precision", () => {
711
+ expect(
712
+ UptimeUtil.roundToPrecision({
713
+ number: 99.9999,
714
+ precision: UptimePrecision.NO_DECIMAL,
715
+ }),
716
+ ).toBe(99);
717
+
718
+ expect(
719
+ UptimeUtil.roundToPrecision({
720
+ number: 99.9999,
721
+ precision: UptimePrecision.TWO_DECIMAL,
722
+ }),
723
+ ).toBe(99.99);
724
+ });
725
+ });
726
+ });