@checkstack/healthcheck-backend 1.21.3 → 1.23.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.
@@ -13,8 +13,9 @@ import { HealthCheckService } from "./service";
13
13
  * Satellite ingest evaluates assertions ON THE CORE (satellites never held
14
14
  * the assertion semantics — before this, satellite-executed checks silently
15
15
  * skipped assertions), then strips ephemeral fields for parity with local
16
- * runs. These tests drive `ingestSatelliteResult` against a mock db and
17
- * assert on the run row it persists.
16
+ * runs. `processSatelliteResult` returns the processed status + result record
17
+ * (and the check name); the shared post-run path persists it. These tests
18
+ * assert on that returned payload.
18
19
  */
19
20
 
20
21
  const KEY = computeAssertionKey({
@@ -30,43 +31,17 @@ const collectorResultSchema = z.object({
30
31
  body: healthResultString({ "x-ephemeral": true }),
31
32
  });
32
33
 
33
- function buildService({
34
- entries,
35
- inserted,
36
- }: {
37
- entries: CollectorConfigEntry[];
38
- inserted: Record<string, unknown>[];
39
- }) {
40
- const tx = {
41
- insert: mock(() => ({
42
- values: mock((vals: Record<string, unknown>) => {
43
- inserted.push(vals);
44
- return Object.assign(Promise.resolve(), {
45
- onConflictDoUpdate: mock(() => Promise.resolve()),
46
- onConflictDoNothing: mock(() => Promise.resolve()),
47
- });
48
- }),
49
- })),
34
+ function buildService({ entries }: { entries: CollectorConfigEntry[] }) {
35
+ const db = {
50
36
  select: mock(() => ({
51
37
  from: mock(() => ({
52
38
  where: mock(() =>
53
- Object.assign(Promise.resolve([]), {
54
- limit: mock(() => Promise.resolve([])),
55
- }),
39
+ Promise.resolve([{ name: "Test check", collectors: entries }]),
56
40
  ),
57
41
  })),
58
42
  })),
59
43
  };
60
44
 
61
- const db = {
62
- select: mock(() => ({
63
- from: mock(() => ({
64
- where: mock(() => Promise.resolve([{ collectors: entries }])),
65
- })),
66
- })),
67
- transaction: mock(async (fn: (t: typeof tx) => Promise<void>) => fn(tx)),
68
- };
69
-
70
45
  const collectorRegistry = {
71
46
  register: mock(() => {}),
72
47
  getCollector: mock(() => ({
@@ -113,64 +88,59 @@ const entries: CollectorConfigEntry[] = [
113
88
  },
114
89
  ];
115
90
 
116
- async function ingest({
91
+ async function process({
117
92
  entries,
118
93
  statusCode,
119
94
  }: {
120
95
  entries: CollectorConfigEntry[];
121
96
  statusCode: number;
122
97
  }) {
123
- const inserted: Record<string, unknown>[] = [];
124
- const service = buildService({ entries, inserted });
125
- await service.ingestSatelliteResult({
98
+ const service = buildService({ entries });
99
+ return service.processSatelliteResult({
126
100
  configId: "config-1",
127
- systemId: "system-1",
128
101
  status: "healthy",
129
- latencyMs: 42,
130
102
  result: satelliteResult({ statusCode }) as never,
131
- executedAt: "2026-07-03T10:00:00.000Z",
132
- sourceId: "sat-1",
133
- sourceLabel: "EU West",
134
103
  });
135
- const runInsert = inserted.find((v) => "status" in v && "result" in v);
136
- expect(runInsert).toBeDefined();
137
- return runInsert as Record<string, unknown>;
138
104
  }
139
105
 
140
- function collectorEntryOf(runInsert: Record<string, unknown>) {
141
- const result = runInsert.result as {
106
+ function collectorEntryOf(resultRecord: Record<string, unknown>) {
107
+ const result = resultRecord as {
142
108
  metadata: { collectors: Record<string, Record<string, unknown>> };
143
109
  };
144
110
  return result.metadata.collectors["entry-1"];
145
111
  }
146
112
 
147
- describe("ingestSatelliteResult - assertion evaluation at ingest", () => {
113
+ describe("processSatelliteResult - assertion evaluation at ingest", () => {
148
114
  it("downgrades a satellite-healthy run whose assertion fails", async () => {
149
- const runInsert = await ingest({ entries, statusCode: 404 });
150
- expect(runInsert.status).toBe("unhealthy");
115
+ const { status, resultRecord } = await process({ entries, statusCode: 404 });
116
+ expect(status).toBe("unhealthy");
151
117
 
152
- const entry = collectorEntryOf(runInsert);
118
+ const entry = collectorEntryOf(resultRecord);
153
119
  expect(entry._assertionFailed).toBe("statusCode equals 200");
154
120
  expect(entry._assertions).toEqual([
155
121
  expect.objectContaining({ key: KEY, passed: false, actual: "404" }),
156
122
  ]);
157
- const message = (runInsert.result as { message: string }).message;
158
- expect(message).toBe(
123
+ expect((resultRecord as { message: string }).message).toBe(
159
124
  "Check failed: Assertion failed: statusCode equals 200",
160
125
  );
161
126
  });
162
127
 
163
128
  it("keeps a passing run healthy and stores the passing outcome", async () => {
164
- const runInsert = await ingest({ entries, statusCode: 200 });
165
- expect(runInsert.status).toBe("healthy");
129
+ const { status, resultRecord } = await process({ entries, statusCode: 200 });
130
+ expect(status).toBe("healthy");
166
131
 
167
- const entry = collectorEntryOf(runInsert);
132
+ const entry = collectorEntryOf(resultRecord);
168
133
  expect(entry._assertionFailed).toBeUndefined();
169
134
  expect(entry._assertions).toEqual([
170
135
  expect.objectContaining({ key: KEY, passed: true, actual: "200" }),
171
136
  ]);
172
137
  });
173
138
 
139
+ it("resolves the check name for the notification", async () => {
140
+ const { configName } = await process({ entries, statusCode: 200 });
141
+ expect(configName).toBe("Test check");
142
+ });
143
+
174
144
  it("strips ephemeral fields AFTER assertions ran against them", async () => {
175
145
  const withBodyAssertion: CollectorConfigEntry[] = [
176
146
  {
@@ -187,12 +157,12 @@ describe("ingestSatelliteResult - assertion evaluation at ingest", () => {
187
157
  ],
188
158
  },
189
159
  ];
190
- const runInsert = await ingest({
160
+ const { status, resultRecord } = await process({
191
161
  entries: withBodyAssertion,
192
162
  statusCode: 200,
193
163
  });
194
164
 
195
- const entry = collectorEntryOf(runInsert);
165
+ const entry = collectorEntryOf(resultRecord);
196
166
  // The JSONPath assertion evaluated against the (ephemeral) body...
197
167
  expect(entry._assertions).toEqual([
198
168
  expect.objectContaining({ passed: true, actual: "ok" }),
@@ -200,14 +170,17 @@ describe("ingestSatelliteResult - assertion evaluation at ingest", () => {
200
170
  // ...but the body itself never reaches storage.
201
171
  expect(entry.body).toBeUndefined();
202
172
  expect(entry.statusCode).toBe(200);
203
- expect(runInsert.status).toBe("healthy");
173
+ expect(status).toBe("healthy");
204
174
  });
205
175
 
206
176
  it("tolerates collector entries the config no longer knows", async () => {
207
- const runInsert = await ingest({ entries: [], statusCode: 500 });
177
+ const { status, resultRecord } = await process({
178
+ entries: [],
179
+ statusCode: 500,
180
+ });
208
181
  // No assertions configured: status passes through untouched.
209
- expect(runInsert.status).toBe("healthy");
210
- const entry = collectorEntryOf(runInsert);
182
+ expect(status).toBe("healthy");
183
+ const entry = collectorEntryOf(resultRecord);
211
184
  expect(entry._assertions).toBeUndefined();
212
185
  });
213
186
  });
@@ -159,9 +159,12 @@ describe("HealthCheckService - paused configuration filtering", () => {
159
159
  const result = await service.getSystemHealthStatus("system-1");
160
160
 
161
161
  // The post-filter associations list is empty, so the system has no
162
- // active checks and reads healthy — paused failures do NOT keep the
163
- // system degraded.
164
- expect(result.status).toBe("healthy");
162
+ // ACTIVE checks. Paused failures still do NOT keep the system degraded -
163
+ // that is the behaviour this test guards - but the result is now
164
+ // `unknown` rather than `healthy`: with its only check paused, nothing is
165
+ // measuring this system, and claiming health it has no evidence for is
166
+ // what made a broken check read green on the catalog and status page.
167
+ expect(result.status).toBe("unknown");
165
168
  expect(result.checkStatuses).toHaveLength(0);
166
169
  });
167
170
 
@@ -260,7 +263,9 @@ describe("HealthCheckService - paused configuration filtering", () => {
260
263
 
261
264
  const result = await service.getSystemHealthStatus("system-1");
262
265
 
263
- expect(result.status).toBe("healthy");
266
+ // No enabled associations = nothing measured, so `unknown` rather than an
267
+ // invented `healthy`.
268
+ expect(result.status).toBe("unknown");
264
269
  expect(result.checkStatuses).toHaveLength(0);
265
270
  });
266
271
  });
@@ -48,18 +48,46 @@ type Run = { status: "healthy" | "degraded" | "unhealthy"; timestamp: Date };
48
48
 
49
49
  /**
50
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).
51
+ * (`null` = env-less) to that env's runs (DESC), all attributed to the LOCAL
52
+ * core; `satelliteRuns` adds slices probed from a satellite. `environmentIds` is
53
+ * the assignment's selector under test.
54
+ *
55
+ * The per-slice runs query resolves against the env id AND source id bound in
56
+ * its predicate. Known env ids and satellite ids are disjoint in these fixtures,
57
+ * so each is recovered by set membership; an absent value means the `isNull`
58
+ * clause, i.e. the env-less / local slice.
55
59
  */
56
60
  function createRollupMockDb(props: {
57
61
  runsByEnv: Map<string | null, Run[]>;
58
62
  environmentIds: string[] | null;
63
+ satelliteRuns?: { sourceId: string; environmentId: string | null; runs: Run[] }[];
64
+ satelliteIds?: string[] | null;
65
+ satelliteEnvironmentIds?: Record<string, string[] | null> | null;
66
+ includeLocal?: boolean;
59
67
  }) {
60
- const { runsByEnv, environmentIds } = props;
68
+ const {
69
+ runsByEnv,
70
+ environmentIds,
71
+ satelliteRuns = [],
72
+ satelliteIds = null,
73
+ satelliteEnvironmentIds = null,
74
+ includeLocal = true,
75
+ } = props;
76
+ const slices = [
77
+ ...[...runsByEnv].map(([environmentId, runs]) => ({
78
+ environmentId,
79
+ sourceId: null as string | null,
80
+ runs,
81
+ })),
82
+ ...satelliteRuns,
83
+ ];
61
84
  const knownEnvIds = new Set(
62
- [...runsByEnv.keys()].filter((k): k is string => k !== null),
85
+ slices
86
+ .map((s) => s.environmentId)
87
+ .filter((k): k is string => k !== null),
88
+ );
89
+ const knownSourceIds = new Set(
90
+ slices.map((s) => s.sourceId).filter((k): k is string => k !== null),
63
91
  );
64
92
 
65
93
  const assocWhere = mock(() =>
@@ -71,6 +99,9 @@ function createRollupMockDb(props: {
71
99
  paused: false,
72
100
  stateThresholds: null,
73
101
  environmentIds,
102
+ satelliteIds,
103
+ satelliteEnvironmentIds,
104
+ includeLocal,
74
105
  },
75
106
  ]),
76
107
  );
@@ -81,25 +112,35 @@ function createRollupMockDb(props: {
81
112
  innerJoin: mock(() => assocInnerJoin),
82
113
  });
83
114
 
84
- // Per-env runs query: pick the slice named by the predicate's env value.
85
- const resolvePerEnv = (predicate: unknown): Run[] => {
115
+ // Per-slice runs query: pick the slice named by the predicate's env + source.
116
+ const resolveSlice = (predicate: unknown): Run[] => {
86
117
  const values = collectPredicateValues(predicate);
87
- const envId = values.find((v) => knownEnvIds.has(v)) ?? null;
88
- return runsByEnv.get(envId) ?? [];
118
+ const environmentId = values.find((v) => knownEnvIds.has(v)) ?? null;
119
+ const sourceId = values.find((v) => knownSourceIds.has(v)) ?? null;
120
+ return (
121
+ slices.find(
122
+ (s) => s.environmentId === environmentId && s.sourceId === sourceId,
123
+ )?.runs ?? []
124
+ );
89
125
  };
90
126
  const runsFromFor = () => {
91
127
  const runsWhere = mock((predicate: unknown) => {
92
- const rows = resolvePerEnv(predicate);
128
+ const rows = resolveSlice(predicate);
93
129
  const limit = mock(() => Promise.resolve(rows));
94
130
  return { orderBy: mock(() => ({ limit })), limit };
95
131
  });
96
132
  return Object.assign(Promise.resolve([]), { where: runsWhere });
97
133
  };
98
134
 
99
- // Distinct env keys query: select({environmentId}).from().where().
135
+ // Distinct slice keys query: selectDistinct({environmentId, sourceId}).
100
136
  const distinctFrom = Object.assign(Promise.resolve([]), {
101
137
  where: mock(() =>
102
- Promise.resolve([...runsByEnv.keys()].map((k) => ({ environmentId: k }))),
138
+ Promise.resolve(
139
+ slices.map(({ environmentId, sourceId }) => ({
140
+ environmentId,
141
+ sourceId,
142
+ })),
143
+ ),
103
144
  ),
104
145
  });
105
146
 
@@ -245,58 +286,109 @@ describe("HealthCheckService - system rollup worst-wins across environments", ()
245
286
  expect(result.checkStatuses[0].runsConsidered).toBe(3);
246
287
  });
247
288
 
248
- it("the per-env slice (concrete environmentId) is unaffected — only the rollup branch changed", async () => {
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
- );
264
- const assocInnerJoin = Object.assign(Promise.resolve([]), { where: assocWhere });
265
- const assocFrom = Object.assign(Promise.resolve([]), {
266
- innerJoin: mock(() => assocInnerJoin),
289
+ it("reports unhealthy when the LOCAL check passes but a SATELLITE check fails", async () => {
290
+ // The reported bug (@stuajnht): a system read HEALTHY while one of its
291
+ // probe locations failed every single time. Both sources' runs landed in
292
+ // one slice, so `evaluateConsecutive` saw healthy/unhealthy alternating,
293
+ // broke its streak on every run, met no threshold, and fell through to its
294
+ // healthy default. Sliced per source, the satellite is evaluated on its own
295
+ // and worst-wins carries it to the system.
296
+ const runsByEnv = new Map<string | null, Run[]>([[null, runs("healthy", 5)]]);
297
+ const mockDb = createRollupMockDb({
298
+ runsByEnv,
299
+ environmentIds: null,
300
+ satelliteIds: ["sat-eu"],
301
+ satelliteRuns: [
302
+ { sourceId: "sat-eu", environmentId: null, runs: runs("unhealthy", 5) },
303
+ ],
267
304
  });
305
+ const service = new HealthCheckService(mockDb as never, {} as never, {} as never);
306
+
307
+ const result = await service.getSystemHealthStatus("system-1");
268
308
 
269
- const runsLimit = mock(() => Promise.resolve(prodOnly));
270
- const runsOrderBy = mock(() => ({ limit: runsLimit }));
271
- const runsWhere = mock(() => ({ orderBy: runsOrderBy, limit: runsLimit }));
272
- const runsFrom = Object.assign(Promise.resolve(prodOnly), {
273
- where: runsWhere,
274
- orderBy: runsOrderBy,
309
+ expect(result.status).toBe("unhealthy");
310
+ expect(result.checkStatuses[0].status).toBe("unhealthy");
311
+ // Two locations probing one environment = two slices, one failing.
312
+ expect(result.checkStatuses[0].sliceCount).toBe(2);
313
+ expect(result.checkStatuses[0].failingSliceCount).toBe(1);
314
+ });
315
+
316
+ it("names the failing location in the per-slice breakdown", async () => {
317
+ const runsByEnv = new Map<string | null, Run[]>([["prod", runs("healthy", 5)]]);
318
+ const mockDb = createRollupMockDb({
319
+ runsByEnv,
320
+ environmentIds: ["prod"],
321
+ satelliteIds: ["sat-eu"],
322
+ satelliteRuns: [
323
+ { sourceId: "sat-eu", environmentId: "prod", runs: runs("unhealthy", 5) },
324
+ ],
275
325
  });
326
+ const service = new HealthCheckService(mockDb as never, {} as never, {} as never);
276
327
 
277
- let selectCallCount = 0;
278
- const mockDb = withTransactionMock({
279
- select: mock(() => {
280
- selectCallCount += 1;
281
- if (selectCallCount === 1) return { from: mock(() => assocFrom) };
282
- return { from: mock(() => runsFrom) };
283
- }),
284
- insert: mock(() => ({
285
- values: mock(() => ({
286
- onConflictDoUpdate: mock(() => Promise.resolve()),
287
- onConflictDoNothing: mock(() => Promise.resolve()),
288
- returning: mock(() => Promise.resolve([])),
289
- })),
290
- })),
291
- update: mock(() => ({ set: mock(() => ({ where: mock(() => Promise.resolve()) })) })),
292
- delete: mock(() => ({ where: mock(() => Promise.resolve()) })),
293
- execute: mock(() => Promise.resolve()),
328
+ const { slices } = (await service.getSystemHealthStatus("system-1")).checkStatuses[0];
329
+
330
+ expect(slices).toHaveLength(2);
331
+ expect(slices.find((s) => s.sourceId === null)?.status).toBe("healthy");
332
+ expect(slices.find((s) => s.sourceId === "sat-eu")?.status).toBe("unhealthy");
333
+ });
334
+
335
+ it("drops a de-assigned satellite's stale failing runs", async () => {
336
+ // No health-change event fires for a slice that merely stopped producing
337
+ // runs, so without the effective-source filter an unassigned satellite's
338
+ // last failures would drag the rollup until they aged out of the window.
339
+ const runsByEnv = new Map<string | null, Run[]>([[null, runs("healthy", 5)]]);
340
+ const mockDb = createRollupMockDb({
341
+ runsByEnv,
342
+ environmentIds: null,
343
+ satelliteIds: [],
344
+ satelliteRuns: [
345
+ { sourceId: "sat-gone", environmentId: null, runs: runs("unhealthy", 5) },
346
+ ],
294
347
  });
348
+ const service = new HealthCheckService(mockDb as never, {} as never, {} as never);
349
+
350
+ const result = await service.getSystemHealthStatus("system-1");
351
+
352
+ expect(result.status).toBe("healthy");
353
+ expect(result.checkStatuses[0].sliceCount).toBe(1);
354
+ });
355
+
356
+ it("keeps evaluating satellites when the core stopped running the check", async () => {
357
+ // `includeLocal: false` means the core's old runs no longer contribute, so
358
+ // the satellite alone decides.
359
+ const runsByEnv = new Map<string | null, Run[]>([[null, runs("healthy", 5)]]);
360
+ const mockDb = createRollupMockDb({
361
+ runsByEnv,
362
+ environmentIds: null,
363
+ includeLocal: false,
364
+ satelliteIds: ["sat-eu"],
365
+ satelliteRuns: [
366
+ { sourceId: "sat-eu", environmentId: null, runs: runs("unhealthy", 5) },
367
+ ],
368
+ });
369
+ const service = new HealthCheckService(mockDb as never, {} as never, {} as never);
370
+
371
+ const result = await service.getSystemHealthStatus("system-1");
295
372
 
373
+ expect(result.status).toBe("unhealthy");
374
+ expect(result.checkStatuses[0].sliceCount).toBe(1);
375
+ expect(result.checkStatuses[0].failingSliceCount).toBe(1);
376
+ });
377
+
378
+ it("a pinned environment evaluates only that environment's slices", async () => {
379
+ // Passing an explicit environmentId narrows to that env - the other env's
380
+ // runs must not contribute - while the source dimension is still sliced.
381
+ const runsByEnv = new Map<string | null, Run[]>([
382
+ ["prod", runs("unhealthy", 5)],
383
+ ["staging", runs("healthy", 5)],
384
+ ]);
385
+ const mockDb = createRollupMockDb({ runsByEnv, environmentIds: null });
296
386
  const service = new HealthCheckService(mockDb as never, {} as never, {} as never);
387
+
297
388
  const result = await service.getSystemHealthStatus("system-1", "prod");
298
389
  expect(result.status).toBe("unhealthy");
299
390
  expect(result.checkStatuses[0].sliceCount).toBe(1);
300
391
  expect(result.checkStatuses[0].failingSliceCount).toBe(1);
392
+ expect(result.checkStatuses[0].runsConsidered).toBe(5);
301
393
  });
302
394
  });