@oneuptime/common 11.5.11 → 11.5.12
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.
- package/Models/AnalyticsModels/NetworkFlow.ts +12 -1
- package/Server/API/StatusPageAPI.ts +14 -1
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +0 -2
- package/Server/Services/IncidentService.ts +62 -67
- package/Server/Services/MonitorGroupService.ts +15 -2
- package/Server/Services/MonitorService.ts +91 -3
- package/Server/Services/MonitorStatusTimelineService.ts +285 -100
- package/Server/Services/StatusPageService.ts +59 -34
- package/Server/Utils/Monitor/MonitorResource.ts +43 -14
- package/Server/Utils/Monitor/MonitorStatusTimeline.ts +32 -7
- package/Server/Utils/Monitor/MonitorStatusTimelineReconciler.ts +362 -0
- package/Tests/Server/Services/MonitorStatusTimelineService.test.ts +235 -0
- package/Tests/Server/Utils/Monitor/MonitorStatusTimelineReconciler.test.ts +555 -0
- package/Tests/Types/Database/CompareOperatorWireSerialization.test.ts +216 -0
- package/Tests/Types/Database/GreaterThan.test.ts +5 -1
- package/Tests/Types/Database/GreaterThanOrEqual.test.ts +5 -1
- package/Tests/Types/Database/GreaterThanOrNull.test.ts +24 -1
- package/Tests/Types/Database/LessThan.test.ts +5 -1
- package/Tests/Types/Database/LessThanOrEqual.test.ts +24 -1
- package/Tests/Types/Database/LessThanOrNull.test.ts +5 -1
- package/Tests/Utils/Uptime/UptimeUtil.test.ts +726 -0
- package/Types/BaseDatabase/GreaterThan.ts +8 -1
- package/Types/BaseDatabase/GreaterThanOrEqual.ts +8 -1
- package/Types/BaseDatabase/GreaterThanOrNull.ts +9 -1
- package/Types/BaseDatabase/LessThan.ts +8 -1
- package/Types/BaseDatabase/LessThanOrEqual.ts +9 -1
- package/Types/BaseDatabase/LessThanOrNull.ts +8 -1
- package/Utils/StatusPage/ResourceUptime.ts +11 -1
- package/Utils/Uptime/UptimeUtil.ts +142 -36
- package/build/dist/Models/AnalyticsModels/NetworkFlow.js +12 -1
- package/build/dist/Models/AnalyticsModels/NetworkFlow.js.map +1 -1
- package/build/dist/Server/API/StatusPageAPI.js +12 -2
- package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +0 -2
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/IncidentService.js +47 -56
- package/build/dist/Server/Services/IncidentService.js.map +1 -1
- package/build/dist/Server/Services/MonitorGroupService.js +15 -2
- package/build/dist/Server/Services/MonitorGroupService.js.map +1 -1
- package/build/dist/Server/Services/MonitorService.js +66 -3
- package/build/dist/Server/Services/MonitorService.js.map +1 -1
- package/build/dist/Server/Services/MonitorStatusTimelineService.js +244 -102
- package/build/dist/Server/Services/MonitorStatusTimelineService.js.map +1 -1
- package/build/dist/Server/Services/StatusPageService.js +57 -33
- package/build/dist/Server/Services/StatusPageService.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorResource.js +34 -12
- package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js +25 -8
- package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorStatusTimelineReconciler.js +268 -0
- package/build/dist/Server/Utils/Monitor/MonitorStatusTimelineReconciler.js.map +1 -0
- package/build/dist/Types/BaseDatabase/GreaterThan.js +8 -1
- package/build/dist/Types/BaseDatabase/GreaterThan.js.map +1 -1
- package/build/dist/Types/BaseDatabase/GreaterThanOrEqual.js +8 -1
- package/build/dist/Types/BaseDatabase/GreaterThanOrEqual.js.map +1 -1
- package/build/dist/Types/BaseDatabase/GreaterThanOrNull.js +9 -1
- package/build/dist/Types/BaseDatabase/GreaterThanOrNull.js.map +1 -1
- package/build/dist/Types/BaseDatabase/LessThan.js +8 -1
- package/build/dist/Types/BaseDatabase/LessThan.js.map +1 -1
- package/build/dist/Types/BaseDatabase/LessThanOrEqual.js +9 -1
- package/build/dist/Types/BaseDatabase/LessThanOrEqual.js.map +1 -1
- package/build/dist/Types/BaseDatabase/LessThanOrNull.js +8 -1
- package/build/dist/Types/BaseDatabase/LessThanOrNull.js.map +1 -1
- package/build/dist/Utils/StatusPage/ResourceUptime.js +4 -1
- package/build/dist/Utils/StatusPage/ResourceUptime.js.map +1 -1
- package/build/dist/Utils/Uptime/UptimeUtil.js +86 -33
- package/build/dist/Utils/Uptime/UptimeUtil.js.map +1 -1
- package/package.json +1 -1
- package/Server/Infrastructure/Postgres/SchemaMigrations/1784401962564-MigrationName.ts +0 -101
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784401962564-MigrationName.js +0 -40
- 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
|
-
|
|
47
|
+
public override async create(
|
|
29
48
|
createBy: CreateBy<MonitorStatusTimeline>,
|
|
30
|
-
): Promise<
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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.
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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.
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
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.
|
|
772
|
+
if (!a.startsAt || !b.startsAt) {
|
|
769
773
|
return 0;
|
|
770
774
|
}
|
|
771
775
|
|
|
772
|
-
return b.
|
|
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,
|