@checkstack/healthcheck-backend 1.17.0 → 1.19.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 +559 -0
- package/package.json +32 -29
- package/src/adaptive-timeout.test.ts +91 -0
- package/src/adaptive-timeout.ts +75 -0
- package/src/ai/system-signals-contributor.test.ts +2 -0
- package/src/automations.test.ts +47 -0
- package/src/automations.ts +19 -3
- package/src/health-notification-content.test.ts +89 -0
- package/src/health-notification-content.ts +138 -0
- package/src/healthcheck-gitops-kinds.test.ts +34 -2
- package/src/healthcheck-gitops-kinds.ts +17 -13
- package/src/index.ts +58 -6
- package/src/migration-chain-contract.test.ts +7 -1
- package/src/notification-policy.test.ts +19 -0
- package/src/notification-policy.ts +26 -0
- package/src/queue-executor.test.ts +391 -338
- package/src/queue-executor.ts +426 -362
- package/src/realtime-aggregation.ts +9 -2
- package/src/rollup-consumer.test.ts +191 -0
- package/src/rollup-consumer.ts +160 -0
- package/src/router.ts +46 -13
- package/src/schedule-jitter.test.ts +69 -0
- package/src/schedule-jitter.ts +50 -0
- package/src/schedule-reconciler.it.test.ts +453 -0
- package/src/schedule-reconciler.test.ts +418 -0
- package/src/schedule-reconciler.ts +304 -0
- package/src/service-batching.test.ts +106 -0
- package/src/service-bulk-counts.it.test.ts +144 -0
- package/src/service-bulk-run-stats.it.test.ts +197 -0
- package/src/service-ordering.test.ts +10 -2
- package/src/service-paused-filter.test.ts +27 -7
- package/src/service-rollup-worst-wins.test.ts +221 -124
- package/src/service.ts +557 -266
- package/src/slow-check-admission.test.ts +184 -0
- package/src/slow-check-admission.ts +101 -0
- package/src/slow-check-classifier.test.ts +155 -0
- package/src/slow-check-classifier.ts +137 -0
- package/src/slow-check-config.ts +102 -0
- package/src/status-page/rollup.test.ts +40 -0
- package/src/status-page/rollup.ts +27 -0
- package/src/status-page/widgets.test.ts +303 -0
- package/src/status-page/widgets.ts +155 -39
- package/src/suspect-lane.test.ts +50 -0
- package/src/suspect-lane.ts +61 -0
package/src/index.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
setupHealthCheckWorker,
|
|
3
|
-
bootstrapHealthChecks,
|
|
4
3
|
recomputeSystemRollupHealth,
|
|
5
4
|
} from "./queue-executor";
|
|
5
|
+
import { reconcileHealthCheckJobs } from "./schedule-reconciler";
|
|
6
6
|
import { setupRetentionJob } from "./retention-job";
|
|
7
7
|
import * as schema from "./schema";
|
|
8
8
|
import {
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
type SafeDatabase,
|
|
39
39
|
type HealthCheckRegistry,
|
|
40
40
|
type CollectorRegistry,
|
|
41
|
+
type AdvisoryLockService,
|
|
41
42
|
} from "@checkstack/backend-api";
|
|
42
43
|
import type { QueueManager } from "@checkstack/queue-api";
|
|
43
44
|
import {
|
|
@@ -75,6 +76,9 @@ import { IncidentApi } from "@checkstack/incident-common";
|
|
|
75
76
|
import { GitOpsApi } from "@checkstack/gitops-common";
|
|
76
77
|
import { registerSearchProvider } from "@checkstack/command-backend";
|
|
77
78
|
import { resolveRoute } from "@checkstack/common";
|
|
79
|
+
import type { InferClient } from "@checkstack/common";
|
|
80
|
+
import type { SignalService } from "@checkstack/signal-common";
|
|
81
|
+
import { setupRollupConsumer } from "./rollup-consumer";
|
|
78
82
|
import { createHealthCheckCache } from "./cache";
|
|
79
83
|
import { inArray, ilike } from "drizzle-orm";
|
|
80
84
|
|
|
@@ -174,6 +178,12 @@ export default createBackendPlugin({
|
|
|
174
178
|
let gitopsCollectorRegistry: CollectorRegistry | undefined;
|
|
175
179
|
let gitopsQueueManager: QueueManager | undefined;
|
|
176
180
|
let gitopsConfigSecrets: HealthCheckSecretsDeps | undefined;
|
|
181
|
+
let gitopsCatalogClient: InferClient<typeof CatalogApi> | undefined;
|
|
182
|
+
// Resolved AdvisoryLockService captured in init() for use in
|
|
183
|
+
// afterPluginsReady (the boot reconcile serializes across pods on it).
|
|
184
|
+
let resolvedAdvisoryLock: AdvisoryLockService | undefined;
|
|
185
|
+
// SignalService captured in init() for the afterPluginsReady rollup consumer.
|
|
186
|
+
let resolvedSignalService: SignalService | undefined;
|
|
177
187
|
let healthCheckCache:
|
|
178
188
|
| ReturnType<typeof createHealthCheckCache>
|
|
179
189
|
| undefined;
|
|
@@ -211,6 +221,15 @@ export default createBackendPlugin({
|
|
|
211
221
|
throw new Error("QueueManager not initialized");
|
|
212
222
|
return gitopsQueueManager;
|
|
213
223
|
},
|
|
224
|
+
getDb: () => {
|
|
225
|
+
if (!gitopsDb) throw new Error("Healthcheck database not initialized");
|
|
226
|
+
return gitopsDb;
|
|
227
|
+
},
|
|
228
|
+
getCatalogClient: () => {
|
|
229
|
+
if (!gitopsCatalogClient)
|
|
230
|
+
throw new Error("Catalog client not initialized");
|
|
231
|
+
return gitopsCatalogClient;
|
|
232
|
+
},
|
|
214
233
|
});
|
|
215
234
|
|
|
216
235
|
env.registerInit({
|
|
@@ -317,6 +336,9 @@ export default createBackendPlugin({
|
|
|
317
336
|
gitopsHealthCheckRegistry = healthCheckRegistry;
|
|
318
337
|
gitopsCollectorRegistry = collectorRegistry;
|
|
319
338
|
gitopsQueueManager = queueManager;
|
|
339
|
+
gitopsCatalogClient = rpcClient.forPlugin(CatalogApi);
|
|
340
|
+
resolvedAdvisoryLock = advisoryLock;
|
|
341
|
+
resolvedSignalService = signalService;
|
|
320
342
|
|
|
321
343
|
// Bind the COMPUTE-ON-READ accessor's db + service for the `health`
|
|
322
344
|
// entity (defined in register()). From here onward the entity `read`
|
|
@@ -553,8 +575,8 @@ export default createBackendPlugin({
|
|
|
553
575
|
return mapped;
|
|
554
576
|
},
|
|
555
577
|
},
|
|
556
|
-
recomputeSystemRollupHealth: (systemId) =>
|
|
557
|
-
recomputeSystemRollupHealth({
|
|
578
|
+
recomputeSystemRollupHealth: async (systemId) => {
|
|
579
|
+
await recomputeSystemRollupHealth({
|
|
558
580
|
systemId,
|
|
559
581
|
// Reuse the COMPUTE-ON-READ service instance bound to the
|
|
560
582
|
// `health` entity read accessor — it's the same db/registry
|
|
@@ -563,7 +585,8 @@ export default createBackendPlugin({
|
|
|
563
585
|
getHealthEntity: () => healthEntity,
|
|
564
586
|
advisoryLock,
|
|
565
587
|
logger,
|
|
566
|
-
})
|
|
588
|
+
});
|
|
589
|
+
},
|
|
567
590
|
});
|
|
568
591
|
rpc.registerRouter(healthCheckRouter, healthCheckContract);
|
|
569
592
|
|
|
@@ -607,11 +630,17 @@ export default createBackendPlugin({
|
|
|
607
630
|
}) => {
|
|
608
631
|
// Store emitHook for the queue worker (Closure-based Hook Getter pattern)
|
|
609
632
|
storedEmitHook = emitHook;
|
|
610
|
-
//
|
|
611
|
-
|
|
633
|
+
// Converge the per-environment recurring job set at boot (schedule
|
|
634
|
+
// desired (config, system, env) jobs, cancel orphans incl. old-format
|
|
635
|
+
// ones). The periodic reconcile below keeps it converged as catalog
|
|
636
|
+
// membership changes.
|
|
637
|
+
const reconcileCatalogClient = rpcClient.forPlugin(CatalogApi);
|
|
638
|
+
await reconcileHealthCheckJobs({
|
|
612
639
|
db: database,
|
|
613
640
|
queueManager,
|
|
641
|
+
catalogClient: reconcileCatalogClient,
|
|
614
642
|
logger,
|
|
643
|
+
advisoryLock: resolvedAdvisoryLock,
|
|
615
644
|
});
|
|
616
645
|
|
|
617
646
|
// Notification subscription specs. Per-resource group lifecycle
|
|
@@ -649,11 +678,34 @@ export default createBackendPlugin({
|
|
|
649
678
|
for (const action of createHealthCheckActions({
|
|
650
679
|
service,
|
|
651
680
|
queueManager,
|
|
681
|
+
catalogClient: reconcileCatalogClient,
|
|
652
682
|
emitHook,
|
|
653
683
|
})) {
|
|
654
684
|
automationActions.registerAction(action, pluginMetadata);
|
|
655
685
|
}
|
|
656
686
|
|
|
687
|
+
// Phase 2: event-driven debounced system-rollup consumer. Under
|
|
688
|
+
// per-environment jobs each run writes only its own env entity; this
|
|
689
|
+
// subscribes to per-env `health` changes and debounces a recompute of
|
|
690
|
+
// the bare `<systemId>` rollup entity (+ SYSTEM_STATUS_CHANGED). Needs
|
|
691
|
+
// the cache/signal/lock resolved in init().
|
|
692
|
+
if (resolvedAdvisoryLock && resolvedSignalService && healthCheckCache) {
|
|
693
|
+
await setupRollupConsumer({
|
|
694
|
+
queueManager,
|
|
695
|
+
onEntityChanged: entityPoint.onEntityChanged,
|
|
696
|
+
service,
|
|
697
|
+
advisoryLock: resolvedAdvisoryLock,
|
|
698
|
+
signalService: resolvedSignalService,
|
|
699
|
+
cache: healthCheckCache,
|
|
700
|
+
getHealthEntity: () => healthEntity,
|
|
701
|
+
logger,
|
|
702
|
+
});
|
|
703
|
+
} else {
|
|
704
|
+
logger.warn(
|
|
705
|
+
"Health rollup consumer NOT wired: advisoryLock/signalService/cache unresolved after init",
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
|
|
657
709
|
// React to catalog system deletion (tombstone) via the reactive
|
|
658
710
|
// `catalog-system` entity instead of the (removed) `system.deleted`
|
|
659
711
|
// hook (§10.4). `work-queue` delivery preserved: association cleanup
|
|
@@ -19,7 +19,10 @@ import { describe, expect, it } from "bun:test";
|
|
|
19
19
|
import type { QueueManager } from "@checkstack/queue-api";
|
|
20
20
|
import type { Hook } from "@checkstack/backend-api";
|
|
21
21
|
import { stateThresholds } from "./state-thresholds-migrations";
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
createHealthCheckActions,
|
|
24
|
+
type HealthCheckActionDeps,
|
|
25
|
+
} from "./automations";
|
|
23
26
|
import type { HealthCheckService } from "./service";
|
|
24
27
|
|
|
25
28
|
// `createHealthCheckActions` only constructs the action definitions; the deps
|
|
@@ -27,6 +30,8 @@ import type { HealthCheckService } from "./service";
|
|
|
27
30
|
// Stubs are sufficient.
|
|
28
31
|
const stubService = {} as unknown as HealthCheckService;
|
|
29
32
|
const stubQueueManager = {} as unknown as QueueManager;
|
|
33
|
+
const stubCatalogClient =
|
|
34
|
+
{} as unknown as HealthCheckActionDeps["catalogClient"];
|
|
30
35
|
const stubEmitHook = async <T>(_hook: Hook<T>, _payload: T): Promise<void> => {};
|
|
31
36
|
|
|
32
37
|
describe("healthcheck config migration-chain contract", () => {
|
|
@@ -42,6 +47,7 @@ describe("healthcheck config migration-chain contract", () => {
|
|
|
42
47
|
const actions = createHealthCheckActions({
|
|
43
48
|
service: stubService,
|
|
44
49
|
queueManager: stubQueueManager,
|
|
50
|
+
catalogClient: stubCatalogClient,
|
|
45
51
|
emitHook: stubEmitHook,
|
|
46
52
|
});
|
|
47
53
|
expect(actions.length).toBeGreaterThan(0);
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
} from "@checkstack/healthcheck-common";
|
|
6
6
|
import {
|
|
7
7
|
classifyTransition,
|
|
8
|
+
shouldEmitRollupNotification,
|
|
8
9
|
shouldNotifyTransition,
|
|
9
10
|
type TransitionKind,
|
|
10
11
|
} from "./notification-policy";
|
|
@@ -79,6 +80,24 @@ describe("shouldNotifyTransition", () => {
|
|
|
79
80
|
});
|
|
80
81
|
});
|
|
81
82
|
|
|
83
|
+
describe("shouldEmitRollupNotification", () => {
|
|
84
|
+
// Regression guard for the duplicate-notification bug: a fanned-out system
|
|
85
|
+
// whose environment goes unhealthy notified once per env ("... in env X").
|
|
86
|
+
// The rollup notification ("... is unhealthy") describes the same outage, so
|
|
87
|
+
// it must be suppressed whenever an environment already notified this tick.
|
|
88
|
+
it("suppresses the rollup notification when an environment already notified", () => {
|
|
89
|
+
expect(
|
|
90
|
+
shouldEmitRollupNotification({ anyEnvironmentNotified: true }),
|
|
91
|
+
).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("emits the rollup notification as a fallback when no environment notified", () => {
|
|
95
|
+
expect(
|
|
96
|
+
shouldEmitRollupNotification({ anyEnvironmentNotified: false }),
|
|
97
|
+
).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
82
101
|
describe("flapping scenario from the bug report", () => {
|
|
83
102
|
// healthy → degraded → unhealthy → degraded → healthy
|
|
84
103
|
//
|
|
@@ -54,3 +54,29 @@ export function shouldNotifyTransition(
|
|
|
54
54
|
if (kind === "deescalation" && policy.suppressDeEscalations) return false;
|
|
55
55
|
return true;
|
|
56
56
|
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Decide whether the system-ROLLUP notification should fire for a fanned-out
|
|
60
|
+
* system in a given tick.
|
|
61
|
+
*
|
|
62
|
+
* When a check fans out to environments, each environment that changes status
|
|
63
|
+
* emits its own notification ("system unhealthy in env X"). The post-loop
|
|
64
|
+
* rollup transition ("system unhealthy") then describes the SAME underlying
|
|
65
|
+
* outage, so firing it too produces the duplicate notification pair users see.
|
|
66
|
+
*
|
|
67
|
+
* The rollup change is always driven by the very environment(s) that already
|
|
68
|
+
* notified this tick, so the rollup notification is redundant whenever any
|
|
69
|
+
* environment notified. It is only emitted as a fallback when NO environment
|
|
70
|
+
* notified (e.g. every per-env delivery was suppressed or threw) so a real
|
|
71
|
+
* system status change never goes entirely unannounced.
|
|
72
|
+
*
|
|
73
|
+
* The rollup TRANSITION record and the `SYSTEM_STATUS_CHANGED` signal are
|
|
74
|
+
* emitted regardless — only the user-facing notification is deduplicated.
|
|
75
|
+
*/
|
|
76
|
+
export function shouldEmitRollupNotification({
|
|
77
|
+
anyEnvironmentNotified,
|
|
78
|
+
}: {
|
|
79
|
+
anyEnvironmentNotified: boolean;
|
|
80
|
+
}): boolean {
|
|
81
|
+
return !anyEnvironmentNotified;
|
|
82
|
+
}
|