@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.
- package/CHANGELOG.md +77 -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/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/service-ingest-assertions.test.ts +213 -0
- package/src/service.ts +86 -9
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
|
+
});
|
|
@@ -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
|
-
|
|
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
|