@checkstack/healthcheck-backend 1.14.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 +171 -0
- package/package.json +29 -29
- package/src/aggregation-utils.test.ts +132 -0
- package/src/aggregation-utils.ts +70 -6
- package/src/ai/healthcheck-propose.ts +9 -0
- package/src/ai/healthcheck-update.ts +5 -1
- package/src/collector-assertions.test.ts +97 -1
- package/src/collector-assertions.ts +141 -34
- package/src/config-secrets-backfill.test.ts +142 -0
- package/src/config-secrets-backfill.ts +129 -0
- package/src/config-secrets.test.ts +771 -0
- package/src/config-secrets.ts +361 -0
- package/src/healthcheck-gitops-kinds.ts +42 -31
- package/src/index.ts +47 -1
- package/src/queue-executor.test.ts +182 -0
- package/src/queue-executor.ts +64 -6
- 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-config-secrets.test.ts +165 -0
- package/src/router-create-and-assign.test.ts +5 -1
- package/src/router.ts +39 -7
- package/src/service-config-secrets.test.ts +680 -0
- package/src/service-ingest-assertions.test.ts +213 -0
- package/src/service.ts +529 -56
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,176 @@
|
|
|
1
1
|
# @checkstack/healthcheck-backend
|
|
2
2
|
|
|
3
|
+
## 1.16.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- c55d7c6: Make collector assertions analyzable: structured per-assertion outcomes on
|
|
8
|
+
every run, pass/fail counts in every aggregate tier, and dedicated analysis
|
|
9
|
+
surfaces. Previously a passing assertion left no trace and only the first
|
|
10
|
+
failure was recorded as a string.
|
|
11
|
+
|
|
12
|
+
- `@checkstack/healthcheck-common` adds the assertion-analytics contract:
|
|
13
|
+
`AssertionOutcomeSchema`, per-bucket `BucketAssertionStats` (stored under
|
|
14
|
+
the platform-owned top-level `assertions` key of `aggregatedResult`), and
|
|
15
|
+
the canonical assertion identity key (`computeAssertionKey` /
|
|
16
|
+
`parseAssertionKey`, a JSON tuple of field/jsonPath/operator/value).
|
|
17
|
+
Editing an assertion starts a new series; identical duplicates collapse.
|
|
18
|
+
- The executor evaluates ALL assertions (no first-failure short-circuit) and
|
|
19
|
+
stores `_assertions` on each collector entry alongside the unchanged
|
|
20
|
+
`_assertionFailed` compatibility string. Pass/fail counts are folded into
|
|
21
|
+
the hourly realtime aggregation, the on-read raw tier, cross-tier bucket
|
|
22
|
+
re-merges, and the daily retention rollup (assertion counts are the only
|
|
23
|
+
`aggregatedResult` content that survives the rollup - they are purely
|
|
24
|
+
additive), so assertion analytics do not silently end at the hourly
|
|
25
|
+
retention horizon.
|
|
26
|
+
- Satellite ingest now evaluates assertions on the core
|
|
27
|
+
(`ingestSatelliteResult`), downgrading a satellite-reported healthy run
|
|
28
|
+
whose assertions fail, and strips ephemeral result fields (e.g. raw HTTP
|
|
29
|
+
bodies) at ingest for parity with local runs. BEHAVIOR CHANGE:
|
|
30
|
+
satellite-executed checks previously never enforced assertions at all;
|
|
31
|
+
they now do, with no satellite upgrade or wire-protocol change. Buffered
|
|
32
|
+
satellite results are evaluated against the configuration current at
|
|
33
|
+
ingest time.
|
|
34
|
+
- The run detail gains an Assertions tab (per-collector groups, pass AND
|
|
35
|
+
fail rows with expected vs actual, a legacy fallback for pre-feature
|
|
36
|
+
runs), and the drawer's auto-chart grid leads each collector group with
|
|
37
|
+
per-assertion pass-rate tiles (sparkline of per-bucket pass rate,
|
|
38
|
+
expandable to a pass/fail StackedTimeline; currently-configured assertions
|
|
39
|
+
appear before any data exists, historical-only series are flagged).
|
|
40
|
+
|
|
41
|
+
State & scale: all new state lives in the existing `healthCheckRuns.result`
|
|
42
|
+
and `healthCheckAggregates.aggregated_result` jsonb columns (durable, shared
|
|
43
|
+
Postgres - no new tables, no pod-local state); reads resolve identically on
|
|
44
|
+
every pod; the run-vs-bucket duplication is the platform's existing
|
|
45
|
+
raw-vs-aggregate tiering with the existing single-writer upsert paths.
|
|
46
|
+
|
|
47
|
+
### Patch Changes
|
|
48
|
+
|
|
49
|
+
- Updated dependencies [c55d7c6]
|
|
50
|
+
- Updated dependencies [c55d7c6]
|
|
51
|
+
- Updated dependencies [a83bcc2]
|
|
52
|
+
- Updated dependencies [c55d7c6]
|
|
53
|
+
- @checkstack/ai-backend@0.10.7
|
|
54
|
+
- @checkstack/healthcheck-common@1.13.0
|
|
55
|
+
- @checkstack/common@0.21.0
|
|
56
|
+
- @checkstack/automation-backend@0.10.9
|
|
57
|
+
- @checkstack/catalog-backend@1.6.7
|
|
58
|
+
- @checkstack/incident-backend@1.9.5
|
|
59
|
+
- @checkstack/satellite-backend@0.8.1
|
|
60
|
+
- @checkstack/backend-api@0.29.1
|
|
61
|
+
- @checkstack/sdk@0.123.1
|
|
62
|
+
- @checkstack/ai-common@0.6.5
|
|
63
|
+
- @checkstack/cache-api@0.3.18
|
|
64
|
+
- @checkstack/catalog-common@2.6.2
|
|
65
|
+
- @checkstack/command-backend@0.2.19
|
|
66
|
+
- @checkstack/gitops-backend@0.5.19
|
|
67
|
+
- @checkstack/gitops-common@0.7.2
|
|
68
|
+
- @checkstack/incident-common@1.7.2
|
|
69
|
+
- @checkstack/maintenance-common@1.8.2
|
|
70
|
+
- @checkstack/notification-common@1.5.2
|
|
71
|
+
- @checkstack/queue-api@0.3.18
|
|
72
|
+
- @checkstack/script-packages-backend@0.3.23
|
|
73
|
+
- @checkstack/secrets-backend@0.3.1
|
|
74
|
+
- @checkstack/secrets-common@0.3.1
|
|
75
|
+
- @checkstack/signal-common@0.2.16
|
|
76
|
+
- @checkstack/status-page-backend@0.4.6
|
|
77
|
+
- @checkstack/status-page-common@0.5.2
|
|
78
|
+
- @checkstack/cache-utils@0.2.23
|
|
79
|
+
|
|
80
|
+
## 1.15.0
|
|
81
|
+
|
|
82
|
+
### Minor Changes
|
|
83
|
+
|
|
84
|
+
- faf98f5: Security: config secrets (health-check strategy/collector credentials such as
|
|
85
|
+
SSH passwords, DB credentials, HTTP auth, and integration connection
|
|
86
|
+
credentials) ride ONE shared, domain-agnostic extraction channel instead of
|
|
87
|
+
being stored as plaintext or re-implemented per plugin.
|
|
88
|
+
|
|
89
|
+
New primitive and shared service:
|
|
90
|
+
|
|
91
|
+
- `configSecret({ id })` (in `@checkstack/backend-api`) declares an
|
|
92
|
+
extraction-channel secret keyed by a STABLE `id`, independent of field name or
|
|
93
|
+
position, so renaming or reordering a field never orphans its value. Use it
|
|
94
|
+
(not `configString({ "x-secret": true })`) for any credential whose config is
|
|
95
|
+
relayed to a satellite, projected to AI, or diffed by GitOps. `validateSecretIds`
|
|
96
|
+
rejects, at plugin registration, an `x-secret` field with no `id`, a duplicate
|
|
97
|
+
`id`, or a secret nested in an un-keyable container (array / record / tuple /
|
|
98
|
+
map) - so a mis-keyable schema fails boot rather than at run time.
|
|
99
|
+
- `ConfigSecretChannel` (in `@checkstack/secrets-backend`) is the single
|
|
100
|
+
extract / inflate / collect / redact / merge / delete / prune implementation.
|
|
101
|
+
Health-checks and integration connections both BIND it to their own scope
|
|
102
|
+
(marker prefix + internal-secret key layout); neither re-implements the walk.
|
|
103
|
+
|
|
104
|
+
Lifecycle (both bindings):
|
|
105
|
+
|
|
106
|
+
- **Write**: an inline value is extracted into the encrypted internal secret
|
|
107
|
+
store; the stored config keeps only an opaque marker. `${{ secrets.NAME }}`
|
|
108
|
+
references are stored verbatim and resolve through the active backend (local
|
|
109
|
+
or Vault) at run time.
|
|
110
|
+
- **Read**: configuration and connection reads strip `x-secret` values and
|
|
111
|
+
internal markers while keeping `${{ secrets.NAME }}` references visible; the
|
|
112
|
+
AI `getConfigurations` tool and create/update responses are redacted too. A
|
|
113
|
+
value never reaches a browser or an AI model context.
|
|
114
|
+
- **Run**: the core executor inflates markers/references in memory just before
|
|
115
|
+
the client is built. Satellites receive markers only and fetch values
|
|
116
|
+
just-in-time over the authenticated WS channel, per run, never persisted, then
|
|
117
|
+
fail CLOSED if any marker/reference survives resolution.
|
|
118
|
+
- **No orphan**: clearing a secret, removing a field/collector, swapping an
|
|
119
|
+
inline value for a reference, updating a connection, or deleting a
|
|
120
|
+
configuration/connection deletes the now-unreferenced internal secret. Cleanup
|
|
121
|
+
is schema-free (scans markers by prefix) and best-effort on delete, so it works
|
|
122
|
+
even when the owning plugin is uninstalled and never blocks a delete.
|
|
123
|
+
- **Forged-marker safe**: extract/inflate key each internal secret by the
|
|
124
|
+
SCHEMA leaf's stable `id`, never by an id parsed out of a stored marker string,
|
|
125
|
+
so a crafted marker can never resolve or delete another scope's secret.
|
|
126
|
+
|
|
127
|
+
Health-checks additionally get an idempotent, advisory-locked backfill that
|
|
128
|
+
moves pre-existing plaintext values into the internal store, and per-config-id
|
|
129
|
+
locking so concurrent writers across pods can never leave a dangling marker.
|
|
130
|
+
Integration connection credentials keep their released `__connref__:` marker
|
|
131
|
+
prefix and key layout (id equals the flat field name), so existing stored
|
|
132
|
+
connections are byte-compatible.
|
|
133
|
+
|
|
134
|
+
BREAKING CHANGES:
|
|
135
|
+
|
|
136
|
+
- Configuration and connection reads no longer include `x-secret` field values
|
|
137
|
+
(clients must treat blank-on-save as keep-existing; the bundled editors
|
|
138
|
+
already do).
|
|
139
|
+
- Satellites must be upgraded together with the core: an old satellite cannot
|
|
140
|
+
resolve the markers a new core stores, so its credentialed checks fail until
|
|
141
|
+
upgraded.
|
|
142
|
+
|
|
143
|
+
### Patch Changes
|
|
144
|
+
|
|
145
|
+
- Updated dependencies [faf98f5]
|
|
146
|
+
- Updated dependencies [faf98f5]
|
|
147
|
+
- @checkstack/ai-backend@0.10.6
|
|
148
|
+
- @checkstack/backend-api@0.29.0
|
|
149
|
+
- @checkstack/secrets-backend@0.3.0
|
|
150
|
+
- @checkstack/secrets-common@0.3.0
|
|
151
|
+
- @checkstack/common@0.20.0
|
|
152
|
+
- @checkstack/healthcheck-common@1.12.0
|
|
153
|
+
- @checkstack/satellite-backend@0.8.0
|
|
154
|
+
- @checkstack/automation-backend@0.10.8
|
|
155
|
+
- @checkstack/catalog-backend@1.6.6
|
|
156
|
+
- @checkstack/incident-backend@1.9.4
|
|
157
|
+
- @checkstack/command-backend@0.2.18
|
|
158
|
+
- @checkstack/gitops-backend@0.5.18
|
|
159
|
+
- @checkstack/script-packages-backend@0.3.22
|
|
160
|
+
- @checkstack/status-page-backend@0.4.5
|
|
161
|
+
- @checkstack/gitops-common@0.7.1
|
|
162
|
+
- @checkstack/sdk@0.122.1
|
|
163
|
+
- @checkstack/ai-common@0.6.4
|
|
164
|
+
- @checkstack/cache-api@0.3.17
|
|
165
|
+
- @checkstack/catalog-common@2.6.1
|
|
166
|
+
- @checkstack/incident-common@1.7.1
|
|
167
|
+
- @checkstack/maintenance-common@1.8.1
|
|
168
|
+
- @checkstack/notification-common@1.5.1
|
|
169
|
+
- @checkstack/queue-api@0.3.17
|
|
170
|
+
- @checkstack/signal-common@0.2.15
|
|
171
|
+
- @checkstack/status-page-common@0.5.1
|
|
172
|
+
- @checkstack/cache-utils@0.2.22
|
|
173
|
+
|
|
3
174
|
## 1.14.0
|
|
4
175
|
|
|
5
176
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/healthcheck-backend",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.16.0",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -14,32 +14,32 @@
|
|
|
14
14
|
"lint:code": "eslint . --max-warnings 0"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@checkstack/ai-backend": "0.10.
|
|
18
|
-
"@checkstack/ai-common": "0.6.
|
|
19
|
-
"@checkstack/automation-backend": "0.10.
|
|
20
|
-
"@checkstack/backend-api": "0.
|
|
21
|
-
"@checkstack/cache-api": "0.3.
|
|
22
|
-
"@checkstack/cache-utils": "0.2.
|
|
23
|
-
"@checkstack/catalog-backend": "1.6.
|
|
24
|
-
"@checkstack/catalog-common": "2.6.
|
|
25
|
-
"@checkstack/command-backend": "0.2.
|
|
26
|
-
"@checkstack/common": "0.
|
|
27
|
-
"@checkstack/gitops-backend": "0.5.
|
|
28
|
-
"@checkstack/gitops-common": "0.7.
|
|
29
|
-
"@checkstack/healthcheck-common": "1.
|
|
30
|
-
"@checkstack/incident-backend": "1.9.
|
|
31
|
-
"@checkstack/incident-common": "1.7.
|
|
32
|
-
"@checkstack/maintenance-common": "1.8.
|
|
33
|
-
"@checkstack/notification-common": "1.5.
|
|
34
|
-
"@checkstack/queue-api": "0.3.
|
|
35
|
-
"@checkstack/satellite-backend": "0.
|
|
36
|
-
"@checkstack/script-packages-backend": "0.3.
|
|
37
|
-
"@checkstack/sdk": "0.
|
|
38
|
-
"@checkstack/secrets-backend": "0.
|
|
39
|
-
"@checkstack/secrets-common": "0.
|
|
40
|
-
"@checkstack/signal-common": "0.2.
|
|
41
|
-
"@checkstack/status-page-backend": "0.4.
|
|
42
|
-
"@checkstack/status-page-common": "0.5.
|
|
17
|
+
"@checkstack/ai-backend": "0.10.7",
|
|
18
|
+
"@checkstack/ai-common": "0.6.5",
|
|
19
|
+
"@checkstack/automation-backend": "0.10.9",
|
|
20
|
+
"@checkstack/backend-api": "0.29.1",
|
|
21
|
+
"@checkstack/cache-api": "0.3.18",
|
|
22
|
+
"@checkstack/cache-utils": "0.2.23",
|
|
23
|
+
"@checkstack/catalog-backend": "1.6.7",
|
|
24
|
+
"@checkstack/catalog-common": "2.6.2",
|
|
25
|
+
"@checkstack/command-backend": "0.2.19",
|
|
26
|
+
"@checkstack/common": "0.21.0",
|
|
27
|
+
"@checkstack/gitops-backend": "0.5.19",
|
|
28
|
+
"@checkstack/gitops-common": "0.7.2",
|
|
29
|
+
"@checkstack/healthcheck-common": "1.13.0",
|
|
30
|
+
"@checkstack/incident-backend": "1.9.5",
|
|
31
|
+
"@checkstack/incident-common": "1.7.2",
|
|
32
|
+
"@checkstack/maintenance-common": "1.8.2",
|
|
33
|
+
"@checkstack/notification-common": "1.5.2",
|
|
34
|
+
"@checkstack/queue-api": "0.3.18",
|
|
35
|
+
"@checkstack/satellite-backend": "0.8.1",
|
|
36
|
+
"@checkstack/script-packages-backend": "0.3.23",
|
|
37
|
+
"@checkstack/sdk": "0.123.1",
|
|
38
|
+
"@checkstack/secrets-backend": "0.3.1",
|
|
39
|
+
"@checkstack/secrets-common": "0.3.1",
|
|
40
|
+
"@checkstack/signal-common": "0.2.16",
|
|
41
|
+
"@checkstack/status-page-backend": "0.4.6",
|
|
42
|
+
"@checkstack/status-page-common": "0.5.2",
|
|
43
43
|
"@hono/zod-validator": "^0.7.6",
|
|
44
44
|
"@orpc/contract": "^1.14.4",
|
|
45
45
|
"@orpc/server": "^1.14.4",
|
|
@@ -52,8 +52,8 @@
|
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@checkstack/drizzle-helper": "0.0.6",
|
|
55
|
-
"@checkstack/scripts": "0.7.
|
|
56
|
-
"@checkstack/test-utils-backend": "0.1.
|
|
55
|
+
"@checkstack/scripts": "0.7.2",
|
|
56
|
+
"@checkstack/test-utils-backend": "0.1.53",
|
|
57
57
|
"@checkstack/tsconfig": "0.0.7",
|
|
58
58
|
"@types/bun": "^1.0.0",
|
|
59
59
|
"@types/tdigest": "^0.1.5",
|
|
@@ -4,12 +4,14 @@ import {
|
|
|
4
4
|
calculateLatencyStats,
|
|
5
5
|
countStatuses,
|
|
6
6
|
extractLatencies,
|
|
7
|
+
foldRunAssertionStats,
|
|
7
8
|
mergeTieredBuckets,
|
|
8
9
|
combineBuckets,
|
|
9
10
|
reaggregateBuckets,
|
|
10
11
|
mergeAggregatedBucketResults,
|
|
11
12
|
type NormalizedBucket,
|
|
12
13
|
} from "./aggregation-utils";
|
|
14
|
+
import { computeAssertionKey } from "@checkstack/healthcheck-common";
|
|
13
15
|
import {
|
|
14
16
|
VersionedAggregated,
|
|
15
17
|
aggregatedCounter,
|
|
@@ -982,3 +984,133 @@ describe("aggregation-utils", () => {
|
|
|
982
984
|
});
|
|
983
985
|
});
|
|
984
986
|
});
|
|
987
|
+
|
|
988
|
+
describe("assertion stats in aggregation", () => {
|
|
989
|
+
const KEY = computeAssertionKey({
|
|
990
|
+
assertion: { field: "statusCode", operator: "equals", value: 200 },
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
const outcome = (passed: boolean) => ({
|
|
994
|
+
key: KEY,
|
|
995
|
+
field: "statusCode",
|
|
996
|
+
operator: "equals",
|
|
997
|
+
value: "200",
|
|
998
|
+
passed,
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
describe("foldRunAssertionStats", () => {
|
|
1002
|
+
it("folds outcomes across a bucket's runs per collector entry", () => {
|
|
1003
|
+
const runs = [
|
|
1004
|
+
{
|
|
1005
|
+
metadata: {
|
|
1006
|
+
collectors: {
|
|
1007
|
+
"uuid-1": { _collectorId: "c", _assertions: [outcome(true)] },
|
|
1008
|
+
},
|
|
1009
|
+
},
|
|
1010
|
+
},
|
|
1011
|
+
{
|
|
1012
|
+
metadata: {
|
|
1013
|
+
collectors: {
|
|
1014
|
+
"uuid-1": { _collectorId: "c", _assertions: [outcome(false)] },
|
|
1015
|
+
},
|
|
1016
|
+
},
|
|
1017
|
+
},
|
|
1018
|
+
// Pre-feature run without outcomes is tolerated.
|
|
1019
|
+
{ metadata: { collectors: { "uuid-1": { _collectorId: "c" } } } },
|
|
1020
|
+
];
|
|
1021
|
+
expect(foldRunAssertionStats(runs)).toEqual({
|
|
1022
|
+
"uuid-1": { [KEY]: { passCount: 1, failCount: 1 } },
|
|
1023
|
+
});
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
it("returns undefined when no run carries outcomes", () => {
|
|
1027
|
+
expect(foldRunAssertionStats([{ metadata: {} }, {}])).toBeUndefined();
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
it("ignores malformed outcome entries", () => {
|
|
1031
|
+
const runs = [
|
|
1032
|
+
{
|
|
1033
|
+
metadata: {
|
|
1034
|
+
collectors: {
|
|
1035
|
+
"uuid-1": {
|
|
1036
|
+
_collectorId: "c",
|
|
1037
|
+
_assertions: ["garbage", outcome(true)],
|
|
1038
|
+
},
|
|
1039
|
+
},
|
|
1040
|
+
},
|
|
1041
|
+
},
|
|
1042
|
+
];
|
|
1043
|
+
expect(foldRunAssertionStats(runs)).toEqual({
|
|
1044
|
+
"uuid-1": { [KEY]: { passCount: 1, failCount: 0 } },
|
|
1045
|
+
});
|
|
1046
|
+
});
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
describe("mergeAggregatedBucketResults with assertion stats", () => {
|
|
1050
|
+
it("merges counts additively and keeps them out of strategy merging", () => {
|
|
1051
|
+
const mergeAggregatedStates = mock(
|
|
1052
|
+
(a: Record<string, unknown>, b: Record<string, unknown>) => ({
|
|
1053
|
+
...a,
|
|
1054
|
+
...b,
|
|
1055
|
+
}),
|
|
1056
|
+
);
|
|
1057
|
+
const registry = {
|
|
1058
|
+
getStrategy: mock(() => ({
|
|
1059
|
+
aggregatedResult: { mergeAggregatedStates },
|
|
1060
|
+
})),
|
|
1061
|
+
register: mock(() => {}),
|
|
1062
|
+
getStrategies: mock(() => []),
|
|
1063
|
+
getStrategiesWithMeta: mock(() => []),
|
|
1064
|
+
} as unknown as HealthCheckRegistry;
|
|
1065
|
+
const collectorRegistry = {
|
|
1066
|
+
register: mock(() => {}),
|
|
1067
|
+
getCollector: mock(() => undefined),
|
|
1068
|
+
getCollectors: mock(() => []),
|
|
1069
|
+
} as unknown as CollectorRegistry;
|
|
1070
|
+
|
|
1071
|
+
const merged = mergeAggregatedBucketResults({
|
|
1072
|
+
aggregatedResults: [
|
|
1073
|
+
{
|
|
1074
|
+
uptime: 1,
|
|
1075
|
+
assertions: { "uuid-1": { [KEY]: { passCount: 3, failCount: 1 } } },
|
|
1076
|
+
},
|
|
1077
|
+
{
|
|
1078
|
+
uptime: 2,
|
|
1079
|
+
assertions: { "uuid-1": { [KEY]: { passCount: 2, failCount: 0 } } },
|
|
1080
|
+
},
|
|
1081
|
+
],
|
|
1082
|
+
collectorRegistry,
|
|
1083
|
+
registry,
|
|
1084
|
+
strategyId: "test-strategy",
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
expect(merged?.assertions).toEqual({
|
|
1088
|
+
"uuid-1": { [KEY]: { passCount: 5, failCount: 1 } },
|
|
1089
|
+
});
|
|
1090
|
+
// The strategy merger only ever saw its own fields.
|
|
1091
|
+
for (const call of mergeAggregatedStates.mock.calls) {
|
|
1092
|
+
for (const arg of call) {
|
|
1093
|
+
expect(
|
|
1094
|
+
(arg as Record<string, unknown>).assertions,
|
|
1095
|
+
).toBeUndefined();
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
it("single-bucket results pass through with their stats intact", () => {
|
|
1101
|
+
const { collectorRegistry, registry, strategyId } =
|
|
1102
|
+
createMockRegistries();
|
|
1103
|
+
const only = {
|
|
1104
|
+
assertions: { "uuid-1": { [KEY]: { passCount: 7, failCount: 0 } } },
|
|
1105
|
+
};
|
|
1106
|
+
expect(
|
|
1107
|
+
mergeAggregatedBucketResults({
|
|
1108
|
+
aggregatedResults: [only],
|
|
1109
|
+
collectorRegistry,
|
|
1110
|
+
registry,
|
|
1111
|
+
strategyId,
|
|
1112
|
+
}),
|
|
1113
|
+
).toBe(only);
|
|
1114
|
+
});
|
|
1115
|
+
});
|
|
1116
|
+
});
|
package/src/aggregation-utils.ts
CHANGED
|
@@ -2,6 +2,15 @@ import type {
|
|
|
2
2
|
CollectorRegistry,
|
|
3
3
|
HealthCheckRegistry,
|
|
4
4
|
} from "@checkstack/backend-api";
|
|
5
|
+
import {
|
|
6
|
+
ASSERTIONS_AGG_KEY,
|
|
7
|
+
AssertionOutcomeSchema,
|
|
8
|
+
foldOutcomesIntoStats,
|
|
9
|
+
mergeAssertionStats,
|
|
10
|
+
readAssertionStats,
|
|
11
|
+
type AssertionOutcome,
|
|
12
|
+
type BucketAssertionStats,
|
|
13
|
+
} from "@checkstack/healthcheck-common";
|
|
5
14
|
|
|
6
15
|
// ===== Percentile Calculation =====
|
|
7
16
|
|
|
@@ -138,7 +147,8 @@ export function aggregateCollectorData(
|
|
|
138
147
|
const existing = aggregatedByUuid.get(uuid)?.aggregated;
|
|
139
148
|
|
|
140
149
|
// Strip internal fields from collector data
|
|
141
|
-
const { _collectorId, _assertionFailed, ...collectorMetadata } =
|
|
150
|
+
const { _collectorId, _assertionFailed, _assertions, ...collectorMetadata } =
|
|
151
|
+
data;
|
|
142
152
|
|
|
143
153
|
// Call mergeResult to incrementally aggregate
|
|
144
154
|
const merged = registered.collector.mergeResult(existing, {
|
|
@@ -166,6 +176,39 @@ export function aggregateCollectorData(
|
|
|
166
176
|
return result;
|
|
167
177
|
}
|
|
168
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Fold the structured assertion outcomes of a bucket's raw runs into the
|
|
181
|
+
* per-assertion pass/fail stats (the on-read raw tier's counterpart of the
|
|
182
|
+
* realtime hourly fold). Returns undefined when no run carries outcomes.
|
|
183
|
+
*/
|
|
184
|
+
export function foldRunAssertionStats(
|
|
185
|
+
runs: Array<{ metadata?: Record<string, unknown> }>,
|
|
186
|
+
): BucketAssertionStats | undefined {
|
|
187
|
+
let stats: BucketAssertionStats | undefined;
|
|
188
|
+
for (const run of runs) {
|
|
189
|
+
const collectors = run.metadata?.collectors as
|
|
190
|
+
| Record<string, Record<string, unknown>>
|
|
191
|
+
| undefined;
|
|
192
|
+
if (!collectors) continue;
|
|
193
|
+
for (const [uuid, data] of Object.entries(collectors)) {
|
|
194
|
+
const rawOutcomes = data._assertions;
|
|
195
|
+
if (!Array.isArray(rawOutcomes) || rawOutcomes.length === 0) continue;
|
|
196
|
+
const outcomes: AssertionOutcome[] = [];
|
|
197
|
+
for (const raw of rawOutcomes) {
|
|
198
|
+
const parsed = AssertionOutcomeSchema.safeParse(raw);
|
|
199
|
+
if (parsed.success) outcomes.push(parsed.data);
|
|
200
|
+
}
|
|
201
|
+
if (outcomes.length === 0) continue;
|
|
202
|
+
stats = foldOutcomesIntoStats({
|
|
203
|
+
stats,
|
|
204
|
+
collectorEntryId: uuid,
|
|
205
|
+
outcomes,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return stats;
|
|
210
|
+
}
|
|
211
|
+
|
|
169
212
|
// ===== Bucket Result Merging =====
|
|
170
213
|
|
|
171
214
|
/**
|
|
@@ -201,15 +244,29 @@ export function mergeAggregatedBucketResults(params: {
|
|
|
201
244
|
return validResults[0];
|
|
202
245
|
}
|
|
203
246
|
|
|
247
|
+
// === Platform-level assertion stats (additive, never strategy-merged) ===
|
|
248
|
+
let mergedAssertionStats: BucketAssertionStats | undefined;
|
|
249
|
+
for (const result of validResults) {
|
|
250
|
+
mergedAssertionStats = mergeAssertionStats({
|
|
251
|
+
a: mergedAssertionStats,
|
|
252
|
+
b: readAssertionStats({ aggregatedResult: result }),
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
204
256
|
// === Strategy-level field merging ===
|
|
205
257
|
let mergedStrategyFields: Record<string, unknown> = {};
|
|
206
258
|
|
|
207
259
|
const registeredStrategy = registry.getStrategy(strategyId);
|
|
208
260
|
if (registeredStrategy?.aggregatedResult) {
|
|
209
|
-
// Extract strategy-level fields (everything except
|
|
261
|
+
// Extract strategy-level fields (everything except the platform-owned
|
|
262
|
+
// 'collectors' and 'assertions' keys)
|
|
210
263
|
const strategyDataSets: Array<Record<string, unknown>> = [];
|
|
211
264
|
for (const result of validResults) {
|
|
212
|
-
const {
|
|
265
|
+
const {
|
|
266
|
+
collectors: _collectors,
|
|
267
|
+
[ASSERTIONS_AGG_KEY]: _assertions,
|
|
268
|
+
...strategyFields
|
|
269
|
+
} = result;
|
|
213
270
|
if (Object.keys(strategyFields).length > 0) {
|
|
214
271
|
strategyDataSets.push(strategyFields);
|
|
215
272
|
}
|
|
@@ -233,7 +290,11 @@ export function mergeAggregatedBucketResults(params: {
|
|
|
233
290
|
}
|
|
234
291
|
} else {
|
|
235
292
|
// Strategy not found - preserve strategy fields from first result
|
|
236
|
-
const {
|
|
293
|
+
const {
|
|
294
|
+
collectors: _collectors,
|
|
295
|
+
[ASSERTIONS_AGG_KEY]: _assertions,
|
|
296
|
+
...firstStrategyFields
|
|
297
|
+
} = validResults[0];
|
|
237
298
|
mergedStrategyFields = firstStrategyFields;
|
|
238
299
|
}
|
|
239
300
|
|
|
@@ -302,17 +363,20 @@ export function mergeAggregatedBucketResults(params: {
|
|
|
302
363
|
}
|
|
303
364
|
}
|
|
304
365
|
|
|
305
|
-
// Combine strategy fields and
|
|
366
|
+
// Combine strategy fields, collector fields, and assertion stats
|
|
306
367
|
const hasCollectors = Object.keys(mergedCollectors).length > 0;
|
|
307
368
|
const hasStrategyFields = Object.keys(mergedStrategyFields).length > 0;
|
|
308
369
|
|
|
309
|
-
if (!hasCollectors && !hasStrategyFields) {
|
|
370
|
+
if (!hasCollectors && !hasStrategyFields && mergedAssertionStats === undefined) {
|
|
310
371
|
return undefined;
|
|
311
372
|
}
|
|
312
373
|
|
|
313
374
|
return {
|
|
314
375
|
...mergedStrategyFields,
|
|
315
376
|
...(hasCollectors ? { collectors: mergedCollectors } : {}),
|
|
377
|
+
...(mergedAssertionStats === undefined
|
|
378
|
+
? {}
|
|
379
|
+
: { [ASSERTIONS_AGG_KEY]: mergedAssertionStats }),
|
|
316
380
|
};
|
|
317
381
|
}
|
|
318
382
|
|
|
@@ -103,9 +103,17 @@ const UNASSIGNED_GUIDANCE =
|
|
|
103
103
|
export async function validateHealthcheckDraft({
|
|
104
104
|
input,
|
|
105
105
|
rpcClient,
|
|
106
|
+
existingConfigurationId,
|
|
106
107
|
}: {
|
|
107
108
|
input: HealthcheckProposeInput;
|
|
108
109
|
rpcClient: RpcClient;
|
|
110
|
+
/**
|
|
111
|
+
* When validating an UPDATE, the id of the config being edited. The server
|
|
112
|
+
* restores that config's stored secrets before validating, so a kept secret
|
|
113
|
+
* (redacted out of the read) does not fail a required-secret check. Omit for
|
|
114
|
+
* a create draft, where blank secrets stay required.
|
|
115
|
+
*/
|
|
116
|
+
existingConfigurationId?: string;
|
|
109
117
|
}): Promise<{ strategy: HealthCheckStrategyDto; collectors: CollectorDto[] }> {
|
|
110
118
|
const healthcheckClient = rpcClient.forPlugin(HealthCheckApi);
|
|
111
119
|
|
|
@@ -116,6 +124,7 @@ export async function validateHealthcheckDraft({
|
|
|
116
124
|
config: input.config,
|
|
117
125
|
intervalSeconds: input.intervalSeconds,
|
|
118
126
|
collectors: input.collectors,
|
|
127
|
+
...(existingConfigurationId ? { existingConfigurationId } : {}),
|
|
119
128
|
});
|
|
120
129
|
if (!validation.valid) {
|
|
121
130
|
throw new HealthcheckProposeValidationError(
|
|
@@ -82,10 +82,14 @@ export function createHealthcheckUpdateTool(): RegisteredAiTool<
|
|
|
82
82
|
}
|
|
83
83
|
const merged = mergeConfig({ existing, body: input.body });
|
|
84
84
|
// Deep-validate the merged result (throws with structured detail if invalid,
|
|
85
|
-
// including bad assertion fields/operators).
|
|
85
|
+
// including bad assertion fields/operators). Pass the id so the server
|
|
86
|
+
// restores stored secrets before validating - `existing.config` is
|
|
87
|
+
// redacted, so a kept `x-secret` field arrives blank and would otherwise
|
|
88
|
+
// fail a required-secret check even for a name-only edit.
|
|
86
89
|
const { strategy } = await validateHealthcheckDraft({
|
|
87
90
|
input: merged,
|
|
88
91
|
rpcClient,
|
|
92
|
+
existingConfigurationId: input.id,
|
|
89
93
|
});
|
|
90
94
|
// before -> after diff over the updatable fields only (the existing config
|
|
91
95
|
// also carries id/timestamps/paused that are not part of an update).
|