@checkstack/healthcheck-backend 1.13.1 → 1.14.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 +34 -0
- package/package.json +19 -18
- package/src/collector-assertions.test.ts +276 -0
- package/src/collector-assertions.ts +169 -0
- package/src/queue-executor.ts +8 -15
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# @checkstack/healthcheck-backend
|
|
2
2
|
|
|
3
|
+
## 1.14.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- e819276: Fix JSONPath collector assertions: the executor previously evaluated every
|
|
8
|
+
assertion with a flat field lookup, so a `Body (JSONPath)` assertion compared
|
|
9
|
+
against `undefined` and the configured path was silently ignored (`Exists`
|
|
10
|
+
always failed, `Not Exists` always passed). The executor now parses the source
|
|
11
|
+
field as JSON and extracts the configured path via `jsonpath-plus` (with
|
|
12
|
+
expression evaluation disabled - filter/script expressions are rejected).
|
|
13
|
+
Fail-closed: a non-JSON body, missing expression, or invalid path fails the
|
|
14
|
+
assertion with a diagnostic, never the collection.
|
|
15
|
+
|
|
16
|
+
Also adds `isEmpty` / `isNotEmpty` to the JSONPath operator set (and the
|
|
17
|
+
AssertionBuilder), treating `[]`, `{}`, `""`, and missing values as empty - so
|
|
18
|
+
"no errors reported" is a single `$.errors Is Empty` assertion, and "key exists
|
|
19
|
+
but is empty" is `Exists` + `Is Empty` on the same path.
|
|
20
|
+
|
|
21
|
+
### Patch Changes
|
|
22
|
+
|
|
23
|
+
- Updated dependencies [e819276]
|
|
24
|
+
- Updated dependencies [e819276]
|
|
25
|
+
- @checkstack/ai-backend@0.10.5
|
|
26
|
+
- @checkstack/backend-api@0.28.0
|
|
27
|
+
- @checkstack/automation-backend@0.10.7
|
|
28
|
+
- @checkstack/catalog-backend@1.6.5
|
|
29
|
+
- @checkstack/incident-backend@1.9.3
|
|
30
|
+
- @checkstack/satellite-backend@0.7.8
|
|
31
|
+
- @checkstack/command-backend@0.2.17
|
|
32
|
+
- @checkstack/gitops-backend@0.5.17
|
|
33
|
+
- @checkstack/script-packages-backend@0.3.21
|
|
34
|
+
- @checkstack/secrets-backend@0.2.17
|
|
35
|
+
- @checkstack/status-page-backend@0.4.4
|
|
36
|
+
|
|
3
37
|
## 1.13.1
|
|
4
38
|
|
|
5
39
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/healthcheck-backend",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.0",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -14,45 +14,46 @@
|
|
|
14
14
|
"lint:code": "eslint . --max-warnings 0"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@checkstack/backend
|
|
18
|
-
"@checkstack/ai-backend": "0.10.4",
|
|
17
|
+
"@checkstack/ai-backend": "0.10.5",
|
|
19
18
|
"@checkstack/ai-common": "0.6.3",
|
|
20
|
-
"@checkstack/
|
|
19
|
+
"@checkstack/automation-backend": "0.10.7",
|
|
20
|
+
"@checkstack/backend-api": "0.28.0",
|
|
21
21
|
"@checkstack/cache-api": "0.3.16",
|
|
22
22
|
"@checkstack/cache-utils": "0.2.21",
|
|
23
|
-
"@checkstack/catalog-backend": "1.6.
|
|
23
|
+
"@checkstack/catalog-backend": "1.6.5",
|
|
24
24
|
"@checkstack/catalog-common": "2.6.0",
|
|
25
|
-
"@checkstack/command-backend": "0.2.
|
|
25
|
+
"@checkstack/command-backend": "0.2.17",
|
|
26
26
|
"@checkstack/common": "0.19.0",
|
|
27
|
-
"@checkstack/gitops-backend": "0.5.
|
|
27
|
+
"@checkstack/gitops-backend": "0.5.17",
|
|
28
28
|
"@checkstack/gitops-common": "0.7.0",
|
|
29
29
|
"@checkstack/healthcheck-common": "1.11.0",
|
|
30
|
-
"@checkstack/
|
|
31
|
-
"@checkstack/secrets-backend": "0.2.16",
|
|
32
|
-
"@checkstack/incident-backend": "1.9.2",
|
|
30
|
+
"@checkstack/incident-backend": "1.9.3",
|
|
33
31
|
"@checkstack/incident-common": "1.7.0",
|
|
34
|
-
"@checkstack/automation-backend": "0.10.6",
|
|
35
32
|
"@checkstack/maintenance-common": "1.8.0",
|
|
36
33
|
"@checkstack/notification-common": "1.5.0",
|
|
37
34
|
"@checkstack/queue-api": "0.3.16",
|
|
38
|
-
"@checkstack/satellite-backend": "0.7.
|
|
39
|
-
"@checkstack/
|
|
35
|
+
"@checkstack/satellite-backend": "0.7.8",
|
|
36
|
+
"@checkstack/script-packages-backend": "0.3.21",
|
|
37
|
+
"@checkstack/sdk": "0.121.0",
|
|
38
|
+
"@checkstack/secrets-backend": "0.2.17",
|
|
39
|
+
"@checkstack/secrets-common": "0.2.8",
|
|
40
40
|
"@checkstack/signal-common": "0.2.14",
|
|
41
|
-
"@checkstack/status-page-backend": "0.4.
|
|
41
|
+
"@checkstack/status-page-backend": "0.4.4",
|
|
42
42
|
"@checkstack/status-page-common": "0.5.0",
|
|
43
43
|
"@hono/zod-validator": "^0.7.6",
|
|
44
|
+
"@orpc/contract": "^1.14.4",
|
|
45
|
+
"@orpc/server": "^1.14.4",
|
|
44
46
|
"drizzle-orm": "^0.45.0",
|
|
45
47
|
"hono": "^4.12.25",
|
|
48
|
+
"jsonpath-plus": "^10.4.0",
|
|
46
49
|
"tdigest": "^0.1.2",
|
|
47
50
|
"yaml": "^2.6.1",
|
|
48
|
-
"zod": "^4.2.1"
|
|
49
|
-
"@orpc/contract": "^1.14.4",
|
|
50
|
-
"@orpc/server": "^1.14.4"
|
|
51
|
+
"zod": "^4.2.1"
|
|
51
52
|
},
|
|
52
53
|
"devDependencies": {
|
|
53
54
|
"@checkstack/drizzle-helper": "0.0.6",
|
|
54
55
|
"@checkstack/scripts": "0.7.0",
|
|
55
|
-
"@checkstack/test-utils-backend": "0.1.
|
|
56
|
+
"@checkstack/test-utils-backend": "0.1.51",
|
|
56
57
|
"@checkstack/tsconfig": "0.0.7",
|
|
57
58
|
"@types/bun": "^1.0.0",
|
|
58
59
|
"@types/tdigest": "^0.1.5",
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import type { CollectorAssertion } from "@checkstack/healthcheck-common";
|
|
3
|
+
import { evaluateCollectorAssertions } from "./collector-assertions";
|
|
4
|
+
|
|
5
|
+
/** The HTTP Request collector's result shape, as the executor sees it. */
|
|
6
|
+
const httpResult = (body: string): Record<string, unknown> => ({
|
|
7
|
+
statusCode: 200,
|
|
8
|
+
statusText: "OK",
|
|
9
|
+
responseTimeMs: 12,
|
|
10
|
+
body,
|
|
11
|
+
bodyLength: body.length,
|
|
12
|
+
success: true,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const jsonPathAssertion = (
|
|
16
|
+
jsonPath: string,
|
|
17
|
+
operator: string,
|
|
18
|
+
value?: unknown,
|
|
19
|
+
): CollectorAssertion => ({
|
|
20
|
+
field: "body.$",
|
|
21
|
+
jsonPath,
|
|
22
|
+
operator,
|
|
23
|
+
...(value === undefined ? {} : { value }),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("evaluateCollectorAssertions", () => {
|
|
27
|
+
describe("plain field assertions (regression)", () => {
|
|
28
|
+
it("passes and fails on a direct result field", () => {
|
|
29
|
+
const result = httpResult("{}");
|
|
30
|
+
|
|
31
|
+
expect(
|
|
32
|
+
evaluateCollectorAssertions({
|
|
33
|
+
assertions: [{ field: "statusCode", operator: "equals", value: 200 }],
|
|
34
|
+
result,
|
|
35
|
+
}),
|
|
36
|
+
).toBeUndefined();
|
|
37
|
+
|
|
38
|
+
expect(
|
|
39
|
+
evaluateCollectorAssertions({
|
|
40
|
+
assertions: [{ field: "statusCode", operator: "equals", value: 404 }],
|
|
41
|
+
result,
|
|
42
|
+
}),
|
|
43
|
+
).toBe("statusCode equals 404");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("returns undefined for no assertions", () => {
|
|
47
|
+
expect(
|
|
48
|
+
evaluateCollectorAssertions({
|
|
49
|
+
assertions: undefined,
|
|
50
|
+
result: httpResult(""),
|
|
51
|
+
}),
|
|
52
|
+
).toBeUndefined();
|
|
53
|
+
expect(
|
|
54
|
+
evaluateCollectorAssertions({ assertions: [], result: httpResult("") }),
|
|
55
|
+
).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("returns the FIRST failing assertion in configured order", () => {
|
|
59
|
+
const failure = evaluateCollectorAssertions({
|
|
60
|
+
assertions: [
|
|
61
|
+
{ field: "success", operator: "isFalse" },
|
|
62
|
+
{ field: "statusCode", operator: "equals", value: 404 },
|
|
63
|
+
],
|
|
64
|
+
result: httpResult("{}"),
|
|
65
|
+
});
|
|
66
|
+
expect(failure).toBe("success isFalse");
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("JSONPath assertions: extraction actually happens", () => {
|
|
71
|
+
it("exists fails when the key is missing and passes when present", () => {
|
|
72
|
+
expect(
|
|
73
|
+
evaluateCollectorAssertions({
|
|
74
|
+
assertions: [jsonPathAssertion("$.error", "exists")],
|
|
75
|
+
result: httpResult(`{"status":"ok"}`),
|
|
76
|
+
}),
|
|
77
|
+
).toBe("body.$ $.error exists");
|
|
78
|
+
|
|
79
|
+
expect(
|
|
80
|
+
evaluateCollectorAssertions({
|
|
81
|
+
assertions: [jsonPathAssertion("$.error", "exists")],
|
|
82
|
+
result: httpResult(`{"error":""}`),
|
|
83
|
+
}),
|
|
84
|
+
).toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("notExists passes for a missing key and fails for a present one", () => {
|
|
88
|
+
expect(
|
|
89
|
+
evaluateCollectorAssertions({
|
|
90
|
+
assertions: [jsonPathAssertion("$.error", "notExists")],
|
|
91
|
+
result: httpResult(`{"status":"ok"}`),
|
|
92
|
+
}),
|
|
93
|
+
).toBeUndefined();
|
|
94
|
+
|
|
95
|
+
expect(
|
|
96
|
+
evaluateCollectorAssertions({
|
|
97
|
+
assertions: [jsonPathAssertion("$.error", "notExists")],
|
|
98
|
+
result: httpResult(`{"error":"boom"}`),
|
|
99
|
+
}),
|
|
100
|
+
).toBe("body.$ $.error notExists");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("equals compares the EXTRACTED value, not the whole body", () => {
|
|
104
|
+
expect(
|
|
105
|
+
evaluateCollectorAssertions({
|
|
106
|
+
assertions: [jsonPathAssertion("$.data.id", "equals", "42")],
|
|
107
|
+
result: httpResult(`{"data":{"id":42},"noise":"x"}`),
|
|
108
|
+
}),
|
|
109
|
+
).toBeUndefined();
|
|
110
|
+
|
|
111
|
+
expect(
|
|
112
|
+
evaluateCollectorAssertions({
|
|
113
|
+
assertions: [jsonPathAssertion("$.data.id", "equals", "43")],
|
|
114
|
+
result: httpResult(`{"data":{"id":42}}`),
|
|
115
|
+
}),
|
|
116
|
+
).toBe("body.$ $.data.id equals 43");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('asserts "error exists but is empty" via exists + isEmpty', () => {
|
|
120
|
+
const assertions = [
|
|
121
|
+
jsonPathAssertion("$.error", "exists"),
|
|
122
|
+
jsonPathAssertion("$.error", "isEmpty"),
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
// Present and empty string: both pass.
|
|
126
|
+
expect(
|
|
127
|
+
evaluateCollectorAssertions({
|
|
128
|
+
assertions,
|
|
129
|
+
result: httpResult(`{"error":""}`),
|
|
130
|
+
}),
|
|
131
|
+
).toBeUndefined();
|
|
132
|
+
|
|
133
|
+
// Missing entirely: exists fails (isEmpty alone would have passed).
|
|
134
|
+
expect(
|
|
135
|
+
evaluateCollectorAssertions({
|
|
136
|
+
assertions,
|
|
137
|
+
result: httpResult(`{"status":"ok"}`),
|
|
138
|
+
}),
|
|
139
|
+
).toBe("body.$ $.error exists");
|
|
140
|
+
|
|
141
|
+
// Present and non-empty: isEmpty fails.
|
|
142
|
+
expect(
|
|
143
|
+
evaluateCollectorAssertions({
|
|
144
|
+
assertions,
|
|
145
|
+
result: httpResult(`{"error":"boom"}`),
|
|
146
|
+
}),
|
|
147
|
+
).toBe("body.$ $.error isEmpty");
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("empty arrays in JSON responses", () => {
|
|
152
|
+
it("isEmpty passes for [] and fails for a populated array", () => {
|
|
153
|
+
expect(
|
|
154
|
+
evaluateCollectorAssertions({
|
|
155
|
+
assertions: [jsonPathAssertion("$.errors", "isEmpty")],
|
|
156
|
+
result: httpResult(`{"errors":[]}`),
|
|
157
|
+
}),
|
|
158
|
+
).toBeUndefined();
|
|
159
|
+
|
|
160
|
+
expect(
|
|
161
|
+
evaluateCollectorAssertions({
|
|
162
|
+
assertions: [jsonPathAssertion("$.errors", "isEmpty")],
|
|
163
|
+
result: httpResult(`{"errors":["boom"]}`),
|
|
164
|
+
}),
|
|
165
|
+
).toBe("body.$ $.errors isEmpty");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("isNotEmpty passes for a populated array and fails for []", () => {
|
|
169
|
+
expect(
|
|
170
|
+
evaluateCollectorAssertions({
|
|
171
|
+
assertions: [jsonPathAssertion("$.items", "isNotEmpty")],
|
|
172
|
+
result: httpResult(`{"items":[1]}`),
|
|
173
|
+
}),
|
|
174
|
+
).toBeUndefined();
|
|
175
|
+
|
|
176
|
+
expect(
|
|
177
|
+
evaluateCollectorAssertions({
|
|
178
|
+
assertions: [jsonPathAssertion("$.items", "isNotEmpty")],
|
|
179
|
+
result: httpResult(`{"items":[]}`),
|
|
180
|
+
}),
|
|
181
|
+
).toBe("body.$ $.items isNotEmpty");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("isEmpty handles empty objects and nested paths", () => {
|
|
185
|
+
expect(
|
|
186
|
+
evaluateCollectorAssertions({
|
|
187
|
+
assertions: [jsonPathAssertion("$.meta.warnings", "isEmpty")],
|
|
188
|
+
result: httpResult(`{"meta":{"warnings":{}}}`),
|
|
189
|
+
}),
|
|
190
|
+
).toBeUndefined();
|
|
191
|
+
|
|
192
|
+
expect(
|
|
193
|
+
evaluateCollectorAssertions({
|
|
194
|
+
assertions: [jsonPathAssertion("$.meta.warnings", "isEmpty")],
|
|
195
|
+
result: httpResult(`{"meta":{"warnings":{"w1":"x"}}}`),
|
|
196
|
+
}),
|
|
197
|
+
).toBe("body.$ $.meta.warnings isEmpty");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("array length is assertable via the .length property", () => {
|
|
201
|
+
expect(
|
|
202
|
+
evaluateCollectorAssertions({
|
|
203
|
+
assertions: [
|
|
204
|
+
jsonPathAssertion("$.items.length", "greaterThan", "2"),
|
|
205
|
+
],
|
|
206
|
+
result: httpResult(`{"items":[1,2,3]}`),
|
|
207
|
+
}),
|
|
208
|
+
).toBeUndefined();
|
|
209
|
+
|
|
210
|
+
expect(
|
|
211
|
+
evaluateCollectorAssertions({
|
|
212
|
+
assertions: [
|
|
213
|
+
jsonPathAssertion("$.items.length", "greaterThan", "5"),
|
|
214
|
+
],
|
|
215
|
+
result: httpResult(`{"items":[1,2,3]}`),
|
|
216
|
+
}),
|
|
217
|
+
).toBe("body.$ $.items.length greaterThan 5");
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe("fail-closed diagnostics", () => {
|
|
222
|
+
it("fails with a diagnostic when the body is not valid JSON", () => {
|
|
223
|
+
expect(
|
|
224
|
+
evaluateCollectorAssertions({
|
|
225
|
+
assertions: [jsonPathAssertion("$.error", "notExists")],
|
|
226
|
+
result: httpResult("<html>not json</html>"),
|
|
227
|
+
}),
|
|
228
|
+
).toBe('body.$ $.error notExists (field "body" is not valid JSON)');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("fails with a diagnostic when the source field is absent", () => {
|
|
232
|
+
expect(
|
|
233
|
+
evaluateCollectorAssertions({
|
|
234
|
+
assertions: [jsonPathAssertion("$.error", "exists")],
|
|
235
|
+
result: { statusCode: 200 },
|
|
236
|
+
}),
|
|
237
|
+
).toBe('body.$ $.error exists (field "body" has no value)');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("fails with a diagnostic when the JSONPath expression is missing", () => {
|
|
241
|
+
expect(
|
|
242
|
+
evaluateCollectorAssertions({
|
|
243
|
+
assertions: [{ field: "body.$", jsonPath: " ", operator: "exists" }],
|
|
244
|
+
result: httpResult("{}"),
|
|
245
|
+
}),
|
|
246
|
+
).toBe("body.$ exists (missing JSONPath expression)");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("rejects filter/script expressions instead of evaluating them", () => {
|
|
250
|
+
const failure = evaluateCollectorAssertions({
|
|
251
|
+
assertions: [
|
|
252
|
+
jsonPathAssertion(`$.items[?(@.x==1)]`, "exists"),
|
|
253
|
+
],
|
|
254
|
+
result: httpResult(`{"items":[{"x":1}]}`),
|
|
255
|
+
});
|
|
256
|
+
expect(failure).toContain("invalid JSONPath");
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("structured (non-string) source fields", () => {
|
|
261
|
+
it("evaluates JSONPath against an already-parsed object field", () => {
|
|
262
|
+
expect(
|
|
263
|
+
evaluateCollectorAssertions({
|
|
264
|
+
assertions: [
|
|
265
|
+
{
|
|
266
|
+
field: "details.$",
|
|
267
|
+
jsonPath: "$.nodes",
|
|
268
|
+
operator: "isEmpty",
|
|
269
|
+
},
|
|
270
|
+
],
|
|
271
|
+
result: { details: { nodes: [] } },
|
|
272
|
+
}),
|
|
273
|
+
).toBeUndefined();
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import {
|
|
2
|
+
evaluateAssertions,
|
|
3
|
+
evaluateJsonPathAssertions,
|
|
4
|
+
} from "@checkstack/backend-api";
|
|
5
|
+
import type { CollectorAssertion } from "@checkstack/healthcheck-common";
|
|
6
|
+
import { extractErrorMessage } from "@checkstack/common";
|
|
7
|
+
import { JSONPath } from "jsonpath-plus";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Suffix the AssertionBuilder appends to a JSONPath field's path: an
|
|
11
|
+
* `x-jsonpath` result field `body` is offered as the assertable field
|
|
12
|
+
* `body.$`, with the actual expression stored in `assertion.jsonPath`.
|
|
13
|
+
*/
|
|
14
|
+
const JSONPATH_FIELD_SUFFIX = ".$";
|
|
15
|
+
|
|
16
|
+
/** Whether an assertion targets a JSONPath into a field, not the field itself. */
|
|
17
|
+
function isJsonPathAssertion(assertion: CollectorAssertion): boolean {
|
|
18
|
+
return (
|
|
19
|
+
assertion.field.endsWith(JSONPATH_FIELD_SUFFIX) ||
|
|
20
|
+
(typeof assertion.jsonPath === "string" && assertion.jsonPath.trim() !== "")
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Extract a JSONPath from a parsed JSON value. `wrap: false` returns the
|
|
26
|
+
* single matched value directly (or `undefined` for no match), so `exists` /
|
|
27
|
+
* `isEmpty` operate on the value itself, not on a match array. `eval: false`
|
|
28
|
+
* rejects script/filter expressions outright - assertion paths are authored
|
|
29
|
+
* by users and must never evaluate code on the core.
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* Narrow an `unknown` to the input type `JSONPath` accepts. Parsed JSON is
|
|
33
|
+
* always one of these by construction; the guard exists because the shared
|
|
34
|
+
* `evaluateJsonPathAssertions` signature hands the json through as `unknown`.
|
|
35
|
+
*/
|
|
36
|
+
function isJsonPathInput(
|
|
37
|
+
value: unknown,
|
|
38
|
+
): value is string | number | boolean | object | null {
|
|
39
|
+
return (
|
|
40
|
+
value === null ||
|
|
41
|
+
typeof value === "string" ||
|
|
42
|
+
typeof value === "number" ||
|
|
43
|
+
typeof value === "boolean" ||
|
|
44
|
+
typeof value === "object"
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function extractJsonPath(path: string, json: unknown): unknown {
|
|
49
|
+
if (!isJsonPathInput(json)) return undefined;
|
|
50
|
+
return JSONPath({ path, json, wrap: false, eval: false });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Human-readable failure string, stored on the run as `_assertionFailed`. */
|
|
54
|
+
function formatFailure({
|
|
55
|
+
assertion,
|
|
56
|
+
detail,
|
|
57
|
+
}: {
|
|
58
|
+
assertion: CollectorAssertion;
|
|
59
|
+
detail?: string;
|
|
60
|
+
}): string {
|
|
61
|
+
const path = assertion.jsonPath?.trim();
|
|
62
|
+
const parts = [
|
|
63
|
+
assertion.field,
|
|
64
|
+
...(isJsonPathAssertion(assertion) && path ? [path] : []),
|
|
65
|
+
assertion.operator,
|
|
66
|
+
...(assertion.value === undefined ? [] : [String(assertion.value)]),
|
|
67
|
+
];
|
|
68
|
+
const base = parts.join(" ");
|
|
69
|
+
return detail ? `${base} (${detail})` : base;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Evaluate a collector's assertions - plain field assertions AND JSONPath
|
|
74
|
+
* assertions - against its result, in the order they were configured.
|
|
75
|
+
*
|
|
76
|
+
* Plain assertions compare `result[field]` directly (unchanged behaviour).
|
|
77
|
+
* JSONPath assertions parse the SOURCE field (e.g. `body` for the field
|
|
78
|
+
* `body.$`) as JSON when it is a string, extract `assertion.jsonPath`, and
|
|
79
|
+
* apply the operator to the extracted value. Fail-closed: a missing
|
|
80
|
+
* expression, a non-JSON source value, or an invalid/eval-blocked path fails
|
|
81
|
+
* 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
|
+
*/
|
|
86
|
+
export function evaluateCollectorAssertions({
|
|
87
|
+
assertions,
|
|
88
|
+
result,
|
|
89
|
+
}: {
|
|
90
|
+
assertions: CollectorAssertion[] | undefined;
|
|
91
|
+
result: Record<string, unknown>;
|
|
92
|
+
}): string | undefined {
|
|
93
|
+
if (!assertions?.length) return undefined;
|
|
94
|
+
|
|
95
|
+
// Parse each JSON source field at most once, not once per assertion.
|
|
96
|
+
const parsedSources = new Map<string, { json?: unknown; error?: string }>();
|
|
97
|
+
const parseSource = (sourceField: string) => {
|
|
98
|
+
const cached = parsedSources.get(sourceField);
|
|
99
|
+
if (cached) return cached;
|
|
100
|
+
|
|
101
|
+
const raw = result[sourceField];
|
|
102
|
+
let entry: { json?: unknown; error?: string };
|
|
103
|
+
if (raw === undefined || raw === null) {
|
|
104
|
+
entry = { error: `field "${sourceField}" has no value` };
|
|
105
|
+
} else if (typeof raw === "string") {
|
|
106
|
+
try {
|
|
107
|
+
entry = { json: JSON.parse(raw) };
|
|
108
|
+
} catch {
|
|
109
|
+
entry = { error: `field "${sourceField}" is not valid JSON` };
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
// Already-structured value (object/array/number) - use as-is.
|
|
113
|
+
entry = { json: raw };
|
|
114
|
+
}
|
|
115
|
+
parsedSources.set(sourceField, entry);
|
|
116
|
+
return entry;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
for (const assertion of assertions) {
|
|
120
|
+
if (!isJsonPathAssertion(assertion)) {
|
|
121
|
+
const failed = evaluateAssertions([assertion], result);
|
|
122
|
+
if (failed) return formatFailure({ assertion: failed });
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const path = assertion.jsonPath?.trim();
|
|
127
|
+
if (!path) {
|
|
128
|
+
return formatFailure({
|
|
129
|
+
assertion,
|
|
130
|
+
detail: "missing JSONPath expression",
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const sourceField = assertion.field.endsWith(JSONPATH_FIELD_SUFFIX)
|
|
135
|
+
? assertion.field.slice(0, -JSONPATH_FIELD_SUFFIX.length)
|
|
136
|
+
: assertion.field;
|
|
137
|
+
const source = parseSource(sourceField);
|
|
138
|
+
if (source.error) {
|
|
139
|
+
return formatFailure({ assertion, detail: source.error });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const failed = evaluateJsonPathAssertions(
|
|
144
|
+
[
|
|
145
|
+
{
|
|
146
|
+
path,
|
|
147
|
+
operator: assertion.operator,
|
|
148
|
+
value:
|
|
149
|
+
assertion.value === undefined
|
|
150
|
+
? undefined
|
|
151
|
+
: String(assertion.value),
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
source.json,
|
|
155
|
+
extractJsonPath,
|
|
156
|
+
);
|
|
157
|
+
if (failed) return formatFailure({ assertion });
|
|
158
|
+
} catch (error) {
|
|
159
|
+
// jsonpath-plus rejects malformed paths and (with eval disabled)
|
|
160
|
+
// filter/script expressions by throwing.
|
|
161
|
+
return formatFailure({
|
|
162
|
+
assertion,
|
|
163
|
+
detail: `invalid JSONPath: ${extractErrorMessage(error)}`,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
package/src/queue-executor.ts
CHANGED
|
@@ -3,7 +3,6 @@ import {
|
|
|
3
3
|
Logger,
|
|
4
4
|
type EmitHookFn,
|
|
5
5
|
type CollectorRegistry,
|
|
6
|
-
evaluateAssertions,
|
|
7
6
|
type SafeDatabase,
|
|
8
7
|
type BaseStrategyConfig,
|
|
9
8
|
type ConnectedClient,
|
|
@@ -57,6 +56,7 @@ import {
|
|
|
57
56
|
shouldNotifyTransition,
|
|
58
57
|
} from "./notification-policy";
|
|
59
58
|
import { recordStateTransition } from "./state-transitions";
|
|
59
|
+
import { evaluateCollectorAssertions } from "./collector-assertions";
|
|
60
60
|
import {
|
|
61
61
|
writeHealthEntity,
|
|
62
62
|
createHealthEntitySerializer,
|
|
@@ -901,21 +901,14 @@ async function executeHealthCheckJob(props: {
|
|
|
901
901
|
collectorError = collectorResult.error;
|
|
902
902
|
}
|
|
903
903
|
|
|
904
|
-
// Evaluate per-collector assertions
|
|
904
|
+
// Evaluate per-collector assertions (plain fields + JSONPath)
|
|
905
905
|
let assertionFailed: string | undefined;
|
|
906
|
-
if (
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
collectorEntry.assertions,
|
|
913
|
-
collectorResult.result as Record<string, unknown>,
|
|
914
|
-
);
|
|
915
|
-
if (failedAssertion) {
|
|
916
|
-
assertionFailed = `${failedAssertion.field} ${
|
|
917
|
-
failedAssertion.operator
|
|
918
|
-
} ${failedAssertion.value ?? ""}`;
|
|
906
|
+
if (collectorResult.result) {
|
|
907
|
+
assertionFailed = evaluateCollectorAssertions({
|
|
908
|
+
assertions: collectorEntry.assertions,
|
|
909
|
+
result: collectorResult.result as Record<string, unknown>,
|
|
910
|
+
});
|
|
911
|
+
if (assertionFailed) {
|
|
919
912
|
logger.debug(
|
|
920
913
|
`Collector ${storageKey} assertion failed: ${assertionFailed}`,
|
|
921
914
|
);
|