@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,8 +1,12 @@
|
|
|
1
|
+
import { evaluateAssertion } from "@checkstack/backend-api";
|
|
2
|
+
import type {
|
|
3
|
+
AssertionOutcome,
|
|
4
|
+
CollectorAssertion,
|
|
5
|
+
} from "@checkstack/healthcheck-common";
|
|
1
6
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
} from "@checkstack/
|
|
5
|
-
import type { CollectorAssertion } from "@checkstack/healthcheck-common";
|
|
7
|
+
computeAssertionKey,
|
|
8
|
+
truncateActual,
|
|
9
|
+
} from "@checkstack/healthcheck-common";
|
|
6
10
|
import { extractErrorMessage } from "@checkstack/common";
|
|
7
11
|
import { JSONPath } from "jsonpath-plus";
|
|
8
12
|
|
|
@@ -69,9 +73,23 @@ function formatFailure({
|
|
|
69
73
|
return detail ? `${base} (${detail})` : base;
|
|
70
74
|
}
|
|
71
75
|
|
|
76
|
+
/** All outcomes of a collector's assertions plus the legacy failure string. */
|
|
77
|
+
export interface CollectorAssertionEvaluation {
|
|
78
|
+
/** One structured outcome per configured assertion, in config order. */
|
|
79
|
+
outcomes: AssertionOutcome[];
|
|
80
|
+
/**
|
|
81
|
+
* The FIRST failing assertion's message, formatted exactly like the legacy
|
|
82
|
+
* `_assertionFailed` string. Undefined when everything passed.
|
|
83
|
+
*/
|
|
84
|
+
firstFailureMessage?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
72
87
|
/**
|
|
73
|
-
* Evaluate a collector's assertions - plain field assertions AND
|
|
74
|
-
* assertions - against its result, in the order they were
|
|
88
|
+
* Evaluate ALL of a collector's assertions - plain field assertions AND
|
|
89
|
+
* JSONPath assertions - against its result, in the order they were
|
|
90
|
+
* configured, returning a structured outcome per assertion (pass AND fail;
|
|
91
|
+
* this is what makes assertions analyzable rather than only visible on
|
|
92
|
+
* failure).
|
|
75
93
|
*
|
|
76
94
|
* Plain assertions compare `result[field]` directly (unchanged behaviour).
|
|
77
95
|
* JSONPath assertions parse the SOURCE field (e.g. `body` for the field
|
|
@@ -79,18 +97,15 @@ function formatFailure({
|
|
|
79
97
|
* apply the operator to the extracted value. Fail-closed: a missing
|
|
80
98
|
* expression, a non-JSON source value, or an invalid/eval-blocked path fails
|
|
81
99
|
* the assertion (with a diagnostic suffix) - it never fails the collector.
|
|
82
|
-
*
|
|
83
|
-
* Returns the failure message of the FIRST failing assertion, or `undefined`
|
|
84
|
-
* when all pass.
|
|
85
100
|
*/
|
|
86
|
-
export function
|
|
101
|
+
export function evaluateCollectorAssertionOutcomes({
|
|
87
102
|
assertions,
|
|
88
103
|
result,
|
|
89
104
|
}: {
|
|
90
105
|
assertions: CollectorAssertion[] | undefined;
|
|
91
106
|
result: Record<string, unknown>;
|
|
92
|
-
}):
|
|
93
|
-
if (!assertions?.length) return
|
|
107
|
+
}): CollectorAssertionEvaluation {
|
|
108
|
+
if (!assertions?.length) return { outcomes: [] };
|
|
94
109
|
|
|
95
110
|
// Parse each JSON source field at most once, not once per assertion.
|
|
96
111
|
const parsedSources = new Map<string, { json?: unknown; error?: string }>();
|
|
@@ -116,19 +131,72 @@ export function evaluateCollectorAssertions({
|
|
|
116
131
|
return entry;
|
|
117
132
|
};
|
|
118
133
|
|
|
134
|
+
const outcomes: AssertionOutcome[] = [];
|
|
135
|
+
let firstFailureMessage: string | undefined;
|
|
136
|
+
|
|
137
|
+
const baseOutcome = (assertion: CollectorAssertion) => ({
|
|
138
|
+
key: computeAssertionKey({ assertion }),
|
|
139
|
+
field: assertion.field,
|
|
140
|
+
jsonPath: assertion.jsonPath?.trim() || undefined,
|
|
141
|
+
operator: assertion.operator,
|
|
142
|
+
value:
|
|
143
|
+
assertion.value === undefined ? undefined : String(assertion.value),
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const recordFailure = ({
|
|
147
|
+
assertion,
|
|
148
|
+
actual,
|
|
149
|
+
message,
|
|
150
|
+
legacyMessage,
|
|
151
|
+
}: {
|
|
152
|
+
assertion: CollectorAssertion;
|
|
153
|
+
actual?: unknown;
|
|
154
|
+
message: string;
|
|
155
|
+
legacyMessage: string;
|
|
156
|
+
}) => {
|
|
157
|
+
outcomes.push({
|
|
158
|
+
...baseOutcome(assertion),
|
|
159
|
+
passed: false,
|
|
160
|
+
actual: actual === undefined ? undefined : truncateActual({ value: actual }),
|
|
161
|
+
message,
|
|
162
|
+
});
|
|
163
|
+
if (firstFailureMessage === undefined) firstFailureMessage = legacyMessage;
|
|
164
|
+
};
|
|
165
|
+
|
|
119
166
|
for (const assertion of assertions) {
|
|
120
167
|
if (!isJsonPathAssertion(assertion)) {
|
|
121
|
-
const
|
|
122
|
-
if (
|
|
168
|
+
const evaluated = evaluateAssertion(assertion, result);
|
|
169
|
+
if (evaluated.passed) {
|
|
170
|
+
outcomes.push({
|
|
171
|
+
...baseOutcome(assertion),
|
|
172
|
+
passed: true,
|
|
173
|
+
actual:
|
|
174
|
+
evaluated.actual === undefined
|
|
175
|
+
? undefined
|
|
176
|
+
: truncateActual({ value: evaluated.actual }),
|
|
177
|
+
});
|
|
178
|
+
} else {
|
|
179
|
+
recordFailure({
|
|
180
|
+
assertion,
|
|
181
|
+
actual: evaluated.actual,
|
|
182
|
+
message: evaluated.message ?? formatFailure({ assertion }),
|
|
183
|
+
legacyMessage: formatFailure({ assertion }),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
123
186
|
continue;
|
|
124
187
|
}
|
|
125
188
|
|
|
126
189
|
const path = assertion.jsonPath?.trim();
|
|
127
190
|
if (!path) {
|
|
128
|
-
|
|
191
|
+
recordFailure({
|
|
129
192
|
assertion,
|
|
130
|
-
|
|
193
|
+
message: "missing JSONPath expression",
|
|
194
|
+
legacyMessage: formatFailure({
|
|
195
|
+
assertion,
|
|
196
|
+
detail: "missing JSONPath expression",
|
|
197
|
+
}),
|
|
131
198
|
});
|
|
199
|
+
continue;
|
|
132
200
|
}
|
|
133
201
|
|
|
134
202
|
const sourceField = assertion.field.endsWith(JSONPATH_FIELD_SUFFIX)
|
|
@@ -136,34 +204,73 @@ export function evaluateCollectorAssertions({
|
|
|
136
204
|
: assertion.field;
|
|
137
205
|
const source = parseSource(sourceField);
|
|
138
206
|
if (source.error) {
|
|
139
|
-
|
|
207
|
+
recordFailure({
|
|
208
|
+
assertion,
|
|
209
|
+
message: source.error,
|
|
210
|
+
legacyMessage: formatFailure({ assertion, detail: source.error }),
|
|
211
|
+
});
|
|
212
|
+
continue;
|
|
140
213
|
}
|
|
141
214
|
|
|
142
215
|
try {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
216
|
+
// Extract once, then reuse the shared operator engine on a synthetic
|
|
217
|
+
// one-field record so the outcome carries the observed value.
|
|
218
|
+
const extracted = extractJsonPath(path, source.json);
|
|
219
|
+
const evaluated = evaluateAssertion(
|
|
220
|
+
{
|
|
221
|
+
field: "__jsonpath__",
|
|
222
|
+
operator: assertion.operator,
|
|
223
|
+
value:
|
|
224
|
+
assertion.value === undefined
|
|
225
|
+
? undefined
|
|
226
|
+
: String(assertion.value),
|
|
227
|
+
},
|
|
228
|
+
{ __jsonpath__: extracted },
|
|
156
229
|
);
|
|
157
|
-
if (
|
|
230
|
+
if (evaluated.passed) {
|
|
231
|
+
outcomes.push({
|
|
232
|
+
...baseOutcome(assertion),
|
|
233
|
+
passed: true,
|
|
234
|
+
actual:
|
|
235
|
+
extracted === undefined
|
|
236
|
+
? undefined
|
|
237
|
+
: truncateActual({ value: extracted }),
|
|
238
|
+
});
|
|
239
|
+
} else {
|
|
240
|
+
recordFailure({
|
|
241
|
+
assertion,
|
|
242
|
+
actual: extracted,
|
|
243
|
+
message: evaluated.message ?? formatFailure({ assertion }),
|
|
244
|
+
legacyMessage: formatFailure({ assertion }),
|
|
245
|
+
});
|
|
246
|
+
}
|
|
158
247
|
} catch (error) {
|
|
159
248
|
// jsonpath-plus rejects malformed paths and (with eval disabled)
|
|
160
249
|
// filter/script expressions by throwing.
|
|
161
|
-
|
|
250
|
+
const detail = `invalid JSONPath: ${extractErrorMessage(error)}`;
|
|
251
|
+
recordFailure({
|
|
162
252
|
assertion,
|
|
163
|
-
|
|
253
|
+
message: detail,
|
|
254
|
+
legacyMessage: formatFailure({ assertion, detail }),
|
|
164
255
|
});
|
|
165
256
|
}
|
|
166
257
|
}
|
|
167
258
|
|
|
168
|
-
return
|
|
259
|
+
return { outcomes, firstFailureMessage };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Legacy single-string view of {@link evaluateCollectorAssertionOutcomes}:
|
|
264
|
+
* the failure message of the FIRST failing assertion, or `undefined` when all
|
|
265
|
+
* pass. Kept for callers that only need the `_assertionFailed` string.
|
|
266
|
+
*/
|
|
267
|
+
export function evaluateCollectorAssertions({
|
|
268
|
+
assertions,
|
|
269
|
+
result,
|
|
270
|
+
}: {
|
|
271
|
+
assertions: CollectorAssertion[] | undefined;
|
|
272
|
+
result: Record<string, unknown>;
|
|
273
|
+
}): string | undefined {
|
|
274
|
+
return evaluateCollectorAssertionOutcomes({ assertions, result })
|
|
275
|
+
.firstFailureMessage;
|
|
169
276
|
}
|
package/src/index.ts
CHANGED
|
@@ -524,6 +524,35 @@ export default createBackendPlugin({
|
|
|
524
524
|
logger,
|
|
525
525
|
signalService,
|
|
526
526
|
configSecrets,
|
|
527
|
+
// Fold active incident health overrides into the user-facing system
|
|
528
|
+
// health reads (worst-wins). Reuses the incident client already built
|
|
529
|
+
// above; maps the incident rows to the source-agnostic override shape
|
|
530
|
+
// the fold expects. Kept out of the shared deriver so SLO/AI/entity
|
|
531
|
+
// paths stay checks-only (see router.ts).
|
|
532
|
+
incidentHealthOverrideReader: {
|
|
533
|
+
getActiveOverrides: async (systemIds) => {
|
|
534
|
+
const { overrides } =
|
|
535
|
+
await incidentClient.getActiveHealthOverrides({ systemIds });
|
|
536
|
+
const mapped: Record<
|
|
537
|
+
string,
|
|
538
|
+
{
|
|
539
|
+
status: "degraded" | "unhealthy";
|
|
540
|
+
source: string;
|
|
541
|
+
reason: string;
|
|
542
|
+
sourceId?: string;
|
|
543
|
+
}[]
|
|
544
|
+
> = {};
|
|
545
|
+
for (const [systemId, list] of Object.entries(overrides)) {
|
|
546
|
+
mapped[systemId] = list.map((o) => ({
|
|
547
|
+
status: o.status,
|
|
548
|
+
source: "incident",
|
|
549
|
+
reason: o.incidentTitle,
|
|
550
|
+
sourceId: o.incidentId,
|
|
551
|
+
}));
|
|
552
|
+
}
|
|
553
|
+
return mapped;
|
|
554
|
+
},
|
|
555
|
+
},
|
|
527
556
|
recomputeSystemRollupHealth: (systemId) =>
|
|
528
557
|
recomputeSystemRollupHealth({
|
|
529
558
|
systemId,
|
|
@@ -1459,3 +1459,185 @@ describe("recomputeSystemRollupHealth", () => {
|
|
|
1459
1459
|
expect(logger.error).toHaveBeenCalledTimes(1);
|
|
1460
1460
|
});
|
|
1461
1461
|
});
|
|
1462
|
+
|
|
1463
|
+
describe("executeHealthCheckJob - structured assertion outcomes", () => {
|
|
1464
|
+
it("stores _assertions per collector entry and downgrades on failure", async () => {
|
|
1465
|
+
const mockDb = createMockDb();
|
|
1466
|
+
const mockRegistry = createMockRegistry();
|
|
1467
|
+
const mockLogger = createMockLogger();
|
|
1468
|
+
const mockQueueManager = createMockQueueManager();
|
|
1469
|
+
const mockCatalogClient = createMockCatalogClient();
|
|
1470
|
+
const mockMaintenanceClient = createMockMaintenanceClient();
|
|
1471
|
+
const mockIncidentClient = createMockIncidentClient();
|
|
1472
|
+
const mockSignalService = createMockSignalService();
|
|
1473
|
+
|
|
1474
|
+
(mockCatalogClient.getSystem as any) = mock(async () => ({
|
|
1475
|
+
id: "system-1",
|
|
1476
|
+
name: "web-01",
|
|
1477
|
+
}));
|
|
1478
|
+
|
|
1479
|
+
// One collector entry with two assertions: isTrue passes, equals fails.
|
|
1480
|
+
let selectCallCount = 0;
|
|
1481
|
+
(mockDb.select as any) = mock(() => {
|
|
1482
|
+
selectCallCount++;
|
|
1483
|
+
if (selectCallCount === 2) {
|
|
1484
|
+
return {
|
|
1485
|
+
from: mock(() => ({
|
|
1486
|
+
innerJoin: mock(() => ({
|
|
1487
|
+
where: mock(() =>
|
|
1488
|
+
Promise.resolve([
|
|
1489
|
+
{
|
|
1490
|
+
configId: "config-1",
|
|
1491
|
+
configName: "checkout",
|
|
1492
|
+
strategyId: "test-strategy",
|
|
1493
|
+
config: { timeout: 5000 },
|
|
1494
|
+
collectors: [
|
|
1495
|
+
{
|
|
1496
|
+
id: "col-1",
|
|
1497
|
+
collectorId: "test-collector",
|
|
1498
|
+
config: {},
|
|
1499
|
+
assertions: [
|
|
1500
|
+
{ field: "ok", operator: "isTrue" },
|
|
1501
|
+
{ field: "code", operator: "equals", value: 200 },
|
|
1502
|
+
],
|
|
1503
|
+
},
|
|
1504
|
+
],
|
|
1505
|
+
interval: 45,
|
|
1506
|
+
enabled: true,
|
|
1507
|
+
paused: false,
|
|
1508
|
+
includeLocal: true,
|
|
1509
|
+
satelliteIds: [],
|
|
1510
|
+
},
|
|
1511
|
+
]),
|
|
1512
|
+
),
|
|
1513
|
+
})),
|
|
1514
|
+
})),
|
|
1515
|
+
};
|
|
1516
|
+
}
|
|
1517
|
+
return {
|
|
1518
|
+
from: mock(() => ({
|
|
1519
|
+
innerJoin: mock(() => ({
|
|
1520
|
+
where: mock(() => Promise.resolve([])),
|
|
1521
|
+
})),
|
|
1522
|
+
})),
|
|
1523
|
+
};
|
|
1524
|
+
});
|
|
1525
|
+
|
|
1526
|
+
// Capture every insert's values so we can find the stored run.
|
|
1527
|
+
const insertedValues: Record<string, unknown>[] = [];
|
|
1528
|
+
(mockDb.insert as any) = mock(() => ({
|
|
1529
|
+
values: mock((vals: Record<string, unknown>) => {
|
|
1530
|
+
insertedValues.push(vals);
|
|
1531
|
+
return Object.assign(Promise.resolve(), {
|
|
1532
|
+
onConflictDoUpdate: mock(() => Promise.resolve()),
|
|
1533
|
+
onConflictDoNothing: mock(() => Promise.resolve()),
|
|
1534
|
+
returning: mock(() => Promise.resolve([])),
|
|
1535
|
+
});
|
|
1536
|
+
}),
|
|
1537
|
+
}));
|
|
1538
|
+
|
|
1539
|
+
const mockCollectorRegistry = {
|
|
1540
|
+
register: mock(() => {}),
|
|
1541
|
+
getCollector: mock(() => ({
|
|
1542
|
+
collector: {
|
|
1543
|
+
id: "test-collector",
|
|
1544
|
+
execute: mock(async () => ({ result: { ok: true, code: 404 } })),
|
|
1545
|
+
config: new Versioned({ version: 1, schema: z.object({}) }),
|
|
1546
|
+
result: new Versioned({
|
|
1547
|
+
version: 1,
|
|
1548
|
+
schema: z.object({ ok: z.boolean(), code: z.number() }),
|
|
1549
|
+
}),
|
|
1550
|
+
mergeResult: mock(() => ({})),
|
|
1551
|
+
},
|
|
1552
|
+
})),
|
|
1553
|
+
getCollectors: mock(() => []),
|
|
1554
|
+
};
|
|
1555
|
+
|
|
1556
|
+
const queue =
|
|
1557
|
+
mockQueueManager.getQueue<HealthCheckJobPayload>("health-checks");
|
|
1558
|
+
let capturedHandler:
|
|
1559
|
+
| ((job: { data: HealthCheckJobPayload }) => Promise<void>)
|
|
1560
|
+
| undefined;
|
|
1561
|
+
(queue.consume as any) = mock(
|
|
1562
|
+
async (
|
|
1563
|
+
handler: (job: { data: HealthCheckJobPayload }) => Promise<void>,
|
|
1564
|
+
) => {
|
|
1565
|
+
capturedHandler = handler;
|
|
1566
|
+
},
|
|
1567
|
+
);
|
|
1568
|
+
|
|
1569
|
+
await setupHealthCheckWorker({
|
|
1570
|
+
db: mockDb as unknown as Parameters<
|
|
1571
|
+
typeof setupHealthCheckWorker
|
|
1572
|
+
>[0]["db"],
|
|
1573
|
+
advisoryLock: mockAdvisoryLock,
|
|
1574
|
+
registry: mockRegistry,
|
|
1575
|
+
collectorRegistry: mockCollectorRegistry as unknown as Parameters<
|
|
1576
|
+
typeof setupHealthCheckWorker
|
|
1577
|
+
>[0]["collectorRegistry"],
|
|
1578
|
+
logger: mockLogger,
|
|
1579
|
+
queueManager: mockQueueManager,
|
|
1580
|
+
signalService: mockSignalService,
|
|
1581
|
+
catalogClient: mockCatalogClient as unknown as Parameters<
|
|
1582
|
+
typeof setupHealthCheckWorker
|
|
1583
|
+
>[0]["catalogClient"],
|
|
1584
|
+
notificationClient: {
|
|
1585
|
+
notifyForSubscription: () => Promise.resolve({ notifiedCount: 0 }),
|
|
1586
|
+
} as unknown as Parameters<
|
|
1587
|
+
typeof setupHealthCheckWorker
|
|
1588
|
+
>[0]["notificationClient"],
|
|
1589
|
+
maintenanceClient: mockMaintenanceClient as unknown as Parameters<
|
|
1590
|
+
typeof setupHealthCheckWorker
|
|
1591
|
+
>[0]["maintenanceClient"],
|
|
1592
|
+
incidentClient: mockIncidentClient as unknown as Parameters<
|
|
1593
|
+
typeof setupHealthCheckWorker
|
|
1594
|
+
>[0]["incidentClient"],
|
|
1595
|
+
getEmitHook: () => undefined,
|
|
1596
|
+
cache: passthroughCache,
|
|
1597
|
+
});
|
|
1598
|
+
|
|
1599
|
+
if (capturedHandler) {
|
|
1600
|
+
// Downstream aggregation touches DB surfaces the lightweight mock
|
|
1601
|
+
// doesn't model; the run insert we assert on happens before that.
|
|
1602
|
+
await capturedHandler({
|
|
1603
|
+
data: { configId: "config-1", systemId: "system-1" },
|
|
1604
|
+
}).catch(() => {});
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
const runInsert = insertedValues.find(
|
|
1608
|
+
(vals) => "status" in vals && "result" in vals,
|
|
1609
|
+
);
|
|
1610
|
+
expect(runInsert).toBeDefined();
|
|
1611
|
+
// The failed `code equals 200` assertion downgrades the run.
|
|
1612
|
+
expect(runInsert?.status).toBe("unhealthy");
|
|
1613
|
+
|
|
1614
|
+
const runResult = runInsert?.result as {
|
|
1615
|
+
metadata: {
|
|
1616
|
+
collectors: Record<
|
|
1617
|
+
string,
|
|
1618
|
+
{
|
|
1619
|
+
_assertionFailed?: string;
|
|
1620
|
+
_assertions?: {
|
|
1621
|
+
field: string;
|
|
1622
|
+
passed: boolean;
|
|
1623
|
+
actual?: string;
|
|
1624
|
+
}[];
|
|
1625
|
+
}
|
|
1626
|
+
>;
|
|
1627
|
+
};
|
|
1628
|
+
};
|
|
1629
|
+
const entry = runResult.metadata.collectors["col-1"];
|
|
1630
|
+
expect(entry._assertionFailed).toBe("code equals 200");
|
|
1631
|
+
expect(entry._assertions?.length).toBe(2);
|
|
1632
|
+
expect(entry._assertions?.[0]).toMatchObject({
|
|
1633
|
+
field: "ok",
|
|
1634
|
+
passed: true,
|
|
1635
|
+
actual: "true",
|
|
1636
|
+
});
|
|
1637
|
+
expect(entry._assertions?.[1]).toMatchObject({
|
|
1638
|
+
field: "code",
|
|
1639
|
+
passed: false,
|
|
1640
|
+
actual: "404",
|
|
1641
|
+
});
|
|
1642
|
+
});
|
|
1643
|
+
});
|
package/src/queue-executor.ts
CHANGED
|
@@ -60,7 +60,8 @@ import {
|
|
|
60
60
|
shouldNotifyTransition,
|
|
61
61
|
} from "./notification-policy";
|
|
62
62
|
import { recordStateTransition } from "./state-transitions";
|
|
63
|
-
import {
|
|
63
|
+
import { evaluateCollectorAssertionOutcomes } from "./collector-assertions";
|
|
64
|
+
import type { AssertionOutcome } from "@checkstack/healthcheck-common";
|
|
64
65
|
import {
|
|
65
66
|
writeHealthEntity,
|
|
66
67
|
createHealthEntitySerializer,
|
|
@@ -947,13 +948,18 @@ async function executeHealthCheckJob(props: {
|
|
|
947
948
|
collectorError = collectorResult.error;
|
|
948
949
|
}
|
|
949
950
|
|
|
950
|
-
// Evaluate per-collector assertions (plain fields + JSONPath)
|
|
951
|
+
// Evaluate per-collector assertions (plain fields + JSONPath).
|
|
952
|
+
// ALL outcomes are stored (pass AND fail) so assertions are
|
|
953
|
+
// analyzable over time, not only visible on failure.
|
|
951
954
|
let assertionFailed: string | undefined;
|
|
955
|
+
let assertionOutcomes: AssertionOutcome[] = [];
|
|
952
956
|
if (collectorResult.result) {
|
|
953
|
-
|
|
957
|
+
const evaluation = evaluateCollectorAssertionOutcomes({
|
|
954
958
|
assertions: collectorEntry.assertions,
|
|
955
959
|
result: collectorResult.result as Record<string, unknown>,
|
|
956
960
|
});
|
|
961
|
+
assertionFailed = evaluation.firstFailureMessage;
|
|
962
|
+
assertionOutcomes = evaluation.outcomes;
|
|
957
963
|
if (assertionFailed) {
|
|
958
964
|
logger.debug(
|
|
959
965
|
`Collector ${storageKey} assertion failed: ${assertionFailed}`,
|
|
@@ -977,6 +983,9 @@ async function executeHealthCheckJob(props: {
|
|
|
977
983
|
_collectorId: collectorEntry.collectorId,
|
|
978
984
|
_assertionFailed: assertionFailed,
|
|
979
985
|
_collectorError: collectorError,
|
|
986
|
+
...(assertionOutcomes.length > 0
|
|
987
|
+
? { _assertions: assertionOutcomes }
|
|
988
|
+
: {}),
|
|
980
989
|
...strippedResult,
|
|
981
990
|
},
|
|
982
991
|
};
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
deserializeTDigest,
|
|
6
6
|
incrementHourlyAggregate,
|
|
7
7
|
} from "./realtime-aggregation";
|
|
8
|
+
import { computeAssertionKey } from "@checkstack/healthcheck-common";
|
|
8
9
|
import { TDigest } from "tdigest";
|
|
9
10
|
|
|
10
11
|
describe("getHourBucketStart", () => {
|
|
@@ -494,3 +495,138 @@ describe("incrementHourlyAggregate", () => {
|
|
|
494
495
|
expect(inserted.maxLatencyMs).toBe(150);
|
|
495
496
|
});
|
|
496
497
|
});
|
|
498
|
+
|
|
499
|
+
describe("incrementHourlyAggregate - assertion stats folding", () => {
|
|
500
|
+
const KEY = computeAssertionKey({
|
|
501
|
+
assertion: { field: "statusCode", operator: "equals", value: 200 },
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
const outcome = (passed: boolean) => ({
|
|
505
|
+
key: KEY,
|
|
506
|
+
field: "statusCode",
|
|
507
|
+
operator: "equals",
|
|
508
|
+
value: "200",
|
|
509
|
+
passed,
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
const collectorRegistry = {
|
|
513
|
+
register: mock(() => {}),
|
|
514
|
+
getCollector: mock(() => ({
|
|
515
|
+
collector: {
|
|
516
|
+
id: "test-collector",
|
|
517
|
+
mergeResult: mock(() => ({ count: 1 })),
|
|
518
|
+
},
|
|
519
|
+
})),
|
|
520
|
+
getCollectors: mock(() => []),
|
|
521
|
+
} as unknown as Parameters<
|
|
522
|
+
typeof incrementHourlyAggregate
|
|
523
|
+
>[0]["collectorRegistry"];
|
|
524
|
+
|
|
525
|
+
function runResult(outcomes: unknown[]) {
|
|
526
|
+
return {
|
|
527
|
+
metadata: {
|
|
528
|
+
collectors: {
|
|
529
|
+
"uuid-1": {
|
|
530
|
+
_collectorId: "test-collector",
|
|
531
|
+
_assertions: outcomes,
|
|
532
|
+
statusCode: 200,
|
|
533
|
+
},
|
|
534
|
+
},
|
|
535
|
+
},
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
it("folds a run's outcomes into the bucket's per-assertion counts", async () => {
|
|
540
|
+
let inserted: Record<string, unknown> | undefined;
|
|
541
|
+
const db = {
|
|
542
|
+
select: mock(() => ({
|
|
543
|
+
from: mock(() => ({
|
|
544
|
+
where: mock(() => ({
|
|
545
|
+
limit: mock(() => Promise.resolve([])),
|
|
546
|
+
})),
|
|
547
|
+
})),
|
|
548
|
+
})),
|
|
549
|
+
insert: mock(() => ({
|
|
550
|
+
values: mock((values: Record<string, unknown>) => {
|
|
551
|
+
inserted = values;
|
|
552
|
+
return { onConflictDoUpdate: mock(() => Promise.resolve()) };
|
|
553
|
+
}),
|
|
554
|
+
})),
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
await incrementHourlyAggregate({
|
|
558
|
+
db: db as never,
|
|
559
|
+
systemId: "sys-1",
|
|
560
|
+
configurationId: "config-1",
|
|
561
|
+
status: "unhealthy",
|
|
562
|
+
latencyMs: 100,
|
|
563
|
+
runTimestamp: new Date("2024-01-15T10:35:00Z"),
|
|
564
|
+
result: runResult([outcome(true), outcome(false)]) as never,
|
|
565
|
+
collectorRegistry,
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
const aggregated = inserted?.aggregatedResult as Record<string, unknown>;
|
|
569
|
+
expect(aggregated.assertions).toEqual({
|
|
570
|
+
"uuid-1": { [KEY]: { passCount: 1, failCount: 1 } },
|
|
571
|
+
});
|
|
572
|
+
// The internal _assertions never leak into collector merge output.
|
|
573
|
+
const collectors = aggregated.collectors as Record<
|
|
574
|
+
string,
|
|
575
|
+
Record<string, unknown>
|
|
576
|
+
>;
|
|
577
|
+
expect(collectors["uuid-1"]._assertions).toBeUndefined();
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it("increments existing counts and keeps distinct keys separate (mid-bucket config edit)", async () => {
|
|
581
|
+
const OTHER_KEY = computeAssertionKey({
|
|
582
|
+
assertion: { field: "statusCode", operator: "equals", value: 201 },
|
|
583
|
+
});
|
|
584
|
+
const existing = {
|
|
585
|
+
tdigestState: null,
|
|
586
|
+
minLatencyMs: null,
|
|
587
|
+
maxLatencyMs: null,
|
|
588
|
+
aggregatedResult: {
|
|
589
|
+
collectors: {},
|
|
590
|
+
assertions: { "uuid-1": { [KEY]: { passCount: 4, failCount: 0 } } },
|
|
591
|
+
},
|
|
592
|
+
};
|
|
593
|
+
let inserted: Record<string, unknown> | undefined;
|
|
594
|
+
const db = {
|
|
595
|
+
select: mock(() => ({
|
|
596
|
+
from: mock(() => ({
|
|
597
|
+
where: mock(() => ({
|
|
598
|
+
limit: mock(() => Promise.resolve([existing])),
|
|
599
|
+
})),
|
|
600
|
+
})),
|
|
601
|
+
})),
|
|
602
|
+
insert: mock(() => ({
|
|
603
|
+
values: mock((values: Record<string, unknown>) => {
|
|
604
|
+
inserted = values;
|
|
605
|
+
return { onConflictDoUpdate: mock(() => Promise.resolve()) };
|
|
606
|
+
}),
|
|
607
|
+
})),
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
// The edited assertion (value 201) starts a NEW series in the same bucket.
|
|
611
|
+
await incrementHourlyAggregate({
|
|
612
|
+
db: db as never,
|
|
613
|
+
systemId: "sys-1",
|
|
614
|
+
configurationId: "config-1",
|
|
615
|
+
status: "healthy",
|
|
616
|
+
latencyMs: 100,
|
|
617
|
+
runTimestamp: new Date("2024-01-15T10:40:00Z"),
|
|
618
|
+
result: runResult([
|
|
619
|
+
{ ...outcome(true), key: OTHER_KEY, value: "201" },
|
|
620
|
+
]) as never,
|
|
621
|
+
collectorRegistry,
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
const aggregated = inserted?.aggregatedResult as Record<string, unknown>;
|
|
625
|
+
expect(aggregated.assertions).toEqual({
|
|
626
|
+
"uuid-1": {
|
|
627
|
+
[KEY]: { passCount: 4, failCount: 0 },
|
|
628
|
+
[OTHER_KEY]: { passCount: 1, failCount: 0 },
|
|
629
|
+
},
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
});
|