@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.
@@ -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 { collectors: mergedCollectors };
348
+ return {
349
+ collectors: mergedCollectors,
350
+ ...(assertionStats === undefined
351
+ ? {}
352
+ : { [ASSERTIONS_AGG_KEY]: assertionStats }),
353
+ };
317
354
  }
@@ -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
- aggregatedResult: undefined, // Cannot combine result across hours
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
- return cache.wrapSystemHealthStatus(input.systemId, () =>
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
+ });