@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.
Files changed (44) hide show
  1. package/CHANGELOG.md +559 -0
  2. package/package.json +32 -29
  3. package/src/adaptive-timeout.test.ts +91 -0
  4. package/src/adaptive-timeout.ts +75 -0
  5. package/src/ai/system-signals-contributor.test.ts +2 -0
  6. package/src/automations.test.ts +47 -0
  7. package/src/automations.ts +19 -3
  8. package/src/health-notification-content.test.ts +89 -0
  9. package/src/health-notification-content.ts +138 -0
  10. package/src/healthcheck-gitops-kinds.test.ts +34 -2
  11. package/src/healthcheck-gitops-kinds.ts +17 -13
  12. package/src/index.ts +58 -6
  13. package/src/migration-chain-contract.test.ts +7 -1
  14. package/src/notification-policy.test.ts +19 -0
  15. package/src/notification-policy.ts +26 -0
  16. package/src/queue-executor.test.ts +391 -338
  17. package/src/queue-executor.ts +426 -362
  18. package/src/realtime-aggregation.ts +9 -2
  19. package/src/rollup-consumer.test.ts +191 -0
  20. package/src/rollup-consumer.ts +160 -0
  21. package/src/router.ts +46 -13
  22. package/src/schedule-jitter.test.ts +69 -0
  23. package/src/schedule-jitter.ts +50 -0
  24. package/src/schedule-reconciler.it.test.ts +453 -0
  25. package/src/schedule-reconciler.test.ts +418 -0
  26. package/src/schedule-reconciler.ts +304 -0
  27. package/src/service-batching.test.ts +106 -0
  28. package/src/service-bulk-counts.it.test.ts +144 -0
  29. package/src/service-bulk-run-stats.it.test.ts +197 -0
  30. package/src/service-ordering.test.ts +10 -2
  31. package/src/service-paused-filter.test.ts +27 -7
  32. package/src/service-rollup-worst-wins.test.ts +221 -124
  33. package/src/service.ts +557 -266
  34. package/src/slow-check-admission.test.ts +184 -0
  35. package/src/slow-check-admission.ts +101 -0
  36. package/src/slow-check-classifier.test.ts +155 -0
  37. package/src/slow-check-classifier.ts +137 -0
  38. package/src/slow-check-config.ts +102 -0
  39. package/src/status-page/rollup.test.ts +40 -0
  40. package/src/status-page/rollup.ts +27 -0
  41. package/src/status-page/widgets.test.ts +303 -0
  42. package/src/status-page/widgets.ts +155 -39
  43. package/src/suspect-lane.test.ts +50 -0
  44. package/src/suspect-lane.ts +61 -0
@@ -1,98 +1,146 @@
1
1
  import { describe, it, expect, mock } from "bun:test";
2
+ import { withTransactionMock } from "@checkstack/test-utils-backend";
2
3
  import { HealthCheckService } from "./service";
3
4
  import { evaluateHealthStatus } from "./state-evaluator";
4
5
 
5
6
  /**
6
- * Regression coverage for the system-rollup worst-wins-across-environments
7
- * fix in `getSystemHealthStatus(systemId)` (the `environmentId === undefined`
8
- * branch).
7
+ * Regression coverage for the system-rollup derivation in
8
+ * `getSystemHealthStatus(systemId)` (the `environmentId === undefined` branch):
9
9
  *
10
- * The original branch flattened every environment's runs into one
11
- * `timestamp DESC` list and handed the interleaved list to the threshold
12
- * evaluator (the default `consecutive` mode). Consecutive mode walks
13
- * newest-first and breaks the streak on the first interleaving env, so the
14
- * rollup collapsed to whichever env ran last in the batch — masking any
15
- * permanently-failing sibling env ("the healthy env wins" / latest-wins)
16
- * and flapping whenever env insertion order drifted across ticks.
10
+ * 1. Worst-wins ACROSS environments within an association. The original branch
11
+ * flattened every environment's runs into one `timestamp DESC` list and
12
+ * handed the interleaved list to the threshold evaluator (default
13
+ * `consecutive` mode). Consecutive mode walks newest-first and breaks the
14
+ * streak on the first interleaving env, so the rollup collapsed to whichever
15
+ * env ran last — masking a permanently-failing sibling env ("the healthy env
16
+ * wins" / latest-wins) and flapping whenever env insertion order drifted.
17
+ * The fix evaluates a FULL per-env window and takes worst-wins across envs.
17
18
  *
18
- * The fix evaluates the threshold window PER ENVIRONMENT within the
19
- * association and takes worst-wins across envs (unhealthy > degraded >
20
- * healthy), making the rollup stable regardless of insertion order or
21
- * multi-pod racing. These tests pin that behavior with a mocked DB that
22
- * returns the interleaved mixed-pool the real query would surface.
19
+ * 2. Currently-effective-slice filtering. A per-env slice whose environment was
20
+ * DISABLED for the assignment (removed from `environmentIds`) must STOP
21
+ * contributing immediately - its stale unhealthy runs must not keep dragging
22
+ * the rollup until they age out of the window.
23
+ *
24
+ * Each environment is now windowed by its OWN query (per-env `LIMIT`), so the
25
+ * mock resolves each per-env runs query against the env bound in its predicate.
23
26
  */
24
- describe("HealthCheckService - system rollup worst-wins across environments", () => {
25
- /**
26
- * The mixed-pool query captured by the mock. Ordered DESC (newest first),
27
- * exactly the shape the real `health_check_runs` query returns. Two envs
28
- * (`prod`, `staging`) of one assignment, both fanning out every tick, prod
29
- * permanently unhealthy and staging permanently healthy. The env insertion
30
- * order in the executor is sequential membership order, so prod lands before
31
- * staging, making the latest run in the pool a staging-healthy run — the
32
- * exact scenario that masked prod's outage under flattening.
33
- */
34
- const PROD_RUN = { status: "unhealthy" as const, environmentId: "prod" };
35
- const STAGE_RUN = { status: "healthy" as const, environmentId: "staging" };
36
-
37
- function buildMixedPool(ticksPerEnv = 5): { status: "unhealthy" | "healthy"; timestamp: Date; environmentId: string }[] {
38
- const pool: { status: "unhealthy" | "healthy"; timestamp: Date; environmentId: string }[] = [];
39
- for (let i = 0; i < ticksPerEnv; i++) {
40
- pool.push({ ...PROD_RUN, timestamp: new Date(2025, 0, 1, 0, 0, i) });
41
- pool.push({ ...STAGE_RUN, timestamp: new Date(2025, 0, 1, 0, 0, i + 0.5) });
27
+
28
+ /** Walk a drizzle predicate object and collect every bound literal value. */
29
+ function collectPredicateValues(predicate: unknown): string[] {
30
+ const values: string[] = [];
31
+ const seen = new Set<unknown>();
32
+ const walk = (node: unknown) => {
33
+ if (node == null || seen.has(node) || typeof node !== "object") return;
34
+ seen.add(node);
35
+ if ("value" in (node as Record<string, unknown>)) {
36
+ const v = (node as { value: unknown }).value;
37
+ if (typeof v === "string") values.push(v);
42
38
  }
43
- return pool; // DESC at the DB layer; we return newest-first below.
44
- }
39
+ for (const child of Object.values(node as Record<string, unknown>)) {
40
+ walk(child);
41
+ }
42
+ };
43
+ walk(predicate);
44
+ return values;
45
+ }
46
+
47
+ type Run = { status: "healthy" | "degraded" | "unhealthy"; timestamp: Date };
45
48
 
46
- function createMockDb(runsMixedDesc: { status: string; timestamp: Date; environmentId: string }[]) {
47
- const assocWhere = mock(() => Promise.resolve([
49
+ /**
50
+ * Build a mock db for the rollup path. `runsByEnv` maps each environment key
51
+ * (`null` = env-less) to that env's runs (DESC). `environmentIds` is the
52
+ * assignment's selector under test. The per-env runs query resolves against the
53
+ * concrete env id bound in its predicate (or the env-less slice when none of the
54
+ * known env ids appear, i.e. the `isNull` clause).
55
+ */
56
+ function createRollupMockDb(props: {
57
+ runsByEnv: Map<string | null, Run[]>;
58
+ environmentIds: string[] | null;
59
+ }) {
60
+ const { runsByEnv, environmentIds } = props;
61
+ const knownEnvIds = new Set(
62
+ [...runsByEnv.keys()].filter((k): k is string => k !== null),
63
+ );
64
+
65
+ const assocWhere = mock(() =>
66
+ Promise.resolve([
48
67
  {
49
68
  configurationId: "config-1",
50
69
  configName: "HTTP probe",
51
70
  enabled: true,
52
71
  paused: false,
53
72
  stateThresholds: null,
73
+ environmentIds,
54
74
  },
55
- ]));
56
- const assocInnerJoin = Object.assign(Promise.resolve([]), { where: assocWhere });
57
- const assocFrom = Object.assign(Promise.resolve([]), { innerJoin: mock(() => assocInnerJoin) });
75
+ ]),
76
+ );
77
+ const assocInnerJoin = Object.assign(Promise.resolve([]), {
78
+ where: assocWhere,
79
+ });
80
+ const assocFrom = Object.assign(Promise.resolve([]), {
81
+ innerJoin: mock(() => assocInnerJoin),
82
+ });
58
83
 
59
- const runsLimit = mock(() => Promise.resolve(runsMixedDesc));
60
- const runsOrderBy = mock(() => ({ limit: runsLimit }));
61
- const runsWhere = mock(() => ({ orderBy: runsOrderBy, limit: runsLimit }));
62
- const runsFrom = Object.assign(Promise.resolve(runsMixedDesc), {
63
- where: runsWhere,
64
- orderBy: runsOrderBy,
84
+ // Per-env runs query: pick the slice named by the predicate's env value.
85
+ const resolvePerEnv = (predicate: unknown): Run[] => {
86
+ const values = collectPredicateValues(predicate);
87
+ const envId = values.find((v) => knownEnvIds.has(v)) ?? null;
88
+ return runsByEnv.get(envId) ?? [];
89
+ };
90
+ const runsFromFor = () => {
91
+ const runsWhere = mock((predicate: unknown) => {
92
+ const rows = resolvePerEnv(predicate);
93
+ const limit = mock(() => Promise.resolve(rows));
94
+ return { orderBy: mock(() => ({ limit })), limit };
65
95
  });
96
+ return Object.assign(Promise.resolve([]), { where: runsWhere });
97
+ };
66
98
 
67
- let selectCallCount = 0;
68
- return {
69
- select: mock(() => {
70
- selectCallCount += 1;
71
- if (selectCallCount === 1) return { from: mock(() => assocFrom) };
72
- return { from: mock(() => runsFrom) };
73
- }),
74
- insert: mock(() => ({
75
- values: mock(() => ({
76
- onConflictDoUpdate: mock(() => Promise.resolve()),
77
- onConflictDoNothing: mock(() => Promise.resolve()),
78
- returning: mock(() => Promise.resolve([])),
79
- })),
99
+ // Distinct env keys query: select({environmentId}).from().where().
100
+ const distinctFrom = Object.assign(Promise.resolve([]), {
101
+ where: mock(() =>
102
+ Promise.resolve([...runsByEnv.keys()].map((k) => ({ environmentId: k }))),
103
+ ),
104
+ });
105
+
106
+ let selectCallCount = 0;
107
+ return withTransactionMock({
108
+ select: mock(() => {
109
+ selectCallCount += 1;
110
+ // #1 associations; every subsequent select is a per-env runs window.
111
+ if (selectCallCount === 1) return { from: mock(() => assocFrom) };
112
+ return { from: mock(() => runsFromFor()) };
113
+ }),
114
+ selectDistinct: mock(() => ({ from: mock(() => distinctFrom) })),
115
+ insert: mock(() => ({
116
+ values: mock(() => ({
117
+ onConflictDoUpdate: mock(() => Promise.resolve()),
118
+ onConflictDoNothing: mock(() => Promise.resolve()),
119
+ returning: mock(() => Promise.resolve([])),
80
120
  })),
81
- update: mock(() => ({ set: mock(() => ({ where: mock(() => Promise.resolve()) })) })),
82
- delete: mock(() => ({ where: mock(() => Promise.resolve()) })),
83
- execute: mock(() => Promise.resolve()),
84
- };
85
- }
86
-
87
- it("the rollup reports unhealthy when ONE env is permanently unhealthy, the other healthy", async () => {
88
- // DB returns newest-first interleaved runs. The pre-fix behavior would
89
- // mask prod's outage because the latest run is staging-healthy; the
90
- // threshold evaluator (default consecutive mode) walks newest-first from
91
- // staging-healthy, breaks the streak on the very next prod-unhealthy run,
92
- // and falls back to `"healthy"`.
93
- const pool = buildMixedPool(5);
94
- const runsDesc = pool.toReversed(); // oldest produced first above; reverse to DESC
95
- const mockDb = createMockDb(runsDesc as never);
121
+ })),
122
+ update: mock(() => ({
123
+ set: mock(() => ({ where: mock(() => Promise.resolve()) })),
124
+ })),
125
+ delete: mock(() => ({ where: mock(() => Promise.resolve()) })),
126
+ execute: mock(() => Promise.resolve()),
127
+ });
128
+ }
129
+
130
+ function runs(status: Run["status"], count: number, envSecondOffset = 0): Run[] {
131
+ return Array.from({ length: count }, (_, i) => ({
132
+ status,
133
+ timestamp: new Date(2025, 0, 1, 0, 0, i, envSecondOffset),
134
+ })).toReversed(); // DESC (newest first)
135
+ }
136
+
137
+ describe("HealthCheckService - system rollup worst-wins across environments", () => {
138
+ it("reports unhealthy when ONE env is permanently unhealthy, the other healthy", async () => {
139
+ const runsByEnv = new Map<string | null, Run[]>([
140
+ ["prod", runs("unhealthy", 5)],
141
+ ["staging", runs("healthy", 5)],
142
+ ]);
143
+ const mockDb = createRollupMockDb({ runsByEnv, environmentIds: null });
96
144
  const service = new HealthCheckService(mockDb as never, {} as never, {} as never);
97
145
 
98
146
  const result = await service.getSystemHealthStatus("system-1");
@@ -100,29 +148,46 @@ describe("HealthCheckService - system rollup worst-wins across environments", ()
100
148
  expect(result.status).toBe("unhealthy");
101
149
  expect(result.checkStatuses).toHaveLength(1);
102
150
  expect(result.checkStatuses[0].status).toBe("unhealthy");
103
- expect(result.checkStatuses[0].runsConsidered).toBe(pool.length);
151
+ expect(result.checkStatuses[0].runsConsidered).toBe(10);
152
+ // Fan-out accounting: two environment slices (prod + staging), one failing.
153
+ expect(result.checkStatuses[0].sliceCount).toBe(2);
154
+ expect(result.checkStatuses[0].failingSliceCount).toBe(1);
104
155
  });
105
156
 
106
- it("flattening the same mixed pool through the evaluator (the pre-fix derivation) would have returned `healthy`", async () => {
157
+ it("counts every failing environment slice for the fan-out denominator (3 envs, 2 failing)", async () => {
158
+ const runsByEnv = new Map<string | null, Run[]>([
159
+ ["prod", runs("unhealthy", 5)],
160
+ ["eu", runs("unhealthy", 5)],
161
+ ["staging", runs("healthy", 5)],
162
+ ]);
163
+ const mockDb = createRollupMockDb({ runsByEnv, environmentIds: null });
164
+ const service = new HealthCheckService(mockDb as never, {} as never, {} as never);
165
+
166
+ const result = await service.getSystemHealthStatus("system-1");
167
+ expect(result.status).toBe("unhealthy");
168
+ expect(result.checkStatuses[0].sliceCount).toBe(3);
169
+ expect(result.checkStatuses[0].failingSliceCount).toBe(2);
170
+ });
171
+
172
+ it("flattening the same mixed pool through the evaluator (the pre-fix derivation) would have returned `healthy`", () => {
107
173
  // Sanity check: the very data the rollup branch reads, fed directly to
108
174
  // `evaluateHealthStatus` as one flat interleaved list, collapses to
109
- // "healthy" — the precise regression this fix replaces with per-env
110
- // evaluation. Pinning it here guards against a relax of the test above.
111
- const pool = buildMixedPool(5);
112
- const runsDesc = pool.toReversed();
113
- const flatStatus = evaluateHealthStatus({
114
- runs: runsDesc as never,
115
- });
175
+ // "healthy" — the precise regression per-env evaluation replaces.
176
+ const pool: Run[] = [];
177
+ for (let i = 0; i < 5; i++) {
178
+ pool.push({ status: "unhealthy", timestamp: new Date(2025, 0, 1, 0, 0, i) });
179
+ pool.push({ status: "healthy", timestamp: new Date(2025, 0, 1, 0, 0, i, 500) });
180
+ }
181
+ const flatStatus = evaluateHealthStatus({ runs: pool.toReversed() as never });
116
182
  expect(flatStatus).toBe("healthy");
117
183
  });
118
184
 
119
185
  it("reports healthy only when EVERY env is healthy", async () => {
120
- const allHealthy = Array.from({ length: 10 }, (_, i) => ({
121
- status: "healthy",
122
- timestamp: new Date(2025, 0, 1, 0, 0, i),
123
- environmentId: i % 2 === 0 ? "prod" : "staging",
124
- })).toReversed();
125
- const mockDb = createMockDb(allHealthy as never);
186
+ const runsByEnv = new Map<string | null, Run[]>([
187
+ ["prod", runs("healthy", 5)],
188
+ ["staging", runs("healthy", 5)],
189
+ ]);
190
+ const mockDb = createRollupMockDb({ runsByEnv, environmentIds: null });
126
191
  const service = new HealthCheckService(mockDb as never, {} as never, {} as never);
127
192
 
128
193
  const result = await service.getSystemHealthStatus("system-1");
@@ -130,46 +195,76 @@ describe("HealthCheckService - system rollup worst-wins across environments", ()
130
195
  });
131
196
 
132
197
  it("degrades (not flaps) when one env is degraded and the other healthy", async () => {
133
- // Default consecutive thresholds need 2 consecutive failures to escalate
134
- // to `degraded` (and 5 to escalate to `unhealthy` — so keep prod's streak
135
- // at exactly 2 degraded runs). Per-env: prod's env-sorted slice =
136
- // [degraded, degraded] (newest first) → degraded; staging → healthy.
137
- // Rollup worst-wins = degraded. Flattening would break on the staging
138
- // interleave and return `healthy` (the masked bug); per-env gives a
139
- // stable `degraded`.
140
- const pool: { status: "healthy" | "degraded"; timestamp: Date; environmentId: string }[] = [];
141
- for (let i = 0; i < 2; i++) {
142
- pool.push({ status: "degraded", timestamp: new Date(2025, 0, 1, 0, 0, i), environmentId: "prod" });
143
- pool.push({ status: "healthy", timestamp: new Date(2025, 0, 1, 0, 0, i + 0.5), environmentId: "staging" });
144
- }
145
- const runsDesc = pool.toReversed();
146
- const mockDb = createMockDb(runsDesc as never);
198
+ const runsByEnv = new Map<string | null, Run[]>([
199
+ ["prod", runs("degraded", 2)],
200
+ ["staging", runs("healthy", 2)],
201
+ ]);
202
+ const mockDb = createRollupMockDb({ runsByEnv, environmentIds: null });
147
203
  const service = new HealthCheckService(mockDb as never, {} as never, {} as never);
148
204
 
149
205
  const result = await service.getSystemHealthStatus("system-1");
150
206
  expect(result.status).toBe("degraded");
151
207
  });
152
208
 
209
+ it("drops a DISABLED environment's stale unhealthy runs from the rollup (regression)", async () => {
210
+ // prod was DISABLED for the assignment (environmentIds now ['staging']) but
211
+ // its historical unhealthy runs still exist. The rollup must ignore prod and
212
+ // read healthy from the sole effective env (staging), immediately - not after
213
+ // prod's runs age out of the window.
214
+ const runsByEnv = new Map<string | null, Run[]>([
215
+ ["prod", runs("unhealthy", 5)],
216
+ ["staging", runs("healthy", 5)],
217
+ ]);
218
+ const mockDb = createRollupMockDb({
219
+ runsByEnv,
220
+ environmentIds: ["staging"],
221
+ });
222
+ const service = new HealthCheckService(mockDb as never, {} as never, {} as never);
223
+
224
+ const result = await service.getSystemHealthStatus("system-1");
225
+
226
+ expect(result.status).toBe("healthy");
227
+ expect(result.checkStatuses[0].status).toBe("healthy");
228
+ // Only the effective (staging) slice counts now.
229
+ expect(result.checkStatuses[0].sliceCount).toBe(1);
230
+ expect(result.checkStatuses[0].failingSliceCount).toBe(0);
231
+ expect(result.checkStatuses[0].runsConsidered).toBe(5);
232
+ });
233
+
234
+ it("opting out ([]) drops all concrete-env runs and keeps only the env-less slice", async () => {
235
+ const runsByEnv = new Map<string | null, Run[]>([
236
+ ["prod", runs("unhealthy", 5)],
237
+ [null, runs("healthy", 3)],
238
+ ]);
239
+ const mockDb = createRollupMockDb({ runsByEnv, environmentIds: [] });
240
+ const service = new HealthCheckService(mockDb as never, {} as never, {} as never);
241
+
242
+ const result = await service.getSystemHealthStatus("system-1");
243
+ expect(result.status).toBe("healthy");
244
+ expect(result.checkStatuses[0].sliceCount).toBe(1);
245
+ expect(result.checkStatuses[0].runsConsidered).toBe(3);
246
+ });
247
+
153
248
  it("the per-env slice (concrete environmentId) is unaffected — only the rollup branch changed", async () => {
154
- // Pass an explicit environmentId; the old per-env branch (string envId)
155
- // must continue to filter to that env's slice. Here we ask for `prod`
156
- // and expect unhealthy.
157
- const prodOnly = Array.from({ length: 5 }, (_, i) => ({
158
- status: "unhealthy",
159
- timestamp: new Date(2025, 0, 1, 0, 0, i),
160
- }));
161
- // Mock: the runs query mirrors the predicate back to prodOnly.
162
- const assocWhere = mock(() => Promise.resolve([
163
- {
164
- configurationId: "config-1",
165
- configName: "HTTP probe",
166
- enabled: true,
167
- paused: false,
168
- stateThresholds: null,
169
- },
170
- ]));
249
+ // Pass an explicit environmentId; the per-env branch (string envId) still
250
+ // filters to that env's slice via a single windowed query and reads unhealthy.
251
+ const prodOnly = runs("unhealthy", 5);
252
+ const assocWhere = mock(() =>
253
+ Promise.resolve([
254
+ {
255
+ configurationId: "config-1",
256
+ configName: "HTTP probe",
257
+ enabled: true,
258
+ paused: false,
259
+ stateThresholds: null,
260
+ environmentIds: null,
261
+ },
262
+ ]),
263
+ );
171
264
  const assocInnerJoin = Object.assign(Promise.resolve([]), { where: assocWhere });
172
- const assocFrom = Object.assign(Promise.resolve([]), { innerJoin: mock(() => assocInnerJoin) });
265
+ const assocFrom = Object.assign(Promise.resolve([]), {
266
+ innerJoin: mock(() => assocInnerJoin),
267
+ });
173
268
 
174
269
  const runsLimit = mock(() => Promise.resolve(prodOnly));
175
270
  const runsOrderBy = mock(() => ({ limit: runsLimit }));
@@ -180,7 +275,7 @@ describe("HealthCheckService - system rollup worst-wins across environments", ()
180
275
  });
181
276
 
182
277
  let selectCallCount = 0;
183
- const mockDb = {
278
+ const mockDb = withTransactionMock({
184
279
  select: mock(() => {
185
280
  selectCallCount += 1;
186
281
  if (selectCallCount === 1) return { from: mock(() => assocFrom) };
@@ -196,10 +291,12 @@ describe("HealthCheckService - system rollup worst-wins across environments", ()
196
291
  update: mock(() => ({ set: mock(() => ({ where: mock(() => Promise.resolve()) })) })),
197
292
  delete: mock(() => ({ where: mock(() => Promise.resolve()) })),
198
293
  execute: mock(() => Promise.resolve()),
199
- };
294
+ });
200
295
 
201
296
  const service = new HealthCheckService(mockDb as never, {} as never, {} as never);
202
297
  const result = await service.getSystemHealthStatus("system-1", "prod");
203
298
  expect(result.status).toBe("unhealthy");
299
+ expect(result.checkStatuses[0].sliceCount).toBe(1);
300
+ expect(result.checkStatuses[0].failingSliceCount).toBe(1);
204
301
  });
205
- });
302
+ });