@checkstack/healthcheck-backend 1.18.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.
@@ -4,96 +4,143 @@ import { HealthCheckService } from "./service";
4
4
  import { evaluateHealthStatus } from "./state-evaluator";
5
5
 
6
6
  /**
7
- * Regression coverage for the system-rollup worst-wins-across-environments
8
- * fix in `getSystemHealthStatus(systemId)` (the `environmentId === undefined`
9
- * branch).
7
+ * Regression coverage for the system-rollup derivation in
8
+ * `getSystemHealthStatus(systemId)` (the `environmentId === undefined` branch):
10
9
  *
11
- * The original branch flattened every environment's runs into one
12
- * `timestamp DESC` list and handed the interleaved list to the threshold
13
- * evaluator (the default `consecutive` mode). Consecutive mode walks
14
- * newest-first and breaks the streak on the first interleaving env, so the
15
- * rollup collapsed to whichever env ran last in the batch — masking any
16
- * permanently-failing sibling env ("the healthy env wins" / latest-wins)
17
- * 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.
18
18
  *
19
- * The fix evaluates the threshold window PER ENVIRONMENT within the
20
- * association and takes worst-wins across envs (unhealthy > degraded >
21
- * healthy), making the rollup stable regardless of insertion order or
22
- * multi-pod racing. These tests pin that behavior with a mocked DB that
23
- * 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.
24
26
  */
25
- describe("HealthCheckService - system rollup worst-wins across environments", () => {
26
- /**
27
- * The mixed-pool query captured by the mock. Ordered DESC (newest first),
28
- * exactly the shape the real `health_check_runs` query returns. Two envs
29
- * (`prod`, `staging`) of one assignment, both fanning out every tick, prod
30
- * permanently unhealthy and staging permanently healthy. The env insertion
31
- * order in the executor is sequential membership order, so prod lands before
32
- * staging, making the latest run in the pool a staging-healthy run — the
33
- * exact scenario that masked prod's outage under flattening.
34
- */
35
- const PROD_RUN = { status: "unhealthy" as const, environmentId: "prod" };
36
- const STAGE_RUN = { status: "healthy" as const, environmentId: "staging" };
37
-
38
- function buildMixedPool(ticksPerEnv = 5): { status: "unhealthy" | "healthy"; timestamp: Date; environmentId: string }[] {
39
- const pool: { status: "unhealthy" | "healthy"; timestamp: Date; environmentId: string }[] = [];
40
- for (let i = 0; i < ticksPerEnv; i++) {
41
- pool.push({ ...PROD_RUN, timestamp: new Date(2025, 0, 1, 0, 0, i) });
42
- 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);
43
38
  }
44
- return pool; // DESC at the DB layer; we return newest-first below.
45
- }
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 };
46
48
 
47
- function createMockDb(runsMixedDesc: { status: string; timestamp: Date; environmentId: string }[]) {
48
- 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([
49
67
  {
50
68
  configurationId: "config-1",
51
69
  configName: "HTTP probe",
52
70
  enabled: true,
53
71
  paused: false,
54
72
  stateThresholds: null,
73
+ environmentIds,
55
74
  },
56
- ]));
57
- const assocInnerJoin = Object.assign(Promise.resolve([]), { where: assocWhere });
58
- 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
+ });
59
83
 
60
- const runsLimit = mock(() => Promise.resolve(runsMixedDesc));
61
- const runsOrderBy = mock(() => ({ limit: runsLimit }));
62
- const runsWhere = mock(() => ({ orderBy: runsOrderBy, limit: runsLimit }));
63
- const runsFrom = Object.assign(Promise.resolve(runsMixedDesc), {
64
- where: runsWhere,
65
- 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 };
66
95
  });
96
+ return Object.assign(Promise.resolve([]), { where: runsWhere });
97
+ };
67
98
 
68
- let selectCallCount = 0;
69
- return withTransactionMock({
70
- select: mock(() => {
71
- selectCallCount += 1;
72
- if (selectCallCount === 1) return { from: mock(() => assocFrom) };
73
- return { from: mock(() => runsFrom) };
74
- }),
75
- insert: mock(() => ({
76
- values: mock(() => ({
77
- onConflictDoUpdate: mock(() => Promise.resolve()),
78
- onConflictDoNothing: mock(() => Promise.resolve()),
79
- returning: mock(() => Promise.resolve([])),
80
- })),
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([])),
81
120
  })),
82
- update: mock(() => ({ set: mock(() => ({ where: mock(() => Promise.resolve()) })) })),
83
- delete: mock(() => ({ where: mock(() => Promise.resolve()) })),
84
- execute: mock(() => Promise.resolve()),
85
- });
86
- }
87
-
88
- it("the rollup reports unhealthy when ONE env is permanently unhealthy, the other healthy", async () => {
89
- // DB returns newest-first interleaved runs. The pre-fix behavior would
90
- // mask prod's outage because the latest run is staging-healthy; the
91
- // threshold evaluator (default consecutive mode) walks newest-first from
92
- // staging-healthy, breaks the streak on the very next prod-unhealthy run,
93
- // and falls back to `"healthy"`.
94
- const pool = buildMixedPool(5);
95
- const runsDesc = pool.toReversed(); // oldest produced first above; reverse to DESC
96
- 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 });
97
144
  const service = new HealthCheckService(mockDb as never, {} as never, {} as never);
98
145
 
99
146
  const result = await service.getSystemHealthStatus("system-1");
@@ -101,57 +148,46 @@ describe("HealthCheckService - system rollup worst-wins across environments", ()
101
148
  expect(result.status).toBe("unhealthy");
102
149
  expect(result.checkStatuses).toHaveLength(1);
103
150
  expect(result.checkStatuses[0].status).toBe("unhealthy");
104
- expect(result.checkStatuses[0].runsConsidered).toBe(pool.length);
151
+ expect(result.checkStatuses[0].runsConsidered).toBe(10);
105
152
  // Fan-out accounting: two environment slices (prod + staging), one failing.
106
153
  expect(result.checkStatuses[0].sliceCount).toBe(2);
107
154
  expect(result.checkStatuses[0].failingSliceCount).toBe(1);
108
155
  });
109
156
 
110
- it("counts every failing environment slice for the fan-out denominator (3 envs, 2 failing)", () => {
111
- // Three envs of one check: prod + eu unhealthy, staging healthy. The rollup
112
- // is unhealthy, and the fan-out accounting must report sliceCount 3 with
113
- // failingSliceCount 2 so the dashboard can render "2 of 3 checks failing".
114
- const pool: {
115
- status: "healthy" | "unhealthy";
116
- timestamp: Date;
117
- environmentId: string;
118
- }[] = [];
119
- for (let i = 0; i < 5; i++) {
120
- pool.push({ status: "unhealthy", timestamp: new Date(2025, 0, 1, 0, 0, i), environmentId: "prod" });
121
- pool.push({ status: "unhealthy", timestamp: new Date(2025, 0, 1, 0, 0, i, 250), environmentId: "eu" });
122
- pool.push({ status: "healthy", timestamp: new Date(2025, 0, 1, 0, 0, i, 500), environmentId: "staging" });
123
- }
124
- const runsDesc = pool.toReversed();
125
- const mockDb = createMockDb(runsDesc as never);
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 });
126
164
  const service = new HealthCheckService(mockDb as never, {} as never, {} as never);
127
165
 
128
- return service.getSystemHealthStatus("system-1").then((result) => {
129
- expect(result.status).toBe("unhealthy");
130
- expect(result.checkStatuses[0].sliceCount).toBe(3);
131
- expect(result.checkStatuses[0].failingSliceCount).toBe(2);
132
- });
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);
133
170
  });
134
171
 
135
- it("flattening the same mixed pool through the evaluator (the pre-fix derivation) would have returned `healthy`", async () => {
172
+ it("flattening the same mixed pool through the evaluator (the pre-fix derivation) would have returned `healthy`", () => {
136
173
  // Sanity check: the very data the rollup branch reads, fed directly to
137
174
  // `evaluateHealthStatus` as one flat interleaved list, collapses to
138
- // "healthy" — the precise regression this fix replaces with per-env
139
- // evaluation. Pinning it here guards against a relax of the test above.
140
- const pool = buildMixedPool(5);
141
- const runsDesc = pool.toReversed();
142
- const flatStatus = evaluateHealthStatus({
143
- runs: runsDesc as never,
144
- });
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 });
145
182
  expect(flatStatus).toBe("healthy");
146
183
  });
147
184
 
148
185
  it("reports healthy only when EVERY env is healthy", async () => {
149
- const allHealthy = Array.from({ length: 10 }, (_, i) => ({
150
- status: "healthy",
151
- timestamp: new Date(2025, 0, 1, 0, 0, i),
152
- environmentId: i % 2 === 0 ? "prod" : "staging",
153
- })).toReversed();
154
- 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 });
155
191
  const service = new HealthCheckService(mockDb as never, {} as never, {} as never);
156
192
 
157
193
  const result = await service.getSystemHealthStatus("system-1");
@@ -159,46 +195,76 @@ describe("HealthCheckService - system rollup worst-wins across environments", ()
159
195
  });
160
196
 
161
197
  it("degrades (not flaps) when one env is degraded and the other healthy", async () => {
162
- // Default consecutive thresholds need 2 consecutive failures to escalate
163
- // to `degraded` (and 5 to escalate to `unhealthy` — so keep prod's streak
164
- // at exactly 2 degraded runs). Per-env: prod's env-sorted slice =
165
- // [degraded, degraded] (newest first) → degraded; staging → healthy.
166
- // Rollup worst-wins = degraded. Flattening would break on the staging
167
- // interleave and return `healthy` (the masked bug); per-env gives a
168
- // stable `degraded`.
169
- const pool: { status: "healthy" | "degraded"; timestamp: Date; environmentId: string }[] = [];
170
- for (let i = 0; i < 2; i++) {
171
- pool.push({ status: "degraded", timestamp: new Date(2025, 0, 1, 0, 0, i), environmentId: "prod" });
172
- pool.push({ status: "healthy", timestamp: new Date(2025, 0, 1, 0, 0, i + 0.5), environmentId: "staging" });
173
- }
174
- const runsDesc = pool.toReversed();
175
- 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 });
176
203
  const service = new HealthCheckService(mockDb as never, {} as never, {} as never);
177
204
 
178
205
  const result = await service.getSystemHealthStatus("system-1");
179
206
  expect(result.status).toBe("degraded");
180
207
  });
181
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
+
182
248
  it("the per-env slice (concrete environmentId) is unaffected — only the rollup branch changed", async () => {
183
- // Pass an explicit environmentId; the old per-env branch (string envId)
184
- // must continue to filter to that env's slice. Here we ask for `prod`
185
- // and expect unhealthy.
186
- const prodOnly = Array.from({ length: 5 }, (_, i) => ({
187
- status: "unhealthy",
188
- timestamp: new Date(2025, 0, 1, 0, 0, i),
189
- }));
190
- // Mock: the runs query mirrors the predicate back to prodOnly.
191
- const assocWhere = mock(() => Promise.resolve([
192
- {
193
- configurationId: "config-1",
194
- configName: "HTTP probe",
195
- enabled: true,
196
- paused: false,
197
- stateThresholds: null,
198
- },
199
- ]));
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
+ );
200
264
  const assocInnerJoin = Object.assign(Promise.resolve([]), { where: assocWhere });
201
- const assocFrom = Object.assign(Promise.resolve([]), { innerJoin: mock(() => assocInnerJoin) });
265
+ const assocFrom = Object.assign(Promise.resolve([]), {
266
+ innerJoin: mock(() => assocInnerJoin),
267
+ });
202
268
 
203
269
  const runsLimit = mock(() => Promise.resolve(prodOnly));
204
270
  const runsOrderBy = mock(() => ({ limit: runsLimit }));
@@ -230,9 +296,7 @@ describe("HealthCheckService - system rollup worst-wins across environments", ()
230
296
  const service = new HealthCheckService(mockDb as never, {} as never, {} as never);
231
297
  const result = await service.getSystemHealthStatus("system-1", "prod");
232
298
  expect(result.status).toBe("unhealthy");
233
- // A single-env evaluation is always one slice; failing here since prod is
234
- // unhealthy.
235
299
  expect(result.checkStatuses[0].sliceCount).toBe(1);
236
300
  expect(result.checkStatuses[0].failingSliceCount).toBe(1);
237
301
  });
238
- });
302
+ });