@checkstack/incident-backend 1.11.0 → 1.13.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 +388 -0
- package/drizzle/0005_real_leper_queen.sql +4 -0
- package/drizzle/0006_last_princess_powerful.sql +1 -0
- package/drizzle/0007_colorful_true_believers.sql +2 -0
- package/drizzle/meta/0005_snapshot.json +346 -0
- package/drizzle/meta/0006_snapshot.json +353 -0
- package/drizzle/meta/0007_snapshot.json +391 -0
- package/drizzle/meta/_journal.json +21 -0
- package/package.json +15 -15
- package/src/ai/incident-add-link.test.ts +1 -0
- package/src/ai/incident-add-update.test.ts +6 -1
- package/src/ai/incident-delete-update.test.ts +59 -0
- package/src/ai/incident-delete-update.ts +73 -0
- package/src/ai/register-ai-tools.ts +2 -0
- package/src/automations.test.ts +70 -0
- package/src/automations.ts +77 -4
- package/src/hooks.ts +54 -4
- package/src/index.ts +4 -0
- package/src/notifications.test.ts +181 -0
- package/src/notifications.ts +12 -1
- package/src/read-visibility.test.ts +158 -0
- package/src/read-visibility.ts +99 -0
- package/src/router.test.ts +69 -1
- package/src/router.ts +195 -15
- package/src/schema.ts +55 -10
- package/src/service-reads.it.test.ts +353 -0
- package/src/service-updates.it.test.ts +340 -0
- package/src/service.it.test.ts +57 -0
- package/src/service.test.ts +235 -1
- package/src/service.ts +489 -177
- package/src/status-page-widget.test.ts +149 -0
- package/src/status-page-widget.ts +136 -37
package/src/automations.test.ts
CHANGED
|
@@ -324,6 +324,76 @@ describe("incident automation actions", () => {
|
|
|
324
324
|
expect(service.resolveIncident).toHaveBeenCalledWith("INC-1", "Fixed");
|
|
325
325
|
});
|
|
326
326
|
|
|
327
|
+
// SLO-1 regression: an automation-driven resolve mutates the incident WITHOUT
|
|
328
|
+
// going through the RPC router, so it must emit `incident.lifecycle.changed`
|
|
329
|
+
// itself — otherwise an override-bearing incident resolved by an automation
|
|
330
|
+
// never triggers SLO downtime reconciliation and its incident-sourced event
|
|
331
|
+
// leaks open.
|
|
332
|
+
it("emits incident.lifecycle.changed on an automation resolve (SLO downtime reconcile)", async () => {
|
|
333
|
+
const resolved = {
|
|
334
|
+
id: "INC-1",
|
|
335
|
+
status: "resolved",
|
|
336
|
+
severity: "critical",
|
|
337
|
+
systemIds: ["sys-1", "sys-2"],
|
|
338
|
+
};
|
|
339
|
+
const service = makeServiceStub({
|
|
340
|
+
resolveIncident: mock(
|
|
341
|
+
async () => resolved,
|
|
342
|
+
) as unknown as IncidentService["resolveIncident"],
|
|
343
|
+
});
|
|
344
|
+
const emit = mock(async () => {});
|
|
345
|
+
const eventBus = { emit } as unknown as Parameters<
|
|
346
|
+
typeof createIncidentActions
|
|
347
|
+
>[0]["eventBus"];
|
|
348
|
+
const resolveAction = createIncidentActions({ service, eventBus })[1];
|
|
349
|
+
|
|
350
|
+
await resolveAction.execute({
|
|
351
|
+
...actionContext,
|
|
352
|
+
config: { incidentId: "INC-1", message: "Fixed" } as never,
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
expect(emit).toHaveBeenCalledTimes(1);
|
|
356
|
+
const emitArgs = emit.mock.calls[0] as unknown[] | undefined;
|
|
357
|
+
const hook = emitArgs?.[0] as { id?: string } | undefined;
|
|
358
|
+
const payload = emitArgs?.[1] as
|
|
359
|
+
| { systemIds?: string[]; action?: string }
|
|
360
|
+
| undefined;
|
|
361
|
+
expect(hook?.id).toBe("incident.lifecycle.changed");
|
|
362
|
+
expect(payload?.action).toBe("resolved");
|
|
363
|
+
expect(payload?.systemIds).toEqual(["sys-1", "sys-2"]);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("emits incident.lifecycle.changed on an automation update_status → resolved", async () => {
|
|
367
|
+
const update = {
|
|
368
|
+
id: "upd-3",
|
|
369
|
+
incidentId: "INC-1",
|
|
370
|
+
message: "Status changed to resolved",
|
|
371
|
+
createdAt: new Date(),
|
|
372
|
+
};
|
|
373
|
+
const service = makeServiceStub({
|
|
374
|
+
addUpdate: mock(
|
|
375
|
+
async () => update,
|
|
376
|
+
) as unknown as IncidentService["addUpdate"],
|
|
377
|
+
});
|
|
378
|
+
const emit = mock(async () => {});
|
|
379
|
+
const eventBus = { emit } as unknown as Parameters<
|
|
380
|
+
typeof createIncidentActions
|
|
381
|
+
>[0]["eventBus"];
|
|
382
|
+
const updateStatusAction = createIncidentActions({ service, eventBus })[3];
|
|
383
|
+
|
|
384
|
+
await updateStatusAction.execute({
|
|
385
|
+
...actionContext,
|
|
386
|
+
config: { incidentId: "INC-1", status: "resolved" } as never,
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
expect(emit).toHaveBeenCalledTimes(1);
|
|
390
|
+
const payload = (emit.mock.calls[0] as unknown[] | undefined)?.[1] as
|
|
391
|
+
| { systemIds?: string[]; action?: string }
|
|
392
|
+
| undefined;
|
|
393
|
+
expect(payload?.action).toBe("resolved");
|
|
394
|
+
expect(payload?.systemIds).toEqual(["sys-1"]);
|
|
395
|
+
});
|
|
396
|
+
|
|
327
397
|
// 6(b) regression: an action-driven resolve must route through the reactive
|
|
328
398
|
// entity (like the RPC router) so it appends an `entity_transitions` row,
|
|
329
399
|
// emits `ENTITY_CHANGED` (waking `wait_until`), and fires the
|
package/src/automations.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* and to match `wait_for_trigger` waits against the same incident.
|
|
12
12
|
*/
|
|
13
13
|
import { z } from "zod";
|
|
14
|
-
import { Versioned } from "@checkstack/backend-api";
|
|
14
|
+
import { Versioned, type EventBus } from "@checkstack/backend-api";
|
|
15
15
|
import type {
|
|
16
16
|
ActionDefinition,
|
|
17
17
|
ArtifactTypeDefinition,
|
|
@@ -22,9 +22,11 @@ import { makeEntityDrivenTriggerSetup } from "@checkstack/automation-backend";
|
|
|
22
22
|
import {
|
|
23
23
|
IncidentSeverityEnum,
|
|
24
24
|
IncidentStatusEnum,
|
|
25
|
+
type IncidentLifecycleAction,
|
|
25
26
|
} from "@checkstack/incident-common";
|
|
26
27
|
|
|
27
28
|
import type { IncidentService } from "./service";
|
|
29
|
+
import { emitIncidentLifecycleChanged } from "./hooks";
|
|
28
30
|
import {
|
|
29
31
|
toIncidentEntityState,
|
|
30
32
|
writeIncidentEntity,
|
|
@@ -259,12 +261,20 @@ export interface IncidentActionDeps {
|
|
|
259
261
|
* non-reactively.
|
|
260
262
|
*/
|
|
261
263
|
getIncidentEntity?: () => EntityHandle<IncidentEntityState> | undefined;
|
|
264
|
+
/**
|
|
265
|
+
* Distributed event bus used to emit `incident.lifecycle.changed` after an
|
|
266
|
+
* automation-driven lifecycle mutation. Automations mutate incidents WITHOUT
|
|
267
|
+
* going through the RPC router, so without this an automation resolving an
|
|
268
|
+
* override-bearing incident would never notify SLO downtime reconciliation and
|
|
269
|
+
* the incident-sourced downtime event would leak open. Undefined in tests.
|
|
270
|
+
*/
|
|
271
|
+
eventBus?: EventBus;
|
|
262
272
|
}
|
|
263
273
|
|
|
264
274
|
export function createIncidentActions(
|
|
265
275
|
deps: IncidentActionDeps,
|
|
266
276
|
): ActionDefinition<unknown, unknown>[] {
|
|
267
|
-
const { service, getIncidentEntity } = deps;
|
|
277
|
+
const { service, getIncidentEntity, eventBus } = deps;
|
|
268
278
|
|
|
269
279
|
const createAction: ActionDefinition<
|
|
270
280
|
z.infer<typeof incidentCreateConfigSchema>,
|
|
@@ -339,6 +349,17 @@ export function createIncidentActions(
|
|
|
339
349
|
);
|
|
340
350
|
} else {
|
|
341
351
|
logger.info(`Automation created incident ${incident.id}`);
|
|
352
|
+
// Only a real create is a lifecycle mutation; a REUSE changed nothing,
|
|
353
|
+
// so SLO already reconciled it at its original creation.
|
|
354
|
+
await emitIncidentLifecycleChanged({
|
|
355
|
+
eventBus,
|
|
356
|
+
logger,
|
|
357
|
+
payload: {
|
|
358
|
+
incidentId: incident.id,
|
|
359
|
+
systemIds: incident.systemIds,
|
|
360
|
+
action: "created",
|
|
361
|
+
},
|
|
362
|
+
});
|
|
342
363
|
}
|
|
343
364
|
return {
|
|
344
365
|
success: true,
|
|
@@ -364,6 +385,15 @@ export function createIncidentActions(
|
|
|
364
385
|
},
|
|
365
386
|
});
|
|
366
387
|
logger.info(`Automation created incident ${incident.id}`);
|
|
388
|
+
await emitIncidentLifecycleChanged({
|
|
389
|
+
eventBus,
|
|
390
|
+
logger,
|
|
391
|
+
payload: {
|
|
392
|
+
incidentId: incident.id,
|
|
393
|
+
systemIds: incident.systemIds,
|
|
394
|
+
action: "created",
|
|
395
|
+
},
|
|
396
|
+
});
|
|
367
397
|
return {
|
|
368
398
|
success: true,
|
|
369
399
|
externalId: incident.id,
|
|
@@ -442,6 +472,17 @@ export function createIncidentActions(
|
|
|
442
472
|
};
|
|
443
473
|
}
|
|
444
474
|
logger.info(`Automation resolved incident ${incident.id}`);
|
|
475
|
+
// A resolve clears any active health override, so SLO must close the
|
|
476
|
+
// incident-forced downtime — the defect this notifier fixes.
|
|
477
|
+
await emitIncidentLifecycleChanged({
|
|
478
|
+
eventBus,
|
|
479
|
+
logger,
|
|
480
|
+
payload: {
|
|
481
|
+
incidentId: incident.id,
|
|
482
|
+
systemIds: incident.systemIds,
|
|
483
|
+
action: "resolved",
|
|
484
|
+
},
|
|
485
|
+
});
|
|
445
486
|
return {
|
|
446
487
|
success: true,
|
|
447
488
|
externalId: incident.id,
|
|
@@ -485,7 +526,8 @@ export function createIncidentActions(
|
|
|
485
526
|
// is a no-op diff (no event). `opts.runId` masks run-resolved secrets.
|
|
486
527
|
const captured: {
|
|
487
528
|
update: Awaited<ReturnType<typeof service.addUpdate>> | null;
|
|
488
|
-
|
|
529
|
+
systemIds: string[];
|
|
530
|
+
} = { update: null, systemIds: [] };
|
|
489
531
|
await writeIncidentEntity({
|
|
490
532
|
handle: getIncidentEntity?.(),
|
|
491
533
|
incidentId,
|
|
@@ -500,6 +542,7 @@ export function createIncidentActions(
|
|
|
500
542
|
if (!incident) {
|
|
501
543
|
throw new Error(`Incident ${incidentId} not found`);
|
|
502
544
|
}
|
|
545
|
+
captured.systemIds = incident.systemIds;
|
|
503
546
|
return toIncidentEntityState(incident);
|
|
504
547
|
},
|
|
505
548
|
});
|
|
@@ -513,6 +556,21 @@ export function createIncidentActions(
|
|
|
513
556
|
logger.info(
|
|
514
557
|
`Automation added update ${update.id} to incident ${incidentId}`,
|
|
515
558
|
);
|
|
559
|
+
// Only a status-changing update is a lifecycle mutation SLO cares about; a
|
|
560
|
+
// comment-only update changes no health-relevant state. A change to
|
|
561
|
+
// resolved clears any override, so SLO must reconcile.
|
|
562
|
+
if (config.statusChange) {
|
|
563
|
+
await emitIncidentLifecycleChanged({
|
|
564
|
+
eventBus,
|
|
565
|
+
logger,
|
|
566
|
+
payload: {
|
|
567
|
+
incidentId,
|
|
568
|
+
systemIds: captured.systemIds,
|
|
569
|
+
action:
|
|
570
|
+
config.statusChange === "resolved" ? "resolved" : "updated",
|
|
571
|
+
},
|
|
572
|
+
});
|
|
573
|
+
}
|
|
516
574
|
return {
|
|
517
575
|
success: true,
|
|
518
576
|
externalId: update.id,
|
|
@@ -553,7 +611,8 @@ export function createIncidentActions(
|
|
|
553
611
|
// re-reads post-write state. `opts.runId` masks run-resolved secrets.
|
|
554
612
|
const captured: {
|
|
555
613
|
update: Awaited<ReturnType<typeof service.addUpdate>> | null;
|
|
556
|
-
|
|
614
|
+
systemIds: string[];
|
|
615
|
+
} = { update: null, systemIds: [] };
|
|
557
616
|
await writeIncidentEntity({
|
|
558
617
|
handle: getIncidentEntity?.(),
|
|
559
618
|
incidentId,
|
|
@@ -568,6 +627,7 @@ export function createIncidentActions(
|
|
|
568
627
|
if (!incident) {
|
|
569
628
|
throw new Error(`Incident ${incidentId} not found`);
|
|
570
629
|
}
|
|
630
|
+
captured.systemIds = incident.systemIds;
|
|
571
631
|
return toIncidentEntityState(incident);
|
|
572
632
|
},
|
|
573
633
|
});
|
|
@@ -581,6 +641,19 @@ export function createIncidentActions(
|
|
|
581
641
|
logger.info(
|
|
582
642
|
`Automation set incident ${incidentId} status → ${config.status}`,
|
|
583
643
|
);
|
|
644
|
+
// A status flip is a lifecycle mutation; a flip to resolved clears any
|
|
645
|
+
// override, so SLO must reconcile the affected systems' downtime.
|
|
646
|
+
const action: IncidentLifecycleAction =
|
|
647
|
+
config.status === "resolved" ? "resolved" : "updated";
|
|
648
|
+
await emitIncidentLifecycleChanged({
|
|
649
|
+
eventBus,
|
|
650
|
+
logger,
|
|
651
|
+
payload: {
|
|
652
|
+
incidentId,
|
|
653
|
+
systemIds: captured.systemIds,
|
|
654
|
+
action,
|
|
655
|
+
},
|
|
656
|
+
});
|
|
584
657
|
return {
|
|
585
658
|
success: true,
|
|
586
659
|
externalId: update.id,
|
package/src/hooks.ts
CHANGED
|
@@ -1,11 +1,61 @@
|
|
|
1
|
+
import { createHook, type EventBus, type Logger } from "@checkstack/backend-api";
|
|
2
|
+
import {
|
|
3
|
+
INCIDENT_LIFECYCLE_CHANGED_HOOK_ID,
|
|
4
|
+
type IncidentLifecycleChangedPayload,
|
|
5
|
+
} from "@checkstack/incident-common";
|
|
6
|
+
|
|
1
7
|
/**
|
|
2
8
|
* Incident cross-plugin hooks.
|
|
3
9
|
*
|
|
4
10
|
* The `incident.created` / `.updated` / `.resolved` hooks were removed in
|
|
5
11
|
* Phase 4 (§10.1): incidents are now the reactive `incident` entity, whose
|
|
6
12
|
* change deriver fires the matching `incident.created` / `.updated` /
|
|
7
|
-
* `.resolved` trigger events through Stage-1 routing.
|
|
8
|
-
*
|
|
9
|
-
* `
|
|
13
|
+
* `.resolved` trigger events through Stage-1 routing.
|
|
14
|
+
*
|
|
15
|
+
* `lifecycleChanged` is a distinct, lower-level cross-plugin hook: it fires on
|
|
16
|
+
* EVERY incident lifecycle mutation (create, update — including a health
|
|
17
|
+
* override added / changed / cleared —, resolve, delete, and the auto-incident
|
|
18
|
+
* paths), carrying `{ incidentId, systemIds, action }`. Unlike the reactive
|
|
19
|
+
* `incident` entity change (state `{ status, severity, systemIds }`, so an
|
|
20
|
+
* override-only edit emits nothing), this catches override changes, which
|
|
21
|
+
* `@checkstack/slo-backend` needs to open/close incident-forced SLO downtime.
|
|
22
|
+
* The id + payload contract live in `@checkstack/incident-common` so consumers
|
|
23
|
+
* subscribe without depending on this backend.
|
|
24
|
+
*/
|
|
25
|
+
export const incidentLifecycleChangedHook =
|
|
26
|
+
createHook<IncidentLifecycleChangedPayload>(
|
|
27
|
+
INCIDENT_LIFECYCLE_CHANGED_HOOK_ID,
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
export const incidentHooks = {
|
|
31
|
+
lifecycleChanged: incidentLifecycleChangedHook,
|
|
32
|
+
} as const;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Emit `incident.lifecycle.changed` on the distributed event bus. THE single
|
|
36
|
+
* place the hook is fired, so every incident lifecycle path - the RPC router,
|
|
37
|
+
* the bulk paths, AND the automation actions - shares identical guard/failure
|
|
38
|
+
* semantics and can never drift. Best-effort by contract: the incident write is
|
|
39
|
+
* already committed by the time this runs, so a delivery failure must never turn
|
|
40
|
+
* a successful mutation into a client/run error - consumers reconcile on the
|
|
41
|
+
* next lifecycle event. A no-op when no event bus is wired (tests).
|
|
10
42
|
*/
|
|
11
|
-
export
|
|
43
|
+
export async function emitIncidentLifecycleChanged({
|
|
44
|
+
eventBus,
|
|
45
|
+
logger,
|
|
46
|
+
payload,
|
|
47
|
+
}: {
|
|
48
|
+
eventBus: EventBus | undefined;
|
|
49
|
+
logger: Logger;
|
|
50
|
+
payload: IncidentLifecycleChangedPayload;
|
|
51
|
+
}): Promise<void> {
|
|
52
|
+
if (!eventBus) return;
|
|
53
|
+
try {
|
|
54
|
+
await eventBus.emit(incidentLifecycleChangedHook, payload);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
logger.warn(
|
|
57
|
+
`Failed to emit incident.lifecycle.changed hook for incident ${payload.incidentId}; consumers will reconcile on the next lifecycle event.`,
|
|
58
|
+
{ error },
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -145,6 +145,7 @@ export default createBackendPlugin({
|
|
|
145
145
|
rpc: coreServices.rpc,
|
|
146
146
|
rpcClient: coreServices.rpcClient,
|
|
147
147
|
signalService: coreServices.signalService,
|
|
148
|
+
eventBus: coreServices.eventBus,
|
|
148
149
|
cacheManager: coreServices.cacheManager,
|
|
149
150
|
advisoryLock: coreServices.advisoryLock,
|
|
150
151
|
resourceResolverRegistry: coreServices.resourceResolverRegistry,
|
|
@@ -155,6 +156,7 @@ export default createBackendPlugin({
|
|
|
155
156
|
rpc,
|
|
156
157
|
rpcClient,
|
|
157
158
|
signalService,
|
|
159
|
+
eventBus,
|
|
158
160
|
cacheManager,
|
|
159
161
|
advisoryLock,
|
|
160
162
|
resourceResolverRegistry,
|
|
@@ -198,6 +200,7 @@ export default createBackendPlugin({
|
|
|
198
200
|
const router = createRouter({
|
|
199
201
|
service,
|
|
200
202
|
signalService,
|
|
203
|
+
eventBus,
|
|
201
204
|
catalogClient,
|
|
202
205
|
notificationClient,
|
|
203
206
|
authClient,
|
|
@@ -218,6 +221,7 @@ export default createBackendPlugin({
|
|
|
218
221
|
for (const action of createIncidentActions({
|
|
219
222
|
service,
|
|
220
223
|
getIncidentEntity: () => incidentEntity,
|
|
224
|
+
eventBus,
|
|
221
225
|
})) {
|
|
222
226
|
automationActions.registerAction(action, pluginMetadata);
|
|
223
227
|
}
|
|
@@ -317,6 +317,187 @@ describe("notifyAffectedSystems", () => {
|
|
|
317
317
|
});
|
|
318
318
|
});
|
|
319
319
|
|
|
320
|
+
describe("update message in body", () => {
|
|
321
|
+
it("appends the escaped update message as a blockquote", async () => {
|
|
322
|
+
await notifyAffectedSystems({
|
|
323
|
+
catalogClient: mockCatalogClient as never,
|
|
324
|
+
notificationClient: mockNotificationClient as never,
|
|
325
|
+
logger: mockLogger as never,
|
|
326
|
+
incidentId: "inc-1",
|
|
327
|
+
incidentTitle: "API Outage",
|
|
328
|
+
systemIds: ["sys-1"],
|
|
329
|
+
action: "updated",
|
|
330
|
+
severity: "minor",
|
|
331
|
+
updateMessage: "Rolled back the bad deploy, monitoring recovery.",
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
const call = (
|
|
335
|
+
mockNotificationClient.notifyForSubscription.mock
|
|
336
|
+
.calls[0] as unknown as [{ body?: string }]
|
|
337
|
+
)[0];
|
|
338
|
+
expect(call?.body).toContain(
|
|
339
|
+
"\n\n> Rolled back the bad deploy, monitoring recovery",
|
|
340
|
+
);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("escapes markdown control characters in the message", async () => {
|
|
344
|
+
await notifyAffectedSystems({
|
|
345
|
+
catalogClient: mockCatalogClient as never,
|
|
346
|
+
notificationClient: mockNotificationClient as never,
|
|
347
|
+
logger: mockLogger as never,
|
|
348
|
+
incidentId: "inc-1",
|
|
349
|
+
incidentTitle: "API Outage",
|
|
350
|
+
systemIds: ["sys-1"],
|
|
351
|
+
action: "updated",
|
|
352
|
+
severity: "minor",
|
|
353
|
+
updateMessage: "See [here](http://evil) **now** `code`",
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
const call = (
|
|
357
|
+
mockNotificationClient.notifyForSubscription.mock
|
|
358
|
+
.calls[0] as unknown as [{ body?: string }]
|
|
359
|
+
)[0];
|
|
360
|
+
// No unescaped link/bold/code syntax survives into the body.
|
|
361
|
+
expect(call?.body).not.toContain("[here](http://evil)");
|
|
362
|
+
expect(call?.body).not.toContain("**now**");
|
|
363
|
+
expect(call?.body).toContain("\\[here\\]");
|
|
364
|
+
expect(call?.body).toContain("\\*\\*now\\*\\*");
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("strips non-whitespace control characters (ESC/NUL/BEL/DEL)", async () => {
|
|
368
|
+
await notifyAffectedSystems({
|
|
369
|
+
catalogClient: mockCatalogClient as never,
|
|
370
|
+
notificationClient: mockNotificationClient as never,
|
|
371
|
+
logger: mockLogger as never,
|
|
372
|
+
incidentId: "inc-1",
|
|
373
|
+
incidentTitle: "API Outage",
|
|
374
|
+
systemIds: ["sys-1"],
|
|
375
|
+
action: "updated",
|
|
376
|
+
severity: "minor",
|
|
377
|
+
updateMessage: "before\u001B\u0000\u0007\u007Fafter",
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
const call = (
|
|
381
|
+
mockNotificationClient.notifyForSubscription.mock
|
|
382
|
+
.calls[0] as unknown as [{ body?: string }]
|
|
383
|
+
)[0];
|
|
384
|
+
const blockquoteLine = (call?.body ?? "").split("\n\n> ")[1] ?? "";
|
|
385
|
+
expect(blockquoteLine).toBe("beforeafter");
|
|
386
|
+
// No control characters survive into the excerpt.
|
|
387
|
+
expect(/[\u0000-\u001F\u007F-\u009F]/u.test(blockquoteLine)).toBe(
|
|
388
|
+
false,
|
|
389
|
+
);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it("escapes HTML-significant < and & so markup cannot be injected", async () => {
|
|
393
|
+
await notifyAffectedSystems({
|
|
394
|
+
catalogClient: mockCatalogClient as never,
|
|
395
|
+
notificationClient: mockNotificationClient as never,
|
|
396
|
+
logger: mockLogger as never,
|
|
397
|
+
incidentId: "inc-1",
|
|
398
|
+
incidentTitle: "API Outage",
|
|
399
|
+
systemIds: ["sys-1"],
|
|
400
|
+
action: "updated",
|
|
401
|
+
severity: "minor",
|
|
402
|
+
updateMessage: "watch <img onerror=x> & <script>",
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
const call = (
|
|
406
|
+
mockNotificationClient.notifyForSubscription.mock
|
|
407
|
+
.calls[0] as unknown as [{ body?: string }]
|
|
408
|
+
)[0];
|
|
409
|
+
const blockquoteLine = (call?.body ?? "").split("\n\n> ")[1] ?? "";
|
|
410
|
+
expect(blockquoteLine).not.toContain("<");
|
|
411
|
+
expect(blockquoteLine).toContain("<img");
|
|
412
|
+
expect(blockquoteLine).toContain("&");
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("collapses newlines so the message cannot break out of the blockquote", async () => {
|
|
416
|
+
await notifyAffectedSystems({
|
|
417
|
+
catalogClient: mockCatalogClient as never,
|
|
418
|
+
notificationClient: mockNotificationClient as never,
|
|
419
|
+
logger: mockLogger as never,
|
|
420
|
+
incidentId: "inc-1",
|
|
421
|
+
incidentTitle: "API Outage",
|
|
422
|
+
systemIds: ["sys-1"],
|
|
423
|
+
action: "updated",
|
|
424
|
+
severity: "minor",
|
|
425
|
+
updateMessage: "line one\n\nline two\ninjected",
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
const call = (
|
|
429
|
+
mockNotificationClient.notifyForSubscription.mock
|
|
430
|
+
.calls[0] as unknown as [{ body?: string }]
|
|
431
|
+
)[0];
|
|
432
|
+
const blockquoteLine = (call?.body ?? "").split("\n\n> ")[1] ?? "";
|
|
433
|
+
expect(blockquoteLine).not.toContain("\n");
|
|
434
|
+
expect(blockquoteLine).toBe("line one line two injected");
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("truncates an over-long message to a bounded length", async () => {
|
|
438
|
+
const longMessage = "a".repeat(1000);
|
|
439
|
+
await notifyAffectedSystems({
|
|
440
|
+
catalogClient: mockCatalogClient as never,
|
|
441
|
+
notificationClient: mockNotificationClient as never,
|
|
442
|
+
logger: mockLogger as never,
|
|
443
|
+
incidentId: "inc-1",
|
|
444
|
+
incidentTitle: "API Outage",
|
|
445
|
+
systemIds: ["sys-1"],
|
|
446
|
+
action: "updated",
|
|
447
|
+
severity: "minor",
|
|
448
|
+
updateMessage: longMessage,
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
const call = (
|
|
452
|
+
mockNotificationClient.notifyForSubscription.mock
|
|
453
|
+
.calls[0] as unknown as [{ body?: string }]
|
|
454
|
+
)[0];
|
|
455
|
+
const blockquoteLine = (call?.body ?? "").split("\n\n> ")[1] ?? "";
|
|
456
|
+
expect(blockquoteLine.endsWith("...")).toBe(true);
|
|
457
|
+
// 500 chars + the "..." indicator.
|
|
458
|
+
expect(blockquoteLine.length).toBeLessThanOrEqual(503);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it("omits the blockquote entirely for a blank/whitespace message", async () => {
|
|
462
|
+
await notifyAffectedSystems({
|
|
463
|
+
catalogClient: mockCatalogClient as never,
|
|
464
|
+
notificationClient: mockNotificationClient as never,
|
|
465
|
+
logger: mockLogger as never,
|
|
466
|
+
incidentId: "inc-1",
|
|
467
|
+
incidentTitle: "API Outage",
|
|
468
|
+
systemIds: ["sys-1"],
|
|
469
|
+
action: "updated",
|
|
470
|
+
severity: "minor",
|
|
471
|
+
updateMessage: " \n ",
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
const call = (
|
|
475
|
+
mockNotificationClient.notifyForSubscription.mock
|
|
476
|
+
.calls[0] as unknown as [{ body?: string }]
|
|
477
|
+
)[0];
|
|
478
|
+
expect(call?.body).not.toContain("\n\n>");
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it("omits the blockquote when no message is provided", async () => {
|
|
482
|
+
await notifyAffectedSystems({
|
|
483
|
+
catalogClient: mockCatalogClient as never,
|
|
484
|
+
notificationClient: mockNotificationClient as never,
|
|
485
|
+
logger: mockLogger as never,
|
|
486
|
+
incidentId: "inc-1",
|
|
487
|
+
incidentTitle: "API Outage",
|
|
488
|
+
systemIds: ["sys-1"],
|
|
489
|
+
action: "created",
|
|
490
|
+
severity: "minor",
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
const call = (
|
|
494
|
+
mockNotificationClient.notifyForSubscription.mock
|
|
495
|
+
.calls[0] as unknown as [{ body?: string }]
|
|
496
|
+
)[0];
|
|
497
|
+
expect(call?.body).not.toContain("\n\n>");
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
|
|
320
501
|
describe("error handling", () => {
|
|
321
502
|
it("logs a warning but does not throw when the notify call fails", async () => {
|
|
322
503
|
mockNotificationClient.notifyForSubscription.mockRejectedValue(
|
package/src/notifications.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { Logger } from "@checkstack/backend-api";
|
|
|
7
7
|
import type { InferClient } from "@checkstack/common";
|
|
8
8
|
import { resolveRoute } from "@checkstack/common";
|
|
9
9
|
import type { NotificationApi } from "@checkstack/notification-common";
|
|
10
|
+
import { buildUpdateMessageSuffix } from "@checkstack/notification-common";
|
|
10
11
|
import {
|
|
11
12
|
incidentRoutes,
|
|
12
13
|
incidentCollapseKey,
|
|
@@ -29,6 +30,13 @@ export async function notifyAffectedSystems(props: {
|
|
|
29
30
|
systemNames?: Map<string, string>;
|
|
30
31
|
action: "created" | "updated" | "resolved" | "reopened";
|
|
31
32
|
severity: string;
|
|
33
|
+
/**
|
|
34
|
+
* The latest incident update's free-text message. When present it is
|
|
35
|
+
* escaped, single-lined, truncated, and appended to the notification body as
|
|
36
|
+
* a blockquote so subscribers see WHAT changed, not just that something did.
|
|
37
|
+
* User-supplied, so it is always sanitized before it reaches a markdown body.
|
|
38
|
+
*/
|
|
39
|
+
updateMessage?: string;
|
|
32
40
|
}): Promise<void> {
|
|
33
41
|
const {
|
|
34
42
|
notificationClient,
|
|
@@ -39,6 +47,7 @@ export async function notifyAffectedSystems(props: {
|
|
|
39
47
|
systemNames,
|
|
40
48
|
action,
|
|
41
49
|
severity,
|
|
50
|
+
updateMessage,
|
|
42
51
|
} = props;
|
|
43
52
|
void props.catalogClient;
|
|
44
53
|
|
|
@@ -64,12 +73,14 @@ export async function notifyAffectedSystems(props: {
|
|
|
64
73
|
}),
|
|
65
74
|
);
|
|
66
75
|
|
|
76
|
+
const messageSuffix = buildUpdateMessageSuffix({ message: updateMessage });
|
|
77
|
+
|
|
67
78
|
try {
|
|
68
79
|
await notificationClient.notifyForSubscription({
|
|
69
80
|
specId: incidentSystemSubscription.specId,
|
|
70
81
|
resourceKeys: uniqueSystemIds,
|
|
71
82
|
title: `Incident ${actionText}: ${incidentTitle}`,
|
|
72
|
-
body: `Incident **"${incidentTitle}"** has been ${actionText}
|
|
83
|
+
body: `Incident **"${incidentTitle}"** has been ${actionText}.${messageSuffix}`,
|
|
73
84
|
importance,
|
|
74
85
|
action: { label: "View Incident", url: incidentDetailPath },
|
|
75
86
|
collapseKey: incidentCollapseKey(incidentId),
|