@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/src/service.ts CHANGED
@@ -12,7 +12,9 @@ import {
12
12
  type CollectorConfigEntry,
13
13
  type HealthcheckSignalStatuses,
14
14
  type RunStats,
15
+ stripEphemeralFields,
15
16
  } from "@checkstack/healthcheck-common";
17
+ import { evaluateCollectorAssertionOutcomes } from "./collector-assertions";
16
18
  import { summarizeRuns, type StatRun } from "./run-stats.logic";
17
19
  import type { ConfigService } from "@checkstack/backend-api";
18
20
  import type { InferClient } from "@checkstack/common";
@@ -39,6 +41,7 @@ import {
39
41
  gte,
40
42
  lte,
41
43
  isNull,
44
+ isNotNull,
42
45
  inArray,
43
46
  } from "drizzle-orm";
44
47
  import { ORPCError } from "@orpc/server";
@@ -57,12 +60,14 @@ import type {
57
60
  import {
58
61
  aggregateCollectorData,
59
62
  extractLatencies,
63
+ foldRunAssertionStats,
60
64
  mergeTieredBuckets,
61
65
  reaggregateBuckets,
62
66
  countStatuses,
63
67
  calculateLatencyStats,
64
68
  type NormalizedBucket,
65
69
  } from "./aggregation-utils";
70
+ import { ASSERTIONS_AGG_KEY } from "@checkstack/healthcheck-common";
66
71
  import {
67
72
  extractConfigurationSecrets,
68
73
  mergeConfigurationSecrets,
@@ -1337,6 +1342,98 @@ export class HealthCheckService {
1337
1342
  return Object.fromEntries(entries);
1338
1343
  }
1339
1344
 
1345
+ /**
1346
+ * Bulk per-(system, check, environment) health for the given systems.
1347
+ *
1348
+ * For each system returns the cross-environment rollup (status +
1349
+ * checkStatuses, same as {@link getSystemHealthStatus}) PLUS a slice per
1350
+ * environment the system has runs for. Consumers that scope by environment
1351
+ * (the dependency map) must read the per-environment slice, because the
1352
+ * rollup deliberately hides a single failing environment.
1353
+ *
1354
+ * Cost scales with the number of environments each system actually fans out
1355
+ * to (`1 + #envs` status evaluations per system); systems with only env-less
1356
+ * runs cost the same as a plain rollup read. Not on any per-run hot path.
1357
+ */
1358
+ async getBulkSystemHealthMatrix(systemIds: string[]): Promise<
1359
+ Record<
1360
+ string,
1361
+ {
1362
+ status: HealthCheckStatus;
1363
+ checkStatuses: SystemHealthStatusResponse["checkStatuses"];
1364
+ environments: Record<
1365
+ string,
1366
+ {
1367
+ status: HealthCheckStatus;
1368
+ checkStatuses: SystemHealthStatusResponse["checkStatuses"];
1369
+ }
1370
+ >;
1371
+ }
1372
+ >
1373
+ > {
1374
+ const result: Record<
1375
+ string,
1376
+ {
1377
+ status: HealthCheckStatus;
1378
+ checkStatuses: SystemHealthStatusResponse["checkStatuses"];
1379
+ environments: Record<
1380
+ string,
1381
+ {
1382
+ status: HealthCheckStatus;
1383
+ checkStatuses: SystemHealthStatusResponse["checkStatuses"];
1384
+ }
1385
+ >;
1386
+ }
1387
+ > = {};
1388
+
1389
+ await Promise.all(
1390
+ systemIds.map(async (systemId) => {
1391
+ const overall = await this.getSystemHealthStatus(systemId);
1392
+
1393
+ // Environments this system actually has runs for (env-less excluded -
1394
+ // it is folded into the rollup and never a real environment id).
1395
+ const envRows = await this.db
1396
+ .selectDistinct({ environmentId: healthCheckRuns.environmentId })
1397
+ .from(healthCheckRuns)
1398
+ .where(
1399
+ and(
1400
+ eq(healthCheckRuns.systemId, systemId),
1401
+ isNotNull(healthCheckRuns.environmentId),
1402
+ ),
1403
+ );
1404
+
1405
+ const environments: Record<
1406
+ string,
1407
+ {
1408
+ status: HealthCheckStatus;
1409
+ checkStatuses: SystemHealthStatusResponse["checkStatuses"];
1410
+ }
1411
+ > = {};
1412
+ await Promise.all(
1413
+ envRows.map(async ({ environmentId }) => {
1414
+ if (!environmentId) return;
1415
+ const envStatus = await this.getSystemHealthStatus(
1416
+ systemId,
1417
+ environmentId,
1418
+ );
1419
+ environments[environmentId] = {
1420
+ status: envStatus.status,
1421
+ checkStatuses: envStatus.checkStatuses,
1422
+ };
1423
+ }),
1424
+ );
1425
+
1426
+ result[systemId] = {
1427
+ status: overall.status,
1428
+ checkStatuses: overall.checkStatuses,
1429
+ environments,
1430
+ };
1431
+ }),
1432
+ );
1433
+
1434
+ return result;
1435
+ }
1436
+
1340
1437
  /**
1341
1438
  * Get comprehensive health overview for a system.
1342
1439
  * Returns all health checks with their last 25 runs for sparkline visualization.
@@ -2151,9 +2248,16 @@ export class HealthCheckService {
2151
2248
  );
2152
2249
  }
2153
2250
 
2251
+ // Per-assertion pass/fail counts (platform-owned, sibling of
2252
+ // `collectors` — see assertion-analytics in healthcheck-common).
2253
+ const assertionStats = foldRunAssertionStats(bucket.runs);
2254
+
2154
2255
  aggregatedResult = {
2155
2256
  ...strategyResult,
2156
2257
  ...(collectorsAggregated ? { collectors: collectorsAggregated } : {}),
2258
+ ...(assertionStats === undefined
2259
+ ? {}
2260
+ : { [ASSERTIONS_AGG_KEY]: assertionStats }),
2157
2261
  };
2158
2262
  }
2159
2263
 
@@ -2421,6 +2525,15 @@ export class HealthCheckService {
2421
2525
  * Ingest a health check result from a satellite.
2422
2526
  * Stores the run with source attribution (sourceId + sourceLabel)
2423
2527
  * and triggers incremental aggregation to keep charts/availability current.
2528
+ *
2529
+ * Assertions are evaluated HERE, on the core, not on the satellite: the
2530
+ * satellite never held the assertion semantics, so historically
2531
+ * satellite-executed checks silently skipped assertions entirely.
2532
+ * Evaluating at ingest fixes that for every satellite version with no
2533
+ * wire-protocol change. Caveat: buffered results are evaluated against the
2534
+ * configuration CURRENT at ingest time. Ephemeral result fields (e.g. raw
2535
+ * HTTP bodies) are needed for JSONPath assertions and are stripped right
2536
+ * after evaluation, matching what the local executor stores.
2424
2537
  */
2425
2538
  async ingestSatelliteResult(props: {
2426
2539
  configId: string;
@@ -2432,20 +2545,77 @@ export class HealthCheckService {
2432
2545
  sourceId: string;
2433
2546
  sourceLabel: string;
2434
2547
  }) {
2435
- const {
2436
- configId,
2437
- systemId,
2438
- status,
2439
- latencyMs,
2440
- result,
2441
- sourceId,
2442
- sourceLabel,
2443
- } = props;
2548
+ const { configId, systemId, latencyMs, result, sourceId, sourceLabel } =
2549
+ props;
2444
2550
 
2445
2551
  const resultRecord = result
2446
2552
  ? ({ ...result } as Record<string, unknown>)
2447
2553
  : {};
2448
2554
 
2555
+ let status = props.status;
2556
+ const metadata = resultRecord.metadata as
2557
+ | Record<string, unknown>
2558
+ | undefined;
2559
+ const collectorsMeta = metadata?.collectors as
2560
+ | Record<string, Record<string, unknown>>
2561
+ | undefined;
2562
+ if (collectorsMeta && Object.keys(collectorsMeta).length > 0) {
2563
+ const [configRow] = await this.db
2564
+ .select({ collectors: healthCheckConfigurations.collectors })
2565
+ .from(healthCheckConfigurations)
2566
+ .where(eq(healthCheckConfigurations.id, configId));
2567
+ const entries: CollectorConfigEntry[] = configRow?.collectors ?? [];
2568
+
2569
+ let firstFailure: string | undefined;
2570
+ const nextCollectorsMeta: Record<string, Record<string, unknown>> = {
2571
+ ...collectorsMeta,
2572
+ };
2573
+ for (const entry of entries) {
2574
+ const entryResult = nextCollectorsMeta[entry.id];
2575
+ if (!entryResult || typeof entryResult !== "object") continue;
2576
+
2577
+ let evaluated: Record<string, unknown> = { ...entryResult };
2578
+ if (entry.assertions?.length) {
2579
+ const evaluation = evaluateCollectorAssertionOutcomes({
2580
+ assertions: entry.assertions,
2581
+ result: evaluated,
2582
+ });
2583
+ evaluated._assertions = evaluation.outcomes;
2584
+ evaluated._assertionFailed = evaluation.firstFailureMessage;
2585
+ if (
2586
+ evaluation.firstFailureMessage !== undefined &&
2587
+ firstFailure === undefined
2588
+ ) {
2589
+ firstFailure = evaluation.firstFailureMessage;
2590
+ }
2591
+ }
2592
+
2593
+ // Parity with the local executor: satellites send raw results, so
2594
+ // ephemeral fields (assertable but never persisted) get stripped
2595
+ // here, AFTER assertions ran against them.
2596
+ const registered = this.collectorRegistry.getCollector(
2597
+ entry.collectorId,
2598
+ );
2599
+ if (registered) {
2600
+ evaluated = stripEphemeralFields(
2601
+ evaluated,
2602
+ registered.collector.result.schema,
2603
+ );
2604
+ }
2605
+ nextCollectorsMeta[entry.id] = evaluated;
2606
+ }
2607
+
2608
+ resultRecord.metadata = { ...metadata, collectors: nextCollectorsMeta };
2609
+
2610
+ // Mirror the local executor: a failed assertion downgrades a run the
2611
+ // satellite reported healthy.
2612
+ if (firstFailure !== undefined && status === "healthy") {
2613
+ status = "unhealthy";
2614
+ resultRecord.status = status;
2615
+ resultRecord.message = `Check failed: Assertion failed: ${firstFailure}`;
2616
+ }
2617
+ }
2618
+
2449
2619
  // Atomic: the run row and the hourly-aggregate increment it feeds must
2450
2620
  // commit together. Without the transaction a failure on the (non-idempotent
2451
2621
  // `runCount + 1`) aggregate left a committed run that the aggregate never
@@ -91,7 +91,17 @@ async function healthStatuses(
91
91
  const { statuses } = await ctx.rpcClient
92
92
  .forPlugin(HealthCheckApi)
93
93
  .getBulkSystemHealthStatus({ systemIds: ids });
94
- return statuses;
94
+ // Project to ONLY the derived status. `getBulkSystemHealthStatus` folds active
95
+ // incident overrides into `status` (so the public page shows the forced
96
+ // status), but the response also carries `override.reason` = the incident
97
+ // TITLE. Status pages are public and incidents may be hidden, so we drop
98
+ // everything but the status here - the incident name must never reach a public
99
+ // widget DTO.
100
+ const projected: Record<string, { status: string } | undefined> = {};
101
+ for (const [systemId, value] of Object.entries(statuses)) {
102
+ projected[systemId] = value ? { status: value.status } : undefined;
103
+ }
104
+ return projected;
95
105
  }
96
106
 
97
107
  function publicStatus({
@@ -0,0 +1,94 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ applySystemHealthOverrides,
4
+ worstHealthStatus,
5
+ type SystemHealthOverrideInput,
6
+ } from "./system-health-override";
7
+ import type { SystemHealthStatusResponse } from "@checkstack/healthcheck-common";
8
+
9
+ const at = new Date("2026-07-05T00:00:00.000Z");
10
+
11
+ function base(
12
+ status: SystemHealthStatusResponse["status"],
13
+ ): SystemHealthStatusResponse {
14
+ return { status, evaluatedAt: at, checkStatuses: [] };
15
+ }
16
+
17
+ function override(
18
+ status: "degraded" | "unhealthy",
19
+ sourceId: string,
20
+ ): SystemHealthOverrideInput {
21
+ return {
22
+ status,
23
+ source: "incident",
24
+ reason: `Incident ${sourceId}`,
25
+ sourceId,
26
+ };
27
+ }
28
+
29
+ describe("worstHealthStatus", () => {
30
+ it("orders unhealthy > degraded > healthy", () => {
31
+ expect(worstHealthStatus("healthy", "degraded")).toBe("degraded");
32
+ expect(worstHealthStatus("degraded", "unhealthy")).toBe("unhealthy");
33
+ expect(worstHealthStatus("unhealthy", "degraded")).toBe("unhealthy");
34
+ expect(worstHealthStatus("healthy", "healthy")).toBe("healthy");
35
+ });
36
+ });
37
+
38
+ describe("applySystemHealthOverrides", () => {
39
+ it("returns the base unchanged (no override field) when there are no overrides", () => {
40
+ const result = applySystemHealthOverrides({
41
+ base: base("healthy"),
42
+ overrides: [],
43
+ });
44
+ expect(result.status).toBe("healthy");
45
+ expect(result.override).toBeUndefined();
46
+ });
47
+
48
+ it("raises a healthy system to the override status and records the source", () => {
49
+ const result = applySystemHealthOverrides({
50
+ base: base("healthy"),
51
+ overrides: [override("unhealthy", "inc-1")],
52
+ });
53
+ expect(result.status).toBe("unhealthy");
54
+ expect(result.override).toEqual({
55
+ status: "unhealthy",
56
+ source: "incident",
57
+ reason: "Incident inc-1",
58
+ sourceId: "inc-1",
59
+ });
60
+ });
61
+
62
+ it("applies an override even when the system has no health checks", () => {
63
+ const result = applySystemHealthOverrides({
64
+ base: base("healthy"), // no-checks default is healthy
65
+ overrides: [override("degraded", "inc-2")],
66
+ });
67
+ expect(result.status).toBe("degraded");
68
+ expect(result.override?.status).toBe("degraded");
69
+ });
70
+
71
+ it("keeps the worse HEALTH CHECK status when a check is worse than the override", () => {
72
+ // Override says degraded, but a check reports unhealthy -> unhealthy wins.
73
+ const result = applySystemHealthOverrides({
74
+ base: base("unhealthy"),
75
+ overrides: [override("degraded", "inc-3")],
76
+ });
77
+ expect(result.status).toBe("unhealthy");
78
+ // The override is still surfaced (it contributed), at its own status.
79
+ expect(result.override?.status).toBe("degraded");
80
+ });
81
+
82
+ it("picks the worst override when several incidents override the same system", () => {
83
+ const result = applySystemHealthOverrides({
84
+ base: base("healthy"),
85
+ overrides: [
86
+ override("degraded", "inc-a"),
87
+ override("unhealthy", "inc-b"),
88
+ override("degraded", "inc-c"),
89
+ ],
90
+ });
91
+ expect(result.status).toBe("unhealthy");
92
+ expect(result.override?.sourceId).toBe("inc-b");
93
+ });
94
+ });
@@ -0,0 +1,93 @@
1
+ import type {
2
+ HealthCheckStatus,
3
+ SystemHealthStatusResponse,
4
+ SystemHealthOverride,
5
+ } from "@checkstack/healthcheck-common";
6
+
7
+ /**
8
+ * Worst-wins ordering for the derived health vocabulary: a higher rank is a
9
+ * worse status. Mirrors the inline `unhealthy > degraded > healthy` ordering the
10
+ * per-check rollup uses in `service.ts`, extracted so the incident-override fold
11
+ * shares the exact same comparison.
12
+ */
13
+ const HEALTH_RANK: Record<HealthCheckStatus, number> = {
14
+ healthy: 0,
15
+ degraded: 1,
16
+ unhealthy: 2,
17
+ };
18
+
19
+ /** Returns whichever status is worse (ties return `a`). */
20
+ export function worstHealthStatus(
21
+ a: HealthCheckStatus,
22
+ b: HealthCheckStatus,
23
+ ): HealthCheckStatus {
24
+ return HEALTH_RANK[b] > HEALTH_RANK[a] ? b : a;
25
+ }
26
+
27
+ /**
28
+ * A non-health-check contribution to a system's health, as consumed by the
29
+ * fold. Kept source-agnostic (`source`/`sourceId`) so the health plugin does not
30
+ * hard-code incident semantics; the incident-backed reader maps its rows into
31
+ * this shape.
32
+ */
33
+ export interface SystemHealthOverrideInput {
34
+ /** The status this contributor forces. Never `healthy` in practice. */
35
+ status: HealthCheckStatus;
36
+ /** Contributor kind, e.g. "incident". */
37
+ source: string;
38
+ /** Human-readable reason, e.g. the incident title. */
39
+ reason: string;
40
+ /** Opaque id of the contributing record, e.g. the incident id. */
41
+ sourceId?: string;
42
+ }
43
+
44
+ /**
45
+ * Reads active health overrides for a set of systems. Implemented in the plugin
46
+ * wiring over the incident RPC; injected into the health service so the service
47
+ * stays free of a direct incident dependency and tests can stub it.
48
+ */
49
+ export interface SystemHealthOverrideReader {
50
+ getActiveOverrides(
51
+ systemIds: string[],
52
+ ): Promise<Record<string, SystemHealthOverrideInput[]>>;
53
+ }
54
+
55
+ /**
56
+ * Fold a system's active overrides into its health-check-derived status via
57
+ * worst-wins. The overall status becomes the worst of the checks-derived status
58
+ * and every override, so an override that raises a system to `degraded` never
59
+ * masks a health check reporting `unhealthy` (the worse status always wins), and
60
+ * an override applies even when the system has no health checks at all.
61
+ *
62
+ * The worst contributing override (if any) is surfaced on `override` so a UI can
63
+ * explain why a system reads worse than its checks alone. When the overrides are
64
+ * empty the base response is returned unchanged (no `override`).
65
+ */
66
+ export function applySystemHealthOverrides({
67
+ base,
68
+ overrides,
69
+ }: {
70
+ base: SystemHealthStatusResponse;
71
+ overrides: SystemHealthOverrideInput[];
72
+ }): SystemHealthStatusResponse {
73
+ if (overrides.length === 0) return base;
74
+
75
+ // The override that reads worst wins the `override` slot; a tie keeps the
76
+ // first (query order), which for incidents is a stable, arbitrary pick.
77
+ let worst = overrides[0]!;
78
+ for (const candidate of overrides) {
79
+ if (HEALTH_RANK[candidate.status] > HEALTH_RANK[worst.status]) {
80
+ worst = candidate;
81
+ }
82
+ }
83
+
84
+ const status = worstHealthStatus(base.status, worst.status);
85
+ const override: SystemHealthOverride = {
86
+ status: worst.status,
87
+ source: worst.source,
88
+ reason: worst.reason,
89
+ sourceId: worst.sourceId,
90
+ };
91
+
92
+ return { ...base, status, override };
93
+ }