@checkstack/slo-backend 0.2.0
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/CHANGELOG.md +41 -0
- package/drizzle/0000_rainy_kronos.sql +57 -0
- package/drizzle/meta/0000_snapshot.json +370 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +7 -0
- package/package.json +41 -0
- package/src/achievement-evaluator.ts +201 -0
- package/src/hooks.ts +76 -0
- package/src/index.ts +425 -0
- package/src/router.ts +192 -0
- package/src/schema.ts +120 -0
- package/src/service.ts +682 -0
- package/src/slo-engine.test.ts +662 -0
- package/src/slo-engine.ts +425 -0
- package/src/streak-calculator.ts +107 -0
- package/src/weekly-digest.ts +140 -0
- package/tsconfig.json +6 -0
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, mock } from "bun:test";
|
|
2
|
+
import { SloEngine } from "./slo-engine";
|
|
3
|
+
import type { SloService } from "./service";
|
|
4
|
+
import type { SloObjective, SloDowntimeEvent } from "@checkstack/slo-common";
|
|
5
|
+
|
|
6
|
+
// =============================================================================
|
|
7
|
+
// MOCK FACTORIES
|
|
8
|
+
// =============================================================================
|
|
9
|
+
|
|
10
|
+
function createMockSignalService() {
|
|
11
|
+
return {
|
|
12
|
+
broadcast: mock(() => Promise.resolve()),
|
|
13
|
+
subscribe: mock(() => () => {}),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function createMockLogger() {
|
|
18
|
+
return {
|
|
19
|
+
debug: mock(() => {}),
|
|
20
|
+
info: mock(() => {}),
|
|
21
|
+
warn: mock(() => {}),
|
|
22
|
+
error: mock(() => {}),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function createObjective(
|
|
27
|
+
overrides: Partial<SloObjective> = {},
|
|
28
|
+
): SloObjective {
|
|
29
|
+
return {
|
|
30
|
+
id: "obj-1",
|
|
31
|
+
systemId: "sys-1",
|
|
32
|
+
// eslint-disable-next-line unicorn/no-null -- Zod schema uses .nullable()
|
|
33
|
+
healthCheckConfigurationId: null,
|
|
34
|
+
target: 99.9,
|
|
35
|
+
windowDays: 30,
|
|
36
|
+
dependencyExclusion: "self-only",
|
|
37
|
+
excludedDependencyIds: undefined,
|
|
38
|
+
burnRateThresholds: {
|
|
39
|
+
warningPercent: 50,
|
|
40
|
+
criticalPercent: 80,
|
|
41
|
+
fastBurnMultiplier: 5,
|
|
42
|
+
},
|
|
43
|
+
createdAt: new Date("2026-01-01"),
|
|
44
|
+
updatedAt: new Date("2026-01-01"),
|
|
45
|
+
...overrides,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function createDowntimeEvent(
|
|
50
|
+
overrides: Partial<SloDowntimeEvent> = {},
|
|
51
|
+
): SloDowntimeEvent {
|
|
52
|
+
return {
|
|
53
|
+
id: "evt-1",
|
|
54
|
+
objectiveId: "obj-1",
|
|
55
|
+
systemId: "sys-1",
|
|
56
|
+
startTime: new Date(),
|
|
57
|
+
// eslint-disable-next-line unicorn/no-null -- Zod schema uses .nullable()
|
|
58
|
+
endTime: null,
|
|
59
|
+
// eslint-disable-next-line unicorn/no-null -- Zod schema uses .nullable()
|
|
60
|
+
durationSeconds: null,
|
|
61
|
+
attributionType: "self",
|
|
62
|
+
// eslint-disable-next-line unicorn/no-null -- Zod schema uses .nullable()
|
|
63
|
+
upstreamSystemId: null,
|
|
64
|
+
// eslint-disable-next-line unicorn/no-null -- Zod schema uses .nullable()
|
|
65
|
+
upstreamSystemName: null,
|
|
66
|
+
...overrides,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function createMockService(
|
|
71
|
+
options: {
|
|
72
|
+
objectives?: SloObjective[];
|
|
73
|
+
openEvents?: SloDowntimeEvent[];
|
|
74
|
+
openSelfEvents?: SloDowntimeEvent[];
|
|
75
|
+
openUpstreamEvents?: SloDowntimeEvent[];
|
|
76
|
+
} = {},
|
|
77
|
+
) {
|
|
78
|
+
const {
|
|
79
|
+
objectives = [],
|
|
80
|
+
openEvents = [],
|
|
81
|
+
openSelfEvents = [],
|
|
82
|
+
openUpstreamEvents = [],
|
|
83
|
+
} = options;
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
getObjectivesForSystem: mock(() => Promise.resolve(objectives)),
|
|
87
|
+
getObjective: mock(({ id }: { id: string }) =>
|
|
88
|
+
Promise.resolve(objectives.find((o) => o.id === id)),
|
|
89
|
+
),
|
|
90
|
+
getOpenDowntimeEventsForObjective: mock(() =>
|
|
91
|
+
Promise.resolve(openEvents),
|
|
92
|
+
),
|
|
93
|
+
getOpenDowntimeEvents: mock(() => Promise.resolve(openEvents)),
|
|
94
|
+
getOpenSelfEvents: mock(() => Promise.resolve(openSelfEvents)),
|
|
95
|
+
getOpenUpstreamEvents: mock(() => Promise.resolve(openUpstreamEvents)),
|
|
96
|
+
openDowntimeEvent: mock(() =>
|
|
97
|
+
Promise.resolve(createDowntimeEvent()),
|
|
98
|
+
),
|
|
99
|
+
closeDowntimeEvent: mock(() =>
|
|
100
|
+
Promise.resolve(createDowntimeEvent({ endTime: new Date(), durationSeconds: 60 })),
|
|
101
|
+
),
|
|
102
|
+
getDowntimeForWindow: mock(() =>
|
|
103
|
+
Promise.resolve({
|
|
104
|
+
totalMinutes: 0,
|
|
105
|
+
selfMinutes: 0,
|
|
106
|
+
upstreamMinutes: 0,
|
|
107
|
+
entries: [],
|
|
108
|
+
}),
|
|
109
|
+
),
|
|
110
|
+
listObjectives: mock(() => Promise.resolve(objectives)),
|
|
111
|
+
getStreak: mock(() => Promise.resolve(undefined)),
|
|
112
|
+
insertDailySnapshot: mock(() => Promise.resolve()),
|
|
113
|
+
incrementStreak: mock(() => Promise.resolve()),
|
|
114
|
+
resetStreak: mock(() => Promise.resolve()),
|
|
115
|
+
getRecentDowntimeEvents: mock(() => Promise.resolve([])),
|
|
116
|
+
unlockAchievement: mock(() => Promise.resolve(undefined)),
|
|
117
|
+
hasAchievement: mock(() => Promise.resolve(false)),
|
|
118
|
+
getAchievements: mock(() => Promise.resolve([])),
|
|
119
|
+
} as unknown as SloService;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// =============================================================================
|
|
123
|
+
// TESTS
|
|
124
|
+
// =============================================================================
|
|
125
|
+
|
|
126
|
+
describe("SloEngine", () => {
|
|
127
|
+
let engine: SloEngine;
|
|
128
|
+
let mockService: SloService;
|
|
129
|
+
let mockSignalService: ReturnType<typeof createMockSignalService>;
|
|
130
|
+
let mockLogger: ReturnType<typeof createMockLogger>;
|
|
131
|
+
|
|
132
|
+
const alwaysHealthy = async () => ({
|
|
133
|
+
isHealthy: true,
|
|
134
|
+
systemName: "upstream",
|
|
135
|
+
});
|
|
136
|
+
const alwaysUnhealthy = async () => ({
|
|
137
|
+
isHealthy: false,
|
|
138
|
+
systemName: "upstream",
|
|
139
|
+
});
|
|
140
|
+
// Suppress unused variable lint — kept for future tests
|
|
141
|
+
void alwaysUnhealthy;
|
|
142
|
+
|
|
143
|
+
describe("handleSystemDown", () => {
|
|
144
|
+
it("should open a downtime event for each objective on the system", async () => {
|
|
145
|
+
const objective = createObjective();
|
|
146
|
+
mockService = createMockService({ objectives: [objective] });
|
|
147
|
+
mockSignalService = createMockSignalService();
|
|
148
|
+
mockLogger = createMockLogger();
|
|
149
|
+
|
|
150
|
+
engine = new SloEngine({
|
|
151
|
+
service: mockService,
|
|
152
|
+
signalService: mockSignalService as never,
|
|
153
|
+
logger: mockLogger as never,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await engine.handleSystemDown({
|
|
157
|
+
systemId: "sys-1",
|
|
158
|
+
getUpstreamHealthStatus: alwaysHealthy,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expect(mockService.openDowntimeEvent).toHaveBeenCalledTimes(1);
|
|
162
|
+
expect(mockService.openDowntimeEvent).toHaveBeenCalledWith(
|
|
163
|
+
expect.objectContaining({
|
|
164
|
+
objectiveId: "obj-1",
|
|
165
|
+
systemId: "sys-1",
|
|
166
|
+
attributionType: "self",
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("should skip opening if there is already an open event (idempotent)", async () => {
|
|
172
|
+
const objective = createObjective();
|
|
173
|
+
const existingEvent = createDowntimeEvent();
|
|
174
|
+
mockService = createMockService({
|
|
175
|
+
objectives: [objective],
|
|
176
|
+
openEvents: [existingEvent],
|
|
177
|
+
});
|
|
178
|
+
mockSignalService = createMockSignalService();
|
|
179
|
+
mockLogger = createMockLogger();
|
|
180
|
+
|
|
181
|
+
engine = new SloEngine({
|
|
182
|
+
service: mockService,
|
|
183
|
+
signalService: mockSignalService as never,
|
|
184
|
+
logger: mockLogger as never,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
await engine.handleSystemDown({
|
|
188
|
+
systemId: "sys-1",
|
|
189
|
+
getUpstreamHealthStatus: alwaysHealthy,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
expect(mockService.openDowntimeEvent).not.toHaveBeenCalled();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("should open multiple events for multiple objectives", async () => {
|
|
196
|
+
const obj1 = createObjective({ id: "obj-1" });
|
|
197
|
+
const obj2 = createObjective({ id: "obj-2" });
|
|
198
|
+
mockService = createMockService({ objectives: [obj1, obj2] });
|
|
199
|
+
mockSignalService = createMockSignalService();
|
|
200
|
+
mockLogger = createMockLogger();
|
|
201
|
+
|
|
202
|
+
engine = new SloEngine({
|
|
203
|
+
service: mockService,
|
|
204
|
+
signalService: mockSignalService as never,
|
|
205
|
+
logger: mockLogger as never,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
await engine.handleSystemDown({
|
|
209
|
+
systemId: "sys-1",
|
|
210
|
+
getUpstreamHealthStatus: alwaysHealthy,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
expect(mockService.openDowntimeEvent).toHaveBeenCalledTimes(2);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("should always attribute as 'self' in strict mode", async () => {
|
|
217
|
+
const objective = createObjective({ dependencyExclusion: "strict" });
|
|
218
|
+
mockService = createMockService({ objectives: [objective] });
|
|
219
|
+
mockSignalService = createMockSignalService();
|
|
220
|
+
mockLogger = createMockLogger();
|
|
221
|
+
|
|
222
|
+
engine = new SloEngine({
|
|
223
|
+
service: mockService,
|
|
224
|
+
signalService: mockSignalService as never,
|
|
225
|
+
logger: mockLogger as never,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
await engine.handleSystemDown({
|
|
229
|
+
systemId: "sys-1",
|
|
230
|
+
getUpstreamHealthStatus: alwaysUnhealthy,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
expect(mockService.openDowntimeEvent).toHaveBeenCalledWith(
|
|
234
|
+
expect.objectContaining({
|
|
235
|
+
attributionType: "self",
|
|
236
|
+
}),
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe("handleSystemUp", () => {
|
|
242
|
+
it("should close all open downtime events for the system", async () => {
|
|
243
|
+
const evt1 = createDowntimeEvent({ id: "evt-1", objectiveId: "obj-1" });
|
|
244
|
+
const evt2 = createDowntimeEvent({ id: "evt-2", objectiveId: "obj-1" });
|
|
245
|
+
const objective = createObjective();
|
|
246
|
+
mockService = createMockService({
|
|
247
|
+
objectives: [objective],
|
|
248
|
+
openEvents: [evt1, evt2],
|
|
249
|
+
});
|
|
250
|
+
mockSignalService = createMockSignalService();
|
|
251
|
+
mockLogger = createMockLogger();
|
|
252
|
+
|
|
253
|
+
engine = new SloEngine({
|
|
254
|
+
service: mockService,
|
|
255
|
+
signalService: mockSignalService as never,
|
|
256
|
+
logger: mockLogger as never,
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
await engine.handleSystemUp({ systemId: "sys-1" });
|
|
260
|
+
|
|
261
|
+
expect(mockService.closeDowntimeEvent).toHaveBeenCalledTimes(2);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("should broadcast SLO_STATUS_CHANGED for affected objectives", async () => {
|
|
265
|
+
const evt = createDowntimeEvent({ objectiveId: "obj-1" });
|
|
266
|
+
const objective = createObjective();
|
|
267
|
+
mockService = createMockService({
|
|
268
|
+
objectives: [objective],
|
|
269
|
+
openEvents: [evt],
|
|
270
|
+
});
|
|
271
|
+
mockSignalService = createMockSignalService();
|
|
272
|
+
mockLogger = createMockLogger();
|
|
273
|
+
|
|
274
|
+
engine = new SloEngine({
|
|
275
|
+
service: mockService,
|
|
276
|
+
signalService: mockSignalService as never,
|
|
277
|
+
logger: mockLogger as never,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
await engine.handleSystemUp({ systemId: "sys-1" });
|
|
281
|
+
|
|
282
|
+
expect(mockSignalService.broadcast).toHaveBeenCalledWith(
|
|
283
|
+
expect.objectContaining({ id: "slo.status.changed" }),
|
|
284
|
+
expect.objectContaining({
|
|
285
|
+
systemId: "sys-1",
|
|
286
|
+
objectiveId: "obj-1",
|
|
287
|
+
}),
|
|
288
|
+
);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("should do nothing if no open events exist", async () => {
|
|
292
|
+
mockService = createMockService();
|
|
293
|
+
mockSignalService = createMockSignalService();
|
|
294
|
+
mockLogger = createMockLogger();
|
|
295
|
+
|
|
296
|
+
engine = new SloEngine({
|
|
297
|
+
service: mockService,
|
|
298
|
+
signalService: mockSignalService as never,
|
|
299
|
+
logger: mockLogger as never,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
await engine.handleSystemUp({ systemId: "sys-1" });
|
|
303
|
+
|
|
304
|
+
expect(mockService.closeDowntimeEvent).not.toHaveBeenCalled();
|
|
305
|
+
expect(mockSignalService.broadcast).not.toHaveBeenCalled();
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
describe("handleUpstreamDown", () => {
|
|
310
|
+
it("should split open self events into upstream events", async () => {
|
|
311
|
+
const selfEvent = createDowntimeEvent({
|
|
312
|
+
id: "evt-self",
|
|
313
|
+
objectiveId: "obj-1",
|
|
314
|
+
attributionType: "self",
|
|
315
|
+
});
|
|
316
|
+
const objective = createObjective({ dependencyExclusion: "self-only" });
|
|
317
|
+
mockService = createMockService({
|
|
318
|
+
objectives: [objective],
|
|
319
|
+
openSelfEvents: [selfEvent],
|
|
320
|
+
});
|
|
321
|
+
mockSignalService = createMockSignalService();
|
|
322
|
+
mockLogger = createMockLogger();
|
|
323
|
+
|
|
324
|
+
engine = new SloEngine({
|
|
325
|
+
service: mockService,
|
|
326
|
+
signalService: mockSignalService as never,
|
|
327
|
+
logger: mockLogger as never,
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
await engine.handleUpstreamDown({
|
|
331
|
+
upstreamSystemId: "upstream-1",
|
|
332
|
+
upstreamSystemName: "Upstream Service",
|
|
333
|
+
downstreamSystemIds: ["sys-1"],
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// Should close the self event
|
|
337
|
+
expect(mockService.closeDowntimeEvent).toHaveBeenCalledWith({
|
|
338
|
+
id: "evt-self",
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// Should open a new upstream event
|
|
342
|
+
expect(mockService.openDowntimeEvent).toHaveBeenCalledWith(
|
|
343
|
+
expect.objectContaining({
|
|
344
|
+
attributionType: "upstream",
|
|
345
|
+
upstreamSystemId: "upstream-1",
|
|
346
|
+
upstreamSystemName: "Upstream Service",
|
|
347
|
+
}),
|
|
348
|
+
);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("should skip splitting for strict mode objectives", async () => {
|
|
352
|
+
const selfEvent = createDowntimeEvent({
|
|
353
|
+
id: "evt-self",
|
|
354
|
+
attributionType: "self",
|
|
355
|
+
});
|
|
356
|
+
const objective = createObjective({ dependencyExclusion: "strict" });
|
|
357
|
+
mockService = createMockService({
|
|
358
|
+
objectives: [objective],
|
|
359
|
+
openSelfEvents: [selfEvent],
|
|
360
|
+
});
|
|
361
|
+
mockSignalService = createMockSignalService();
|
|
362
|
+
mockLogger = createMockLogger();
|
|
363
|
+
|
|
364
|
+
engine = new SloEngine({
|
|
365
|
+
service: mockService,
|
|
366
|
+
signalService: mockSignalService as never,
|
|
367
|
+
logger: mockLogger as never,
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
await engine.handleUpstreamDown({
|
|
371
|
+
upstreamSystemId: "upstream-1",
|
|
372
|
+
upstreamSystemName: "Upstream",
|
|
373
|
+
downstreamSystemIds: ["sys-1"],
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
expect(mockService.closeDowntimeEvent).not.toHaveBeenCalled();
|
|
377
|
+
expect(mockService.openDowntimeEvent).not.toHaveBeenCalled();
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("should skip splitting if upstream is in excluded dependencies", async () => {
|
|
381
|
+
const selfEvent = createDowntimeEvent({
|
|
382
|
+
id: "evt-self",
|
|
383
|
+
attributionType: "self",
|
|
384
|
+
});
|
|
385
|
+
const objective = createObjective({
|
|
386
|
+
dependencyExclusion: "self-only",
|
|
387
|
+
excludedDependencyIds: ["upstream-excluded"],
|
|
388
|
+
});
|
|
389
|
+
mockService = createMockService({
|
|
390
|
+
objectives: [objective],
|
|
391
|
+
openSelfEvents: [selfEvent],
|
|
392
|
+
});
|
|
393
|
+
mockSignalService = createMockSignalService();
|
|
394
|
+
mockLogger = createMockLogger();
|
|
395
|
+
|
|
396
|
+
engine = new SloEngine({
|
|
397
|
+
service: mockService,
|
|
398
|
+
signalService: mockSignalService as never,
|
|
399
|
+
logger: mockLogger as never,
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
await engine.handleUpstreamDown({
|
|
403
|
+
upstreamSystemId: "upstream-excluded",
|
|
404
|
+
upstreamSystemName: "Excluded Upstream",
|
|
405
|
+
downstreamSystemIds: ["sys-1"],
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
expect(mockService.closeDowntimeEvent).not.toHaveBeenCalled();
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
describe("handleUpstreamUp", () => {
|
|
413
|
+
it("should close upstream events and open new self events when downstream still down", async () => {
|
|
414
|
+
const upstreamEvent = createDowntimeEvent({
|
|
415
|
+
id: "evt-upstream",
|
|
416
|
+
objectiveId: "obj-1",
|
|
417
|
+
attributionType: "upstream",
|
|
418
|
+
upstreamSystemId: "upstream-1",
|
|
419
|
+
});
|
|
420
|
+
const objective = createObjective();
|
|
421
|
+
mockService = createMockService({
|
|
422
|
+
objectives: [objective],
|
|
423
|
+
openUpstreamEvents: [upstreamEvent],
|
|
424
|
+
});
|
|
425
|
+
mockSignalService = createMockSignalService();
|
|
426
|
+
mockLogger = createMockLogger();
|
|
427
|
+
|
|
428
|
+
// After closing the upstream event, getOpenDowntimeEventsForObjective
|
|
429
|
+
// should return [] — meaning no other open events remain, so the
|
|
430
|
+
// downstream must still be down and needs re-attribution
|
|
431
|
+
(mockService.getOpenDowntimeEventsForObjective as ReturnType<typeof mock>)
|
|
432
|
+
.mockResolvedValue([]);
|
|
433
|
+
|
|
434
|
+
engine = new SloEngine({
|
|
435
|
+
service: mockService,
|
|
436
|
+
signalService: mockSignalService as never,
|
|
437
|
+
logger: mockLogger as never,
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
await engine.handleUpstreamUp({
|
|
441
|
+
upstreamSystemId: "upstream-1",
|
|
442
|
+
downstreamSystemIds: ["sys-1"],
|
|
443
|
+
getUpstreamHealthStatus: alwaysHealthy,
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// Should close the upstream event
|
|
447
|
+
expect(mockService.closeDowntimeEvent).toHaveBeenCalledWith({
|
|
448
|
+
id: "evt-upstream",
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// Should open a new event (re-attributed)
|
|
452
|
+
expect(mockService.openDowntimeEvent).toHaveBeenCalled();
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
describe("computeStatus", () => {
|
|
457
|
+
it("should calculate correct availability for zero downtime", async () => {
|
|
458
|
+
const objective = createObjective({ target: 99.9, windowDays: 30 });
|
|
459
|
+
mockService = createMockService({ objectives: [objective] });
|
|
460
|
+
mockSignalService = createMockSignalService();
|
|
461
|
+
mockLogger = createMockLogger();
|
|
462
|
+
|
|
463
|
+
engine = new SloEngine({
|
|
464
|
+
service: mockService,
|
|
465
|
+
signalService: mockSignalService as never,
|
|
466
|
+
logger: mockLogger as never,
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
const status = await engine.computeStatus({ objective });
|
|
470
|
+
|
|
471
|
+
expect(status.errorBudgetConsumedMinutes).toBe(0);
|
|
472
|
+
expect(status.errorBudgetRemainingPercent).toBe(100);
|
|
473
|
+
expect(status.isBreaching).toBe(false);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it("should count only selfMinutes for self-only mode", async () => {
|
|
477
|
+
const objective = createObjective({
|
|
478
|
+
target: 99.9,
|
|
479
|
+
windowDays: 30,
|
|
480
|
+
dependencyExclusion: "self-only",
|
|
481
|
+
});
|
|
482
|
+
mockService = createMockService({ objectives: [objective] });
|
|
483
|
+
|
|
484
|
+
// Mock downtime: 10 min self + 20 min upstream
|
|
485
|
+
(mockService.getDowntimeForWindow as ReturnType<typeof mock>).mockResolvedValue({
|
|
486
|
+
totalMinutes: 30,
|
|
487
|
+
selfMinutes: 10,
|
|
488
|
+
upstreamMinutes: 20,
|
|
489
|
+
entries: [],
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
mockSignalService = createMockSignalService();
|
|
493
|
+
mockLogger = createMockLogger();
|
|
494
|
+
|
|
495
|
+
engine = new SloEngine({
|
|
496
|
+
service: mockService,
|
|
497
|
+
signalService: mockSignalService as never,
|
|
498
|
+
logger: mockLogger as never,
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
const status = await engine.computeStatus({ objective });
|
|
502
|
+
|
|
503
|
+
// Should only count 10 self minutes
|
|
504
|
+
expect(status.errorBudgetConsumedMinutes).toBe(10);
|
|
505
|
+
// Strict should count all 30
|
|
506
|
+
expect(status.errorBudgetConsumedStrictMinutes).toBe(30);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("should count totalMinutes for strict mode", async () => {
|
|
510
|
+
const objective = createObjective({
|
|
511
|
+
target: 99.9,
|
|
512
|
+
windowDays: 30,
|
|
513
|
+
dependencyExclusion: "strict",
|
|
514
|
+
});
|
|
515
|
+
mockService = createMockService({ objectives: [objective] });
|
|
516
|
+
|
|
517
|
+
(mockService.getDowntimeForWindow as ReturnType<typeof mock>).mockResolvedValue({
|
|
518
|
+
totalMinutes: 30,
|
|
519
|
+
selfMinutes: 10,
|
|
520
|
+
upstreamMinutes: 20,
|
|
521
|
+
entries: [],
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
mockSignalService = createMockSignalService();
|
|
525
|
+
mockLogger = createMockLogger();
|
|
526
|
+
|
|
527
|
+
engine = new SloEngine({
|
|
528
|
+
service: mockService,
|
|
529
|
+
signalService: mockSignalService as never,
|
|
530
|
+
logger: mockLogger as never,
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
const status = await engine.computeStatus({ objective });
|
|
534
|
+
|
|
535
|
+
// Strict counts all downtime
|
|
536
|
+
expect(status.errorBudgetConsumedMinutes).toBe(30);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it("should flag as breaching when availability is below target", async () => {
|
|
540
|
+
const objective = createObjective({
|
|
541
|
+
target: 99.9,
|
|
542
|
+
windowDays: 30,
|
|
543
|
+
});
|
|
544
|
+
mockService = createMockService({ objectives: [objective] });
|
|
545
|
+
|
|
546
|
+
// 99.9% of 30 days = 43,200 minutes → allowed downtime = 43.2 min
|
|
547
|
+
// Set consumed = 50 min → breaching
|
|
548
|
+
(mockService.getDowntimeForWindow as ReturnType<typeof mock>).mockResolvedValue({
|
|
549
|
+
totalMinutes: 50,
|
|
550
|
+
selfMinutes: 50,
|
|
551
|
+
upstreamMinutes: 0,
|
|
552
|
+
entries: [],
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
mockSignalService = createMockSignalService();
|
|
556
|
+
mockLogger = createMockLogger();
|
|
557
|
+
|
|
558
|
+
engine = new SloEngine({
|
|
559
|
+
service: mockService,
|
|
560
|
+
signalService: mockSignalService as never,
|
|
561
|
+
logger: mockLogger as never,
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
const status = await engine.computeStatus({ objective });
|
|
565
|
+
|
|
566
|
+
expect(status.isBreaching).toBe(true);
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
describe("reconcileObjective", () => {
|
|
571
|
+
it("should open a downtime event when system is already unhealthy", async () => {
|
|
572
|
+
const objective = createObjective();
|
|
573
|
+
mockService = createMockService({ objectives: [objective] });
|
|
574
|
+
mockSignalService = createMockSignalService();
|
|
575
|
+
mockLogger = createMockLogger();
|
|
576
|
+
|
|
577
|
+
engine = new SloEngine({
|
|
578
|
+
service: mockService,
|
|
579
|
+
signalService: mockSignalService as never,
|
|
580
|
+
logger: mockLogger as never,
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
engine.setHealthStatusCallback(async () => ({
|
|
584
|
+
isHealthy: false,
|
|
585
|
+
}));
|
|
586
|
+
|
|
587
|
+
await engine.reconcileObjective({ objective });
|
|
588
|
+
|
|
589
|
+
expect(mockService.openDowntimeEvent).toHaveBeenCalledWith(
|
|
590
|
+
expect.objectContaining({
|
|
591
|
+
objectiveId: "obj-1",
|
|
592
|
+
systemId: "sys-1",
|
|
593
|
+
attributionType: "self",
|
|
594
|
+
}),
|
|
595
|
+
);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it("should skip when system is healthy", async () => {
|
|
599
|
+
const objective = createObjective();
|
|
600
|
+
mockService = createMockService({ objectives: [objective] });
|
|
601
|
+
mockSignalService = createMockSignalService();
|
|
602
|
+
mockLogger = createMockLogger();
|
|
603
|
+
|
|
604
|
+
engine = new SloEngine({
|
|
605
|
+
service: mockService,
|
|
606
|
+
signalService: mockSignalService as never,
|
|
607
|
+
logger: mockLogger as never,
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
engine.setHealthStatusCallback(async () => ({
|
|
611
|
+
isHealthy: true,
|
|
612
|
+
}));
|
|
613
|
+
|
|
614
|
+
await engine.reconcileObjective({ objective });
|
|
615
|
+
|
|
616
|
+
expect(mockService.openDowntimeEvent).not.toHaveBeenCalled();
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
it("should skip gracefully when no callback is set", async () => {
|
|
620
|
+
const objective = createObjective();
|
|
621
|
+
mockService = createMockService({ objectives: [objective] });
|
|
622
|
+
mockSignalService = createMockSignalService();
|
|
623
|
+
mockLogger = createMockLogger();
|
|
624
|
+
|
|
625
|
+
engine = new SloEngine({
|
|
626
|
+
service: mockService,
|
|
627
|
+
signalService: mockSignalService as never,
|
|
628
|
+
logger: mockLogger as never,
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
// No setHealthStatusCallback call — should not throw
|
|
632
|
+
await engine.reconcileObjective({ objective });
|
|
633
|
+
|
|
634
|
+
expect(mockService.openDowntimeEvent).not.toHaveBeenCalled();
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
it("should skip when open events already exist (idempotent)", async () => {
|
|
638
|
+
const objective = createObjective();
|
|
639
|
+
const existingEvent = createDowntimeEvent();
|
|
640
|
+
mockService = createMockService({
|
|
641
|
+
objectives: [objective],
|
|
642
|
+
openEvents: [existingEvent],
|
|
643
|
+
});
|
|
644
|
+
mockSignalService = createMockSignalService();
|
|
645
|
+
mockLogger = createMockLogger();
|
|
646
|
+
|
|
647
|
+
engine = new SloEngine({
|
|
648
|
+
service: mockService,
|
|
649
|
+
signalService: mockSignalService as never,
|
|
650
|
+
logger: mockLogger as never,
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
engine.setHealthStatusCallback(async () => ({
|
|
654
|
+
isHealthy: false,
|
|
655
|
+
}));
|
|
656
|
+
|
|
657
|
+
await engine.reconcileObjective({ objective });
|
|
658
|
+
|
|
659
|
+
expect(mockService.openDowntimeEvent).not.toHaveBeenCalled();
|
|
660
|
+
});
|
|
661
|
+
});
|
|
662
|
+
});
|