@checkstack/healthcheck-backend 1.15.0 → 1.16.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.
@@ -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
+ });
@@ -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
+ });
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";
@@ -57,12 +59,14 @@ import type {
57
59
  import {
58
60
  aggregateCollectorData,
59
61
  extractLatencies,
62
+ foldRunAssertionStats,
60
63
  mergeTieredBuckets,
61
64
  reaggregateBuckets,
62
65
  countStatuses,
63
66
  calculateLatencyStats,
64
67
  type NormalizedBucket,
65
68
  } from "./aggregation-utils";
69
+ import { ASSERTIONS_AGG_KEY } from "@checkstack/healthcheck-common";
66
70
  import {
67
71
  extractConfigurationSecrets,
68
72
  mergeConfigurationSecrets,
@@ -2151,9 +2155,16 @@ export class HealthCheckService {
2151
2155
  );
2152
2156
  }
2153
2157
 
2158
+ // Per-assertion pass/fail counts (platform-owned, sibling of
2159
+ // `collectors` — see assertion-analytics in healthcheck-common).
2160
+ const assertionStats = foldRunAssertionStats(bucket.runs);
2161
+
2154
2162
  aggregatedResult = {
2155
2163
  ...strategyResult,
2156
2164
  ...(collectorsAggregated ? { collectors: collectorsAggregated } : {}),
2165
+ ...(assertionStats === undefined
2166
+ ? {}
2167
+ : { [ASSERTIONS_AGG_KEY]: assertionStats }),
2157
2168
  };
2158
2169
  }
2159
2170
 
@@ -2421,6 +2432,15 @@ export class HealthCheckService {
2421
2432
  * Ingest a health check result from a satellite.
2422
2433
  * Stores the run with source attribution (sourceId + sourceLabel)
2423
2434
  * and triggers incremental aggregation to keep charts/availability current.
2435
+ *
2436
+ * Assertions are evaluated HERE, on the core, not on the satellite: the
2437
+ * satellite never held the assertion semantics, so historically
2438
+ * satellite-executed checks silently skipped assertions entirely.
2439
+ * Evaluating at ingest fixes that for every satellite version with no
2440
+ * wire-protocol change. Caveat: buffered results are evaluated against the
2441
+ * configuration CURRENT at ingest time. Ephemeral result fields (e.g. raw
2442
+ * HTTP bodies) are needed for JSONPath assertions and are stripped right
2443
+ * after evaluation, matching what the local executor stores.
2424
2444
  */
2425
2445
  async ingestSatelliteResult(props: {
2426
2446
  configId: string;
@@ -2432,20 +2452,77 @@ export class HealthCheckService {
2432
2452
  sourceId: string;
2433
2453
  sourceLabel: string;
2434
2454
  }) {
2435
- const {
2436
- configId,
2437
- systemId,
2438
- status,
2439
- latencyMs,
2440
- result,
2441
- sourceId,
2442
- sourceLabel,
2443
- } = props;
2455
+ const { configId, systemId, latencyMs, result, sourceId, sourceLabel } =
2456
+ props;
2444
2457
 
2445
2458
  const resultRecord = result
2446
2459
  ? ({ ...result } as Record<string, unknown>)
2447
2460
  : {};
2448
2461
 
2462
+ let status = props.status;
2463
+ const metadata = resultRecord.metadata as
2464
+ | Record<string, unknown>
2465
+ | undefined;
2466
+ const collectorsMeta = metadata?.collectors as
2467
+ | Record<string, Record<string, unknown>>
2468
+ | undefined;
2469
+ if (collectorsMeta && Object.keys(collectorsMeta).length > 0) {
2470
+ const [configRow] = await this.db
2471
+ .select({ collectors: healthCheckConfigurations.collectors })
2472
+ .from(healthCheckConfigurations)
2473
+ .where(eq(healthCheckConfigurations.id, configId));
2474
+ const entries: CollectorConfigEntry[] = configRow?.collectors ?? [];
2475
+
2476
+ let firstFailure: string | undefined;
2477
+ const nextCollectorsMeta: Record<string, Record<string, unknown>> = {
2478
+ ...collectorsMeta,
2479
+ };
2480
+ for (const entry of entries) {
2481
+ const entryResult = nextCollectorsMeta[entry.id];
2482
+ if (!entryResult || typeof entryResult !== "object") continue;
2483
+
2484
+ let evaluated: Record<string, unknown> = { ...entryResult };
2485
+ if (entry.assertions?.length) {
2486
+ const evaluation = evaluateCollectorAssertionOutcomes({
2487
+ assertions: entry.assertions,
2488
+ result: evaluated,
2489
+ });
2490
+ evaluated._assertions = evaluation.outcomes;
2491
+ evaluated._assertionFailed = evaluation.firstFailureMessage;
2492
+ if (
2493
+ evaluation.firstFailureMessage !== undefined &&
2494
+ firstFailure === undefined
2495
+ ) {
2496
+ firstFailure = evaluation.firstFailureMessage;
2497
+ }
2498
+ }
2499
+
2500
+ // Parity with the local executor: satellites send raw results, so
2501
+ // ephemeral fields (assertable but never persisted) get stripped
2502
+ // here, AFTER assertions ran against them.
2503
+ const registered = this.collectorRegistry.getCollector(
2504
+ entry.collectorId,
2505
+ );
2506
+ if (registered) {
2507
+ evaluated = stripEphemeralFields(
2508
+ evaluated,
2509
+ registered.collector.result.schema,
2510
+ );
2511
+ }
2512
+ nextCollectorsMeta[entry.id] = evaluated;
2513
+ }
2514
+
2515
+ resultRecord.metadata = { ...metadata, collectors: nextCollectorsMeta };
2516
+
2517
+ // Mirror the local executor: a failed assertion downgrades a run the
2518
+ // satellite reported healthy.
2519
+ if (firstFailure !== undefined && status === "healthy") {
2520
+ status = "unhealthy";
2521
+ resultRecord.status = status;
2522
+ resultRecord.message = `Check failed: Assertion failed: ${firstFailure}`;
2523
+ }
2524
+ }
2525
+
2449
2526
  // Atomic: the run row and the hourly-aggregate increment it feeds must
2450
2527
  // commit together. Without the transaction a failure on the (non-idempotent
2451
2528
  // `runCount + 1`) aggregate left a committed run that the aggregate never