@checkstack/healthcheck-backend 1.15.0 → 1.17.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 +211 -0
- package/package.json +29 -29
- package/src/aggregation-utils.test.ts +132 -0
- package/src/aggregation-utils.ts +70 -6
- package/src/collector-assertions.test.ts +97 -1
- package/src/collector-assertions.ts +141 -34
- package/src/index.ts +29 -0
- package/src/queue-executor.test.ts +182 -0
- package/src/queue-executor.ts +12 -3
- package/src/realtime-aggregation.test.ts +136 -0
- package/src/realtime-aggregation.ts +39 -2
- package/src/retention-job.ts +64 -1
- package/src/retention-rollup.test.ts +69 -0
- package/src/router.ts +92 -5
- package/src/service-ingest-assertions.test.ts +213 -0
- package/src/service.ts +179 -9
- package/src/status-page/widgets.ts +11 -1
- package/src/system-health-override.test.ts +94 -0
- package/src/system-health-override.ts +93 -0
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import type { SafeDatabase, CollectorRegistry } from "@checkstack/backend-api";
|
|
2
|
+
import {
|
|
3
|
+
ASSERTIONS_AGG_KEY,
|
|
4
|
+
AssertionOutcomeSchema,
|
|
5
|
+
foldOutcomesIntoStats,
|
|
6
|
+
readAssertionStats,
|
|
7
|
+
type AssertionOutcome,
|
|
8
|
+
} from "@checkstack/healthcheck-common";
|
|
2
9
|
import { TDigest } from "tdigest";
|
|
3
10
|
import * as schema from "./schema";
|
|
4
11
|
import { healthCheckAggregates } from "./schema";
|
|
@@ -269,6 +276,31 @@ function mergeCollectorResults(params: {
|
|
|
269
276
|
return existingResult ?? undefined;
|
|
270
277
|
}
|
|
271
278
|
|
|
279
|
+
// Fold this run's structured assertion outcomes into the bucket's
|
|
280
|
+
// per-assertion pass/fail counts (a PLATFORM-owned top-level key, sibling
|
|
281
|
+
// of `collectors`, so collector mergers never see it). Folded for every
|
|
282
|
+
// entry that carries outcomes, even collectors without a mergeResult.
|
|
283
|
+
let assertionStats = readAssertionStats({
|
|
284
|
+
aggregatedResult: existingResult ?? undefined,
|
|
285
|
+
});
|
|
286
|
+
if (runCollectors) {
|
|
287
|
+
for (const [uuid, collectorData] of Object.entries(runCollectors)) {
|
|
288
|
+
const rawOutcomes = collectorData._assertions;
|
|
289
|
+
if (!Array.isArray(rawOutcomes) || rawOutcomes.length === 0) continue;
|
|
290
|
+
const outcomes: AssertionOutcome[] = [];
|
|
291
|
+
for (const raw of rawOutcomes) {
|
|
292
|
+
const parsed = AssertionOutcomeSchema.safeParse(raw);
|
|
293
|
+
if (parsed.success) outcomes.push(parsed.data);
|
|
294
|
+
}
|
|
295
|
+
if (outcomes.length === 0) continue;
|
|
296
|
+
assertionStats = foldOutcomesIntoStats({
|
|
297
|
+
stats: assertionStats,
|
|
298
|
+
collectorEntryId: uuid,
|
|
299
|
+
outcomes,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
272
304
|
// Start with existing collectors or empty object
|
|
273
305
|
const existingCollectors = (existingResult?.collectors ?? {}) as Record<
|
|
274
306
|
string,
|
|
@@ -291,7 +323,7 @@ function mergeCollectorResults(params: {
|
|
|
291
323
|
const existingAggregate = existingCollectors[uuid];
|
|
292
324
|
|
|
293
325
|
// Strip internal fields from collector data for the run
|
|
294
|
-
const { _collectorId, _assertionFailed, ...collectorMetadata } =
|
|
326
|
+
const { _collectorId, _assertionFailed, _assertions, ...collectorMetadata } =
|
|
295
327
|
collectorData;
|
|
296
328
|
|
|
297
329
|
// Call the collector's mergeResult
|
|
@@ -313,5 +345,10 @@ function mergeCollectorResults(params: {
|
|
|
313
345
|
}
|
|
314
346
|
}
|
|
315
347
|
|
|
316
|
-
return {
|
|
348
|
+
return {
|
|
349
|
+
collectors: mergedCollectors,
|
|
350
|
+
...(assertionStats === undefined
|
|
351
|
+
? {}
|
|
352
|
+
: { [ASSERTIONS_AGG_KEY]: assertionStats }),
|
|
353
|
+
};
|
|
317
354
|
}
|
package/src/retention-job.ts
CHANGED
|
@@ -8,6 +8,12 @@ import {
|
|
|
8
8
|
DEFAULT_RETENTION_CONFIG,
|
|
9
9
|
} from "./schema";
|
|
10
10
|
import { eq, and, lt, sql, desc } from "drizzle-orm";
|
|
11
|
+
import {
|
|
12
|
+
ASSERTIONS_AGG_KEY,
|
|
13
|
+
mergeAssertionStats,
|
|
14
|
+
readAssertionStats,
|
|
15
|
+
type BucketAssertionStats,
|
|
16
|
+
} from "@checkstack/healthcheck-common";
|
|
11
17
|
import type { QueueManager } from "@checkstack/queue-api";
|
|
12
18
|
|
|
13
19
|
type Db = SafeDatabase<typeof schema>;
|
|
@@ -244,6 +250,8 @@ export interface HourlyAggregateRow {
|
|
|
244
250
|
minLatencyMs: number | null;
|
|
245
251
|
maxLatencyMs: number | null;
|
|
246
252
|
p95LatencyMs: number | null;
|
|
253
|
+
/** Carried for the per-assertion pass/fail counts (additive across hours). */
|
|
254
|
+
aggregatedResult?: Record<string, unknown> | null;
|
|
247
255
|
}
|
|
248
256
|
|
|
249
257
|
/** A computed daily aggregate ready to upsert. */
|
|
@@ -261,6 +269,12 @@ export interface DailyAggregateValues {
|
|
|
261
269
|
minLatencyMs: number | undefined;
|
|
262
270
|
maxLatencyMs: number | undefined;
|
|
263
271
|
p95LatencyMs: number | undefined;
|
|
272
|
+
/**
|
|
273
|
+
* Summed per-assertion pass/fail counts across the day's hourly buckets.
|
|
274
|
+
* The ONLY part of `aggregatedResult` that survives the daily rollup —
|
|
275
|
+
* assertion counts are purely additive, unlike strategy/collector states.
|
|
276
|
+
*/
|
|
277
|
+
assertionStats: BucketAssertionStats | undefined;
|
|
264
278
|
}
|
|
265
279
|
|
|
266
280
|
/**
|
|
@@ -296,6 +310,7 @@ export function buildDailyAggregates(
|
|
|
296
310
|
let degradedCount = 0;
|
|
297
311
|
let unhealthyCount = 0;
|
|
298
312
|
let latencySumMs = 0;
|
|
313
|
+
let assertionStats: BucketAssertionStats | undefined;
|
|
299
314
|
|
|
300
315
|
for (const a of rows) {
|
|
301
316
|
runCount += a.runCount;
|
|
@@ -308,6 +323,12 @@ export function buildDailyAggregates(
|
|
|
308
323
|
} else if (a.avgLatencyMs !== null) {
|
|
309
324
|
latencySumMs += a.avgLatencyMs * a.runCount;
|
|
310
325
|
}
|
|
326
|
+
assertionStats = mergeAssertionStats({
|
|
327
|
+
a: assertionStats,
|
|
328
|
+
b: readAssertionStats({
|
|
329
|
+
aggregatedResult: a.aggregatedResult ?? undefined,
|
|
330
|
+
}),
|
|
331
|
+
});
|
|
311
332
|
}
|
|
312
333
|
|
|
313
334
|
const minValues = rows
|
|
@@ -336,6 +357,7 @@ export function buildDailyAggregates(
|
|
|
336
357
|
maxLatencyMs: maxValues.length > 0 ? Math.max(...maxValues) : undefined,
|
|
337
358
|
// Use max of hourly p95s as an upper-bound approximation.
|
|
338
359
|
p95LatencyMs: p95Values.length > 0 ? Math.max(...p95Values) : undefined,
|
|
360
|
+
assertionStats,
|
|
339
361
|
});
|
|
340
362
|
}
|
|
341
363
|
|
|
@@ -370,6 +392,42 @@ async function rollupHourlyAggregates(params: RollupParams) {
|
|
|
370
392
|
// Fold into daily aggregates, preserving (day, environmentId, sourceId) series.
|
|
371
393
|
for (const daily of buildDailyAggregates(oldHourly)) {
|
|
372
394
|
const newLatencySum = daily.latencySumMs;
|
|
395
|
+
|
|
396
|
+
// Assertion pass/fail counts are the ONLY aggregatedResult content that
|
|
397
|
+
// survives the daily rollup (strategy/collector states cannot combine
|
|
398
|
+
// across hours). The conflict path merges in JS by pre-reading the
|
|
399
|
+
// existing daily row — safe because retention runs in a single work-queue
|
|
400
|
+
// consumer group, so there is no concurrent writer for this tuple.
|
|
401
|
+
let dailyAggregatedResult: Record<string, unknown> | undefined;
|
|
402
|
+
if (daily.assertionStats !== undefined) {
|
|
403
|
+
const [existingDaily] = await db
|
|
404
|
+
.select({ aggregatedResult: healthCheckAggregates.aggregatedResult })
|
|
405
|
+
.from(healthCheckAggregates)
|
|
406
|
+
.where(
|
|
407
|
+
and(
|
|
408
|
+
eq(healthCheckAggregates.systemId, systemId),
|
|
409
|
+
eq(healthCheckAggregates.configurationId, configurationId),
|
|
410
|
+
eq(healthCheckAggregates.bucketSize, "daily"),
|
|
411
|
+
eq(healthCheckAggregates.bucketStart, daily.bucketStart),
|
|
412
|
+
daily.environmentId === null
|
|
413
|
+
? sql`${healthCheckAggregates.environmentId} IS NULL`
|
|
414
|
+
: eq(healthCheckAggregates.environmentId, daily.environmentId),
|
|
415
|
+
daily.sourceId === null
|
|
416
|
+
? sql`${healthCheckAggregates.sourceId} IS NULL`
|
|
417
|
+
: eq(healthCheckAggregates.sourceId, daily.sourceId),
|
|
418
|
+
),
|
|
419
|
+
);
|
|
420
|
+
const mergedStats = mergeAssertionStats({
|
|
421
|
+
a: readAssertionStats({
|
|
422
|
+
aggregatedResult: existingDaily?.aggregatedResult ?? undefined,
|
|
423
|
+
}),
|
|
424
|
+
b: daily.assertionStats,
|
|
425
|
+
});
|
|
426
|
+
if (mergedStats !== undefined) {
|
|
427
|
+
dailyAggregatedResult = { [ASSERTIONS_AGG_KEY]: mergedStats };
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
373
431
|
// Upsert the daily aggregate. A row may already exist for this
|
|
374
432
|
// (configurationId, systemId, environmentId, day, daily, sourceId) tuple if
|
|
375
433
|
// a prior rollup ran and then late-arriving hourly buckets (e.g. from a
|
|
@@ -394,7 +452,8 @@ async function rollupHourlyAggregates(params: RollupParams) {
|
|
|
394
452
|
minLatencyMs: daily.minLatencyMs,
|
|
395
453
|
maxLatencyMs: daily.maxLatencyMs,
|
|
396
454
|
p95LatencyMs: daily.p95LatencyMs,
|
|
397
|
-
|
|
455
|
+
// Only the additive assertion counts survive across hours.
|
|
456
|
+
aggregatedResult: dailyAggregatedResult,
|
|
398
457
|
})
|
|
399
458
|
.onConflictDoUpdate({
|
|
400
459
|
target: [...DAILY_AGGREGATE_CONFLICT_TARGET],
|
|
@@ -417,6 +476,10 @@ async function rollupHourlyAggregates(params: RollupParams) {
|
|
|
417
476
|
daily.p95LatencyMs === undefined
|
|
418
477
|
? sql`${healthCheckAggregates.p95LatencyMs}`
|
|
419
478
|
: sql`GREATEST(COALESCE(${healthCheckAggregates.p95LatencyMs}, ${daily.p95LatencyMs}), ${daily.p95LatencyMs})`,
|
|
479
|
+
// JS-merged above (existing row's counts + this rollup's counts).
|
|
480
|
+
...(dailyAggregatedResult === undefined
|
|
481
|
+
? {}
|
|
482
|
+
: { aggregatedResult: dailyAggregatedResult }),
|
|
420
483
|
},
|
|
421
484
|
});
|
|
422
485
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { computeAssertionKey } from "@checkstack/healthcheck-common";
|
|
2
3
|
import { getTableConfig } from "drizzle-orm/pg-core";
|
|
3
4
|
import { healthCheckAggregates } from "./schema";
|
|
4
5
|
import {
|
|
@@ -116,3 +117,71 @@ describe("DAILY_AGGREGATE_CONFLICT_TARGET", () => {
|
|
|
116
117
|
expect(targetCols).toEqual(constraintCols);
|
|
117
118
|
});
|
|
118
119
|
});
|
|
120
|
+
|
|
121
|
+
describe("buildDailyAggregates - assertion stats", () => {
|
|
122
|
+
const KEY = computeAssertionKey({
|
|
123
|
+
assertion: { field: "statusCode", operator: "equals", value: 200 },
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("sums per-assertion counts across the day's hourly buckets", () => {
|
|
127
|
+
const daily = buildDailyAggregates([
|
|
128
|
+
hourly({
|
|
129
|
+
bucketStart: new Date("2026-01-01T03:00:00.000Z"),
|
|
130
|
+
aggregatedResult: {
|
|
131
|
+
collectors: {},
|
|
132
|
+
assertions: { "uuid-1": { [KEY]: { passCount: 50, failCount: 2 } } },
|
|
133
|
+
},
|
|
134
|
+
}),
|
|
135
|
+
hourly({
|
|
136
|
+
bucketStart: new Date("2026-01-01T04:00:00.000Z"),
|
|
137
|
+
aggregatedResult: {
|
|
138
|
+
assertions: { "uuid-1": { [KEY]: { passCount: 60, failCount: 0 } } },
|
|
139
|
+
},
|
|
140
|
+
}),
|
|
141
|
+
// Pre-feature hourly bucket without stats is tolerated.
|
|
142
|
+
hourly({
|
|
143
|
+
bucketStart: new Date("2026-01-01T05:00:00.000Z"),
|
|
144
|
+
aggregatedResult: null,
|
|
145
|
+
}),
|
|
146
|
+
]);
|
|
147
|
+
|
|
148
|
+
expect(daily.length).toBe(1);
|
|
149
|
+
expect(daily[0].assertionStats).toEqual({
|
|
150
|
+
"uuid-1": { [KEY]: { passCount: 110, failCount: 2 } },
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("keeps assertion stats scoped to their (env, source) series", () => {
|
|
155
|
+
const daily = buildDailyAggregates([
|
|
156
|
+
hourly({
|
|
157
|
+
environmentId: "prod",
|
|
158
|
+
aggregatedResult: {
|
|
159
|
+
assertions: { "uuid-1": { [KEY]: { passCount: 1, failCount: 0 } } },
|
|
160
|
+
},
|
|
161
|
+
}),
|
|
162
|
+
hourly({
|
|
163
|
+
environmentId: "staging",
|
|
164
|
+
aggregatedResult: {
|
|
165
|
+
assertions: { "uuid-1": { [KEY]: { passCount: 0, failCount: 1 } } },
|
|
166
|
+
},
|
|
167
|
+
}),
|
|
168
|
+
]);
|
|
169
|
+
|
|
170
|
+
expect(daily.length).toBe(2);
|
|
171
|
+
const prod = daily.find((d) => d.environmentId === "prod");
|
|
172
|
+
const staging = daily.find((d) => d.environmentId === "staging");
|
|
173
|
+
expect(prod?.assertionStats?.["uuid-1"][KEY]).toEqual({
|
|
174
|
+
passCount: 1,
|
|
175
|
+
failCount: 0,
|
|
176
|
+
});
|
|
177
|
+
expect(staging?.assertionStats?.["uuid-1"][KEY]).toEqual({
|
|
178
|
+
passCount: 0,
|
|
179
|
+
failCount: 1,
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("buckets without stats yield undefined assertionStats", () => {
|
|
184
|
+
const daily = buildDailyAggregates([hourly({})]);
|
|
185
|
+
expect(daily[0].assertionStats).toBeUndefined();
|
|
186
|
+
});
|
|
187
|
+
});
|
package/src/router.ts
CHANGED
|
@@ -39,6 +39,11 @@ import { CatalogApi } from "@checkstack/catalog-common";
|
|
|
39
39
|
import { MaintenanceApi } from "@checkstack/maintenance-common";
|
|
40
40
|
import type { Logger } from "@checkstack/backend-api";
|
|
41
41
|
import type { HealthCheckCache } from "./cache";
|
|
42
|
+
import {
|
|
43
|
+
applySystemHealthOverrides,
|
|
44
|
+
type SystemHealthOverrideReader,
|
|
45
|
+
} from "./system-health-override";
|
|
46
|
+
import type { SystemHealthStatusResponse } from "@checkstack/healthcheck-common";
|
|
42
47
|
|
|
43
48
|
/**
|
|
44
49
|
* Creates the healthcheck router using contract-based implementation.
|
|
@@ -81,6 +86,17 @@ export const createHealthCheckRouter = (opts: {
|
|
|
81
86
|
* router MUST receive it or writes would store inline secrets verbatim.
|
|
82
87
|
*/
|
|
83
88
|
configSecrets?: HealthCheckSecretsDeps;
|
|
89
|
+
/**
|
|
90
|
+
* Reads active incident health overrides and folds them into the two
|
|
91
|
+
* user-facing system-health reads (single + bulk) via worst-wins, so a system
|
|
92
|
+
* shows the status an active incident forces even when its checks look fine.
|
|
93
|
+
* Applied OUTSIDE the status cache (always live, so an override lifts the
|
|
94
|
+
* instant its incident resolves) and ONLY in these RPC handlers - never in the
|
|
95
|
+
* shared `getSystemHealthStatus` deriver, whose other callers (SLO downtime,
|
|
96
|
+
* the AI signals scan, the persisted `health` entity) must stay checks-only.
|
|
97
|
+
* Optional so tests / no-incident deployments simply skip the fold.
|
|
98
|
+
*/
|
|
99
|
+
incidentHealthOverrideReader?: SystemHealthOverrideReader;
|
|
84
100
|
}) => {
|
|
85
101
|
const {
|
|
86
102
|
database,
|
|
@@ -94,6 +110,7 @@ export const createHealthCheckRouter = (opts: {
|
|
|
94
110
|
logger,
|
|
95
111
|
signalService,
|
|
96
112
|
recomputeSystemRollupHealth,
|
|
113
|
+
incidentHealthOverrideReader,
|
|
97
114
|
} = opts;
|
|
98
115
|
// Create service instance once - shared across all handlers
|
|
99
116
|
const service = new HealthCheckService(
|
|
@@ -105,6 +122,43 @@ export const createHealthCheckRouter = (opts: {
|
|
|
105
122
|
opts.configSecrets,
|
|
106
123
|
);
|
|
107
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Fold active incident health overrides into a batch of checks-only system
|
|
127
|
+
* statuses via worst-wins. Reads overrides for all systems in ONE incident RPC
|
|
128
|
+
* (or none, when no reader is wired). Resilient by design: incidents are a
|
|
129
|
+
* best-effort enrichment of health, so if the read fails the checks-only
|
|
130
|
+
* statuses are returned unchanged rather than failing the whole health read.
|
|
131
|
+
*/
|
|
132
|
+
const foldIncidentOverrides = async (
|
|
133
|
+
statuses: Record<string, SystemHealthStatusResponse>,
|
|
134
|
+
): Promise<Record<string, SystemHealthStatusResponse>> => {
|
|
135
|
+
const systemIds = Object.keys(statuses);
|
|
136
|
+
if (!incidentHealthOverrideReader || systemIds.length === 0) {
|
|
137
|
+
return statuses;
|
|
138
|
+
}
|
|
139
|
+
let overridesBySystem: Awaited<
|
|
140
|
+
ReturnType<SystemHealthOverrideReader["getActiveOverrides"]>
|
|
141
|
+
>;
|
|
142
|
+
try {
|
|
143
|
+
overridesBySystem =
|
|
144
|
+
await incidentHealthOverrideReader.getActiveOverrides(systemIds);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
logger.warn(
|
|
147
|
+
"Failed to read incident health overrides; returning checks-only status",
|
|
148
|
+
{ error: extractErrorMessage(error) },
|
|
149
|
+
);
|
|
150
|
+
return statuses;
|
|
151
|
+
}
|
|
152
|
+
const folded: Record<string, SystemHealthStatusResponse> = {};
|
|
153
|
+
for (const [systemId, base] of Object.entries(statuses)) {
|
|
154
|
+
folded[systemId] = applySystemHealthOverrides({
|
|
155
|
+
base,
|
|
156
|
+
overrides: overridesBySystem[systemId] ?? [],
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return folded;
|
|
160
|
+
};
|
|
161
|
+
|
|
108
162
|
// Create contract implementer with context type AND auto auth middleware
|
|
109
163
|
const os = implement(healthCheckContract)
|
|
110
164
|
.$context<RpcContext>()
|
|
@@ -201,6 +255,7 @@ export const createHealthCheckRouter = (opts: {
|
|
|
201
255
|
displayName: r.strategy.displayName,
|
|
202
256
|
description: r.strategy.description,
|
|
203
257
|
category: (r.strategy.category ?? "other") as StrategyCategory,
|
|
258
|
+
setupInstructions: r.strategy.setupInstructions,
|
|
204
259
|
configSchema: toJsonSchema(r.strategy.config.schema),
|
|
205
260
|
resultSchema: r.strategy.result
|
|
206
261
|
? toJsonSchemaWithChartMeta(r.strategy.result.schema)
|
|
@@ -622,9 +677,13 @@ export const createHealthCheckRouter = (opts: {
|
|
|
622
677
|
),
|
|
623
678
|
getSystemHealthStatus: os.getSystemHealthStatus.handler(
|
|
624
679
|
async ({ input }) => {
|
|
625
|
-
|
|
680
|
+
const base = await cache.wrapSystemHealthStatus(input.systemId, () =>
|
|
626
681
|
service.getSystemHealthStatus(input.systemId),
|
|
627
682
|
);
|
|
683
|
+
const folded = await foldIncidentOverrides({
|
|
684
|
+
[input.systemId]: base,
|
|
685
|
+
});
|
|
686
|
+
return folded[input.systemId]!;
|
|
628
687
|
},
|
|
629
688
|
),
|
|
630
689
|
|
|
@@ -634,10 +693,7 @@ export const createHealthCheckRouter = (opts: {
|
|
|
634
693
|
// and invalidated by id on mutations, so dashboards with overlapping
|
|
635
694
|
// (but non-identical) system sets share cache entries. See
|
|
636
695
|
// ./cache.ts for the key/TTL/invalidation contract.
|
|
637
|
-
const statuses: Record<
|
|
638
|
-
string,
|
|
639
|
-
Awaited<ReturnType<typeof service.getSystemHealthStatus>>
|
|
640
|
-
> = {};
|
|
696
|
+
const statuses: Record<string, SystemHealthStatusResponse> = {};
|
|
641
697
|
await Promise.all(
|
|
642
698
|
input.systemIds.map(async (systemId) => {
|
|
643
699
|
statuses[systemId] = await cache.wrapSystemHealthStatus(
|
|
@@ -646,6 +702,37 @@ export const createHealthCheckRouter = (opts: {
|
|
|
646
702
|
);
|
|
647
703
|
}),
|
|
648
704
|
);
|
|
705
|
+
return { statuses: await foldIncidentOverrides(statuses) };
|
|
706
|
+
},
|
|
707
|
+
),
|
|
708
|
+
|
|
709
|
+
getBulkSystemHealthMatrix: os.getBulkSystemHealthMatrix.handler(
|
|
710
|
+
async ({ input }) => {
|
|
711
|
+
const matrix = await service.getBulkSystemHealthMatrix(input.systemIds);
|
|
712
|
+
|
|
713
|
+
// Fold active incident overrides into each system's OVERALL rollup, so
|
|
714
|
+
// an incident-forced status still propagates through dependencies (as
|
|
715
|
+
// it does via getBulkSystemHealthStatus). Per-environment slices track
|
|
716
|
+
// health-check status only - incidents force whole-system health, which
|
|
717
|
+
// any-environment (env=null) dependency cells read from this rollup.
|
|
718
|
+
const overallOnly: Record<string, SystemHealthStatusResponse> = {};
|
|
719
|
+
for (const [systemId, m] of Object.entries(matrix)) {
|
|
720
|
+
overallOnly[systemId] = {
|
|
721
|
+
status: m.status,
|
|
722
|
+
evaluatedAt: new Date(),
|
|
723
|
+
checkStatuses: m.checkStatuses,
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
const folded = await foldIncidentOverrides(overallOnly);
|
|
727
|
+
|
|
728
|
+
const statuses: Record<string, (typeof matrix)[string]> = {};
|
|
729
|
+
for (const [systemId, m] of Object.entries(matrix)) {
|
|
730
|
+
statuses[systemId] = {
|
|
731
|
+
status: folded[systemId]?.status ?? m.status,
|
|
732
|
+
checkStatuses: m.checkStatuses,
|
|
733
|
+
environments: m.environments,
|
|
734
|
+
};
|
|
735
|
+
}
|
|
649
736
|
return { statuses };
|
|
650
737
|
},
|
|
651
738
|
),
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { describe, expect, it, mock } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { Versioned } from "@checkstack/backend-api";
|
|
4
|
+
import {
|
|
5
|
+
computeAssertionKey,
|
|
6
|
+
healthResultNumber,
|
|
7
|
+
healthResultString,
|
|
8
|
+
type CollectorConfigEntry,
|
|
9
|
+
} from "@checkstack/healthcheck-common";
|
|
10
|
+
import { HealthCheckService } from "./service";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Satellite ingest evaluates assertions ON THE CORE (satellites never held
|
|
14
|
+
* the assertion semantics — before this, satellite-executed checks silently
|
|
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.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const KEY = computeAssertionKey({
|
|
21
|
+
assertion: { field: "statusCode", operator: "equals", value: 200 },
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// `body` is ephemeral: assertable at evaluation time, never persisted.
|
|
25
|
+
const collectorResultSchema = z.object({
|
|
26
|
+
statusCode: healthResultNumber({
|
|
27
|
+
"x-chart-type": "counter",
|
|
28
|
+
"x-anomaly-enabled": false,
|
|
29
|
+
}),
|
|
30
|
+
body: healthResultString({ "x-ephemeral": true }),
|
|
31
|
+
});
|
|
32
|
+
|
|
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
|
+
})),
|
|
50
|
+
select: mock(() => ({
|
|
51
|
+
from: mock(() => ({
|
|
52
|
+
where: mock(() =>
|
|
53
|
+
Object.assign(Promise.resolve([]), {
|
|
54
|
+
limit: mock(() => Promise.resolve([])),
|
|
55
|
+
}),
|
|
56
|
+
),
|
|
57
|
+
})),
|
|
58
|
+
})),
|
|
59
|
+
};
|
|
60
|
+
|
|
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
|
+
const collectorRegistry = {
|
|
71
|
+
register: mock(() => {}),
|
|
72
|
+
getCollector: mock(() => ({
|
|
73
|
+
collector: {
|
|
74
|
+
id: "test-collector",
|
|
75
|
+
result: new Versioned({ version: 1, schema: collectorResultSchema }),
|
|
76
|
+
},
|
|
77
|
+
})),
|
|
78
|
+
getCollectors: mock(() => []),
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
return new HealthCheckService(
|
|
82
|
+
db as unknown as ConstructorParameters<typeof HealthCheckService>[0],
|
|
83
|
+
{} as unknown as ConstructorParameters<typeof HealthCheckService>[1],
|
|
84
|
+
collectorRegistry as unknown as ConstructorParameters<
|
|
85
|
+
typeof HealthCheckService
|
|
86
|
+
>[2],
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function satelliteResult({ statusCode }: { statusCode: number }) {
|
|
91
|
+
return {
|
|
92
|
+
status: "healthy",
|
|
93
|
+
latencyMs: 42,
|
|
94
|
+
message: "Completed in 42ms",
|
|
95
|
+
metadata: {
|
|
96
|
+
collectors: {
|
|
97
|
+
"entry-1": {
|
|
98
|
+
_collectorId: "test-collector",
|
|
99
|
+
statusCode,
|
|
100
|
+
body: '{"status":"ok"}',
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const entries: CollectorConfigEntry[] = [
|
|
108
|
+
{
|
|
109
|
+
id: "entry-1",
|
|
110
|
+
collectorId: "test-collector",
|
|
111
|
+
config: {},
|
|
112
|
+
assertions: [{ field: "statusCode", operator: "equals", value: 200 }],
|
|
113
|
+
},
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
async function ingest({
|
|
117
|
+
entries,
|
|
118
|
+
statusCode,
|
|
119
|
+
}: {
|
|
120
|
+
entries: CollectorConfigEntry[];
|
|
121
|
+
statusCode: number;
|
|
122
|
+
}) {
|
|
123
|
+
const inserted: Record<string, unknown>[] = [];
|
|
124
|
+
const service = buildService({ entries, inserted });
|
|
125
|
+
await service.ingestSatelliteResult({
|
|
126
|
+
configId: "config-1",
|
|
127
|
+
systemId: "system-1",
|
|
128
|
+
status: "healthy",
|
|
129
|
+
latencyMs: 42,
|
|
130
|
+
result: satelliteResult({ statusCode }) as never,
|
|
131
|
+
executedAt: "2026-07-03T10:00:00.000Z",
|
|
132
|
+
sourceId: "sat-1",
|
|
133
|
+
sourceLabel: "EU West",
|
|
134
|
+
});
|
|
135
|
+
const runInsert = inserted.find((v) => "status" in v && "result" in v);
|
|
136
|
+
expect(runInsert).toBeDefined();
|
|
137
|
+
return runInsert as Record<string, unknown>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function collectorEntryOf(runInsert: Record<string, unknown>) {
|
|
141
|
+
const result = runInsert.result as {
|
|
142
|
+
metadata: { collectors: Record<string, Record<string, unknown>> };
|
|
143
|
+
};
|
|
144
|
+
return result.metadata.collectors["entry-1"];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
describe("ingestSatelliteResult - assertion evaluation at ingest", () => {
|
|
148
|
+
it("downgrades a satellite-healthy run whose assertion fails", async () => {
|
|
149
|
+
const runInsert = await ingest({ entries, statusCode: 404 });
|
|
150
|
+
expect(runInsert.status).toBe("unhealthy");
|
|
151
|
+
|
|
152
|
+
const entry = collectorEntryOf(runInsert);
|
|
153
|
+
expect(entry._assertionFailed).toBe("statusCode equals 200");
|
|
154
|
+
expect(entry._assertions).toEqual([
|
|
155
|
+
expect.objectContaining({ key: KEY, passed: false, actual: "404" }),
|
|
156
|
+
]);
|
|
157
|
+
const message = (runInsert.result as { message: string }).message;
|
|
158
|
+
expect(message).toBe(
|
|
159
|
+
"Check failed: Assertion failed: statusCode equals 200",
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
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");
|
|
166
|
+
|
|
167
|
+
const entry = collectorEntryOf(runInsert);
|
|
168
|
+
expect(entry._assertionFailed).toBeUndefined();
|
|
169
|
+
expect(entry._assertions).toEqual([
|
|
170
|
+
expect.objectContaining({ key: KEY, passed: true, actual: "200" }),
|
|
171
|
+
]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("strips ephemeral fields AFTER assertions ran against them", async () => {
|
|
175
|
+
const withBodyAssertion: CollectorConfigEntry[] = [
|
|
176
|
+
{
|
|
177
|
+
id: "entry-1",
|
|
178
|
+
collectorId: "test-collector",
|
|
179
|
+
config: {},
|
|
180
|
+
assertions: [
|
|
181
|
+
{
|
|
182
|
+
field: "body.$",
|
|
183
|
+
jsonPath: "$.status",
|
|
184
|
+
operator: "equals",
|
|
185
|
+
value: "ok",
|
|
186
|
+
},
|
|
187
|
+
],
|
|
188
|
+
},
|
|
189
|
+
];
|
|
190
|
+
const runInsert = await ingest({
|
|
191
|
+
entries: withBodyAssertion,
|
|
192
|
+
statusCode: 200,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const entry = collectorEntryOf(runInsert);
|
|
196
|
+
// The JSONPath assertion evaluated against the (ephemeral) body...
|
|
197
|
+
expect(entry._assertions).toEqual([
|
|
198
|
+
expect.objectContaining({ passed: true, actual: "ok" }),
|
|
199
|
+
]);
|
|
200
|
+
// ...but the body itself never reaches storage.
|
|
201
|
+
expect(entry.body).toBeUndefined();
|
|
202
|
+
expect(entry.statusCode).toBe(200);
|
|
203
|
+
expect(runInsert.status).toBe("healthy");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("tolerates collector entries the config no longer knows", async () => {
|
|
207
|
+
const runInsert = await ingest({ entries: [], statusCode: 500 });
|
|
208
|
+
// No assertions configured: status passes through untouched.
|
|
209
|
+
expect(runInsert.status).toBe("healthy");
|
|
210
|
+
const entry = collectorEntryOf(runInsert);
|
|
211
|
+
expect(entry._assertions).toBeUndefined();
|
|
212
|
+
});
|
|
213
|
+
});
|