@checkstack/healthcheck-backend 0.0.2 → 0.1.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 +73 -0
- package/drizzle/0006_reflective_power_pack.sql +1 -0
- package/drizzle/meta/0006_snapshot.json +407 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/index.ts +3 -0
- package/src/queue-executor.test.ts +22 -5
- package/src/queue-executor.ts +158 -4
- package/src/router.test.ts +75 -12
- package/src/router.ts +47 -8
- package/src/schema-utils.ts +85 -0
- package/src/schema.ts +6 -1
- package/src/service.ts +9 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,78 @@
|
|
|
1
1
|
# @checkstack/healthcheck-backend
|
|
2
2
|
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- f5b1f49: Extended health check system with per-collector assertion support.
|
|
8
|
+
|
|
9
|
+
- Added `collectors` column to `healthCheckConfigurations` schema for storing collector configs
|
|
10
|
+
- Updated queue-executor to run configured collectors and evaluate per-collector assertions
|
|
11
|
+
- Added `CollectorAssertionSchema` to healthcheck-common for assertion validation
|
|
12
|
+
- Results now stored with `metadata.collectors` containing per-collector result data
|
|
13
|
+
|
|
14
|
+
### Patch Changes
|
|
15
|
+
|
|
16
|
+
- f5b1f49: Added JSONPath assertions for response body validation and fully qualified strategy IDs.
|
|
17
|
+
|
|
18
|
+
**JSONPath Assertions:**
|
|
19
|
+
|
|
20
|
+
- Added `healthResultJSONPath()` factory in healthcheck-common for fields supporting JSONPath queries
|
|
21
|
+
- Extended AssertionBuilder with jsonpath field type showing path input (e.g., `$.data.status`)
|
|
22
|
+
- Added `jsonPath` field to `CollectorAssertionSchema` for persistence
|
|
23
|
+
- HTTP Request collector body field now supports JSONPath assertions
|
|
24
|
+
|
|
25
|
+
**Fully Qualified Strategy IDs:**
|
|
26
|
+
|
|
27
|
+
- HealthCheckRegistry now uses scoped factories like CollectorRegistry
|
|
28
|
+
- Strategies are stored with `pluginId.strategyId` format
|
|
29
|
+
- Added `getStrategiesWithMeta()` method to HealthCheckRegistry interface
|
|
30
|
+
- Router returns qualified IDs so frontend can correctly fetch collectors
|
|
31
|
+
|
|
32
|
+
**UI Improvements:**
|
|
33
|
+
|
|
34
|
+
- Save button disabled when collector configs have invalid required fields
|
|
35
|
+
- Fixed nested button warning in CollectorList accordion
|
|
36
|
+
|
|
37
|
+
- Updated dependencies [f5b1f49]
|
|
38
|
+
- Updated dependencies [f5b1f49]
|
|
39
|
+
- Updated dependencies [f5b1f49]
|
|
40
|
+
- Updated dependencies [f5b1f49]
|
|
41
|
+
- @checkstack/backend-api@0.1.0
|
|
42
|
+
- @checkstack/healthcheck-common@0.1.0
|
|
43
|
+
- @checkstack/common@0.0.3
|
|
44
|
+
- @checkstack/catalog-backend@0.0.3
|
|
45
|
+
- @checkstack/command-backend@0.0.3
|
|
46
|
+
- @checkstack/integration-backend@0.0.3
|
|
47
|
+
- @checkstack/queue-api@0.0.3
|
|
48
|
+
- @checkstack/catalog-common@0.0.3
|
|
49
|
+
- @checkstack/signal-common@0.0.3
|
|
50
|
+
|
|
51
|
+
## 0.0.3
|
|
52
|
+
|
|
53
|
+
### Patch Changes
|
|
54
|
+
|
|
55
|
+
- cb82e4d: Improved `counter` and `pie` auto-chart types to show frequency distributions instead of just the latest value. Both chart types now count occurrences of each unique value across all runs/buckets, making them more intuitive for visualizing data like HTTP status codes.
|
|
56
|
+
|
|
57
|
+
Changed HTTP health check chart annotations: `statusCode` now uses `pie` chart (distribution view), `contentType` now uses `counter` chart (frequency count).
|
|
58
|
+
|
|
59
|
+
Fixed scrollbar hopping when health check signals update the accordion content. All charts now update silently without layout shift or loading state flicker.
|
|
60
|
+
|
|
61
|
+
Refactored health check visualization architecture:
|
|
62
|
+
|
|
63
|
+
- `HealthCheckStatusTimeline` and `HealthCheckLatencyChart` now accept `HealthCheckDiagramSlotContext` directly, handling data transformation internally
|
|
64
|
+
- `HealthCheckDiagram` refactored to accept context from parent, ensuring all visualizations share the same data source and update together on signals
|
|
65
|
+
- `HealthCheckSystemOverview` simplified to use `useHealthCheckData` hook for consolidated data fetching with automatic signal-driven refresh
|
|
66
|
+
|
|
67
|
+
Added `silentRefetch()` method to `usePagination` hook for background data refreshes without showing loading indicators.
|
|
68
|
+
|
|
69
|
+
Fixed `useSignal` hook to use a ref pattern internally, preventing stale closure issues. Callbacks now always access the latest values without requiring manual memoization or refs in consumer components.
|
|
70
|
+
|
|
71
|
+
Added signal handling to `useHealthCheckData` hook for automatic chart refresh when health check runs complete.
|
|
72
|
+
|
|
73
|
+
- Updated dependencies [cb82e4d]
|
|
74
|
+
- @checkstack/healthcheck-common@0.0.3
|
|
75
|
+
|
|
3
76
|
## 0.0.2
|
|
4
77
|
|
|
5
78
|
### Patch Changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ALTER TABLE "health_check_configurations" ADD COLUMN "collectors" jsonb;
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "1ae9bf74-594e-4801-b49a-e1b7073d8572",
|
|
3
|
+
"prevId": "18e326cb-700a-4c6e-bc15-43335c6bb035",
|
|
4
|
+
"version": "7",
|
|
5
|
+
"dialect": "postgresql",
|
|
6
|
+
"tables": {
|
|
7
|
+
"public.health_check_aggregates": {
|
|
8
|
+
"name": "health_check_aggregates",
|
|
9
|
+
"schema": "",
|
|
10
|
+
"columns": {
|
|
11
|
+
"id": {
|
|
12
|
+
"name": "id",
|
|
13
|
+
"type": "uuid",
|
|
14
|
+
"primaryKey": true,
|
|
15
|
+
"notNull": true,
|
|
16
|
+
"default": "gen_random_uuid()"
|
|
17
|
+
},
|
|
18
|
+
"configuration_id": {
|
|
19
|
+
"name": "configuration_id",
|
|
20
|
+
"type": "uuid",
|
|
21
|
+
"primaryKey": false,
|
|
22
|
+
"notNull": true
|
|
23
|
+
},
|
|
24
|
+
"system_id": {
|
|
25
|
+
"name": "system_id",
|
|
26
|
+
"type": "text",
|
|
27
|
+
"primaryKey": false,
|
|
28
|
+
"notNull": true
|
|
29
|
+
},
|
|
30
|
+
"bucket_start": {
|
|
31
|
+
"name": "bucket_start",
|
|
32
|
+
"type": "timestamp",
|
|
33
|
+
"primaryKey": false,
|
|
34
|
+
"notNull": true
|
|
35
|
+
},
|
|
36
|
+
"bucket_size": {
|
|
37
|
+
"name": "bucket_size",
|
|
38
|
+
"type": "bucket_size",
|
|
39
|
+
"typeSchema": "public",
|
|
40
|
+
"primaryKey": false,
|
|
41
|
+
"notNull": true
|
|
42
|
+
},
|
|
43
|
+
"run_count": {
|
|
44
|
+
"name": "run_count",
|
|
45
|
+
"type": "integer",
|
|
46
|
+
"primaryKey": false,
|
|
47
|
+
"notNull": true
|
|
48
|
+
},
|
|
49
|
+
"healthy_count": {
|
|
50
|
+
"name": "healthy_count",
|
|
51
|
+
"type": "integer",
|
|
52
|
+
"primaryKey": false,
|
|
53
|
+
"notNull": true
|
|
54
|
+
},
|
|
55
|
+
"degraded_count": {
|
|
56
|
+
"name": "degraded_count",
|
|
57
|
+
"type": "integer",
|
|
58
|
+
"primaryKey": false,
|
|
59
|
+
"notNull": true
|
|
60
|
+
},
|
|
61
|
+
"unhealthy_count": {
|
|
62
|
+
"name": "unhealthy_count",
|
|
63
|
+
"type": "integer",
|
|
64
|
+
"primaryKey": false,
|
|
65
|
+
"notNull": true
|
|
66
|
+
},
|
|
67
|
+
"avg_latency_ms": {
|
|
68
|
+
"name": "avg_latency_ms",
|
|
69
|
+
"type": "integer",
|
|
70
|
+
"primaryKey": false,
|
|
71
|
+
"notNull": false
|
|
72
|
+
},
|
|
73
|
+
"min_latency_ms": {
|
|
74
|
+
"name": "min_latency_ms",
|
|
75
|
+
"type": "integer",
|
|
76
|
+
"primaryKey": false,
|
|
77
|
+
"notNull": false
|
|
78
|
+
},
|
|
79
|
+
"max_latency_ms": {
|
|
80
|
+
"name": "max_latency_ms",
|
|
81
|
+
"type": "integer",
|
|
82
|
+
"primaryKey": false,
|
|
83
|
+
"notNull": false
|
|
84
|
+
},
|
|
85
|
+
"p95_latency_ms": {
|
|
86
|
+
"name": "p95_latency_ms",
|
|
87
|
+
"type": "integer",
|
|
88
|
+
"primaryKey": false,
|
|
89
|
+
"notNull": false
|
|
90
|
+
},
|
|
91
|
+
"aggregated_result": {
|
|
92
|
+
"name": "aggregated_result",
|
|
93
|
+
"type": "jsonb",
|
|
94
|
+
"primaryKey": false,
|
|
95
|
+
"notNull": false
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
"indexes": {
|
|
99
|
+
"health_check_aggregates_bucket_unique": {
|
|
100
|
+
"name": "health_check_aggregates_bucket_unique",
|
|
101
|
+
"columns": [
|
|
102
|
+
{
|
|
103
|
+
"expression": "configuration_id",
|
|
104
|
+
"isExpression": false,
|
|
105
|
+
"asc": true,
|
|
106
|
+
"nulls": "last"
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
"expression": "system_id",
|
|
110
|
+
"isExpression": false,
|
|
111
|
+
"asc": true,
|
|
112
|
+
"nulls": "last"
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"expression": "bucket_start",
|
|
116
|
+
"isExpression": false,
|
|
117
|
+
"asc": true,
|
|
118
|
+
"nulls": "last"
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
"expression": "bucket_size",
|
|
122
|
+
"isExpression": false,
|
|
123
|
+
"asc": true,
|
|
124
|
+
"nulls": "last"
|
|
125
|
+
}
|
|
126
|
+
],
|
|
127
|
+
"isUnique": true,
|
|
128
|
+
"concurrently": false,
|
|
129
|
+
"method": "btree",
|
|
130
|
+
"with": {}
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
"foreignKeys": {
|
|
134
|
+
"health_check_aggregates_configuration_id_health_check_configurations_id_fk": {
|
|
135
|
+
"name": "health_check_aggregates_configuration_id_health_check_configurations_id_fk",
|
|
136
|
+
"tableFrom": "health_check_aggregates",
|
|
137
|
+
"tableTo": "health_check_configurations",
|
|
138
|
+
"columnsFrom": [
|
|
139
|
+
"configuration_id"
|
|
140
|
+
],
|
|
141
|
+
"columnsTo": [
|
|
142
|
+
"id"
|
|
143
|
+
],
|
|
144
|
+
"onDelete": "cascade",
|
|
145
|
+
"onUpdate": "no action"
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
"compositePrimaryKeys": {},
|
|
149
|
+
"uniqueConstraints": {},
|
|
150
|
+
"policies": {},
|
|
151
|
+
"checkConstraints": {},
|
|
152
|
+
"isRLSEnabled": false
|
|
153
|
+
},
|
|
154
|
+
"public.health_check_configurations": {
|
|
155
|
+
"name": "health_check_configurations",
|
|
156
|
+
"schema": "",
|
|
157
|
+
"columns": {
|
|
158
|
+
"id": {
|
|
159
|
+
"name": "id",
|
|
160
|
+
"type": "uuid",
|
|
161
|
+
"primaryKey": true,
|
|
162
|
+
"notNull": true,
|
|
163
|
+
"default": "gen_random_uuid()"
|
|
164
|
+
},
|
|
165
|
+
"name": {
|
|
166
|
+
"name": "name",
|
|
167
|
+
"type": "text",
|
|
168
|
+
"primaryKey": false,
|
|
169
|
+
"notNull": true
|
|
170
|
+
},
|
|
171
|
+
"strategy_id": {
|
|
172
|
+
"name": "strategy_id",
|
|
173
|
+
"type": "text",
|
|
174
|
+
"primaryKey": false,
|
|
175
|
+
"notNull": true
|
|
176
|
+
},
|
|
177
|
+
"config": {
|
|
178
|
+
"name": "config",
|
|
179
|
+
"type": "jsonb",
|
|
180
|
+
"primaryKey": false,
|
|
181
|
+
"notNull": true
|
|
182
|
+
},
|
|
183
|
+
"collectors": {
|
|
184
|
+
"name": "collectors",
|
|
185
|
+
"type": "jsonb",
|
|
186
|
+
"primaryKey": false,
|
|
187
|
+
"notNull": false
|
|
188
|
+
},
|
|
189
|
+
"interval_seconds": {
|
|
190
|
+
"name": "interval_seconds",
|
|
191
|
+
"type": "integer",
|
|
192
|
+
"primaryKey": false,
|
|
193
|
+
"notNull": true
|
|
194
|
+
},
|
|
195
|
+
"is_template": {
|
|
196
|
+
"name": "is_template",
|
|
197
|
+
"type": "boolean",
|
|
198
|
+
"primaryKey": false,
|
|
199
|
+
"notNull": false,
|
|
200
|
+
"default": false
|
|
201
|
+
},
|
|
202
|
+
"created_at": {
|
|
203
|
+
"name": "created_at",
|
|
204
|
+
"type": "timestamp",
|
|
205
|
+
"primaryKey": false,
|
|
206
|
+
"notNull": true,
|
|
207
|
+
"default": "now()"
|
|
208
|
+
},
|
|
209
|
+
"updated_at": {
|
|
210
|
+
"name": "updated_at",
|
|
211
|
+
"type": "timestamp",
|
|
212
|
+
"primaryKey": false,
|
|
213
|
+
"notNull": true,
|
|
214
|
+
"default": "now()"
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
"indexes": {},
|
|
218
|
+
"foreignKeys": {},
|
|
219
|
+
"compositePrimaryKeys": {},
|
|
220
|
+
"uniqueConstraints": {},
|
|
221
|
+
"policies": {},
|
|
222
|
+
"checkConstraints": {},
|
|
223
|
+
"isRLSEnabled": false
|
|
224
|
+
},
|
|
225
|
+
"public.health_check_runs": {
|
|
226
|
+
"name": "health_check_runs",
|
|
227
|
+
"schema": "",
|
|
228
|
+
"columns": {
|
|
229
|
+
"id": {
|
|
230
|
+
"name": "id",
|
|
231
|
+
"type": "uuid",
|
|
232
|
+
"primaryKey": true,
|
|
233
|
+
"notNull": true,
|
|
234
|
+
"default": "gen_random_uuid()"
|
|
235
|
+
},
|
|
236
|
+
"configuration_id": {
|
|
237
|
+
"name": "configuration_id",
|
|
238
|
+
"type": "uuid",
|
|
239
|
+
"primaryKey": false,
|
|
240
|
+
"notNull": true
|
|
241
|
+
},
|
|
242
|
+
"system_id": {
|
|
243
|
+
"name": "system_id",
|
|
244
|
+
"type": "text",
|
|
245
|
+
"primaryKey": false,
|
|
246
|
+
"notNull": true
|
|
247
|
+
},
|
|
248
|
+
"status": {
|
|
249
|
+
"name": "status",
|
|
250
|
+
"type": "health_check_status",
|
|
251
|
+
"typeSchema": "public",
|
|
252
|
+
"primaryKey": false,
|
|
253
|
+
"notNull": true
|
|
254
|
+
},
|
|
255
|
+
"latency_ms": {
|
|
256
|
+
"name": "latency_ms",
|
|
257
|
+
"type": "integer",
|
|
258
|
+
"primaryKey": false,
|
|
259
|
+
"notNull": false
|
|
260
|
+
},
|
|
261
|
+
"result": {
|
|
262
|
+
"name": "result",
|
|
263
|
+
"type": "jsonb",
|
|
264
|
+
"primaryKey": false,
|
|
265
|
+
"notNull": false
|
|
266
|
+
},
|
|
267
|
+
"timestamp": {
|
|
268
|
+
"name": "timestamp",
|
|
269
|
+
"type": "timestamp",
|
|
270
|
+
"primaryKey": false,
|
|
271
|
+
"notNull": true,
|
|
272
|
+
"default": "now()"
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
"indexes": {},
|
|
276
|
+
"foreignKeys": {
|
|
277
|
+
"health_check_runs_configuration_id_health_check_configurations_id_fk": {
|
|
278
|
+
"name": "health_check_runs_configuration_id_health_check_configurations_id_fk",
|
|
279
|
+
"tableFrom": "health_check_runs",
|
|
280
|
+
"tableTo": "health_check_configurations",
|
|
281
|
+
"columnsFrom": [
|
|
282
|
+
"configuration_id"
|
|
283
|
+
],
|
|
284
|
+
"columnsTo": [
|
|
285
|
+
"id"
|
|
286
|
+
],
|
|
287
|
+
"onDelete": "cascade",
|
|
288
|
+
"onUpdate": "no action"
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
"compositePrimaryKeys": {},
|
|
292
|
+
"uniqueConstraints": {},
|
|
293
|
+
"policies": {},
|
|
294
|
+
"checkConstraints": {},
|
|
295
|
+
"isRLSEnabled": false
|
|
296
|
+
},
|
|
297
|
+
"public.system_health_checks": {
|
|
298
|
+
"name": "system_health_checks",
|
|
299
|
+
"schema": "",
|
|
300
|
+
"columns": {
|
|
301
|
+
"system_id": {
|
|
302
|
+
"name": "system_id",
|
|
303
|
+
"type": "text",
|
|
304
|
+
"primaryKey": false,
|
|
305
|
+
"notNull": true
|
|
306
|
+
},
|
|
307
|
+
"configuration_id": {
|
|
308
|
+
"name": "configuration_id",
|
|
309
|
+
"type": "uuid",
|
|
310
|
+
"primaryKey": false,
|
|
311
|
+
"notNull": true
|
|
312
|
+
},
|
|
313
|
+
"enabled": {
|
|
314
|
+
"name": "enabled",
|
|
315
|
+
"type": "boolean",
|
|
316
|
+
"primaryKey": false,
|
|
317
|
+
"notNull": true,
|
|
318
|
+
"default": true
|
|
319
|
+
},
|
|
320
|
+
"state_thresholds": {
|
|
321
|
+
"name": "state_thresholds",
|
|
322
|
+
"type": "jsonb",
|
|
323
|
+
"primaryKey": false,
|
|
324
|
+
"notNull": false
|
|
325
|
+
},
|
|
326
|
+
"retention_config": {
|
|
327
|
+
"name": "retention_config",
|
|
328
|
+
"type": "jsonb",
|
|
329
|
+
"primaryKey": false,
|
|
330
|
+
"notNull": false
|
|
331
|
+
},
|
|
332
|
+
"created_at": {
|
|
333
|
+
"name": "created_at",
|
|
334
|
+
"type": "timestamp",
|
|
335
|
+
"primaryKey": false,
|
|
336
|
+
"notNull": true,
|
|
337
|
+
"default": "now()"
|
|
338
|
+
},
|
|
339
|
+
"updated_at": {
|
|
340
|
+
"name": "updated_at",
|
|
341
|
+
"type": "timestamp",
|
|
342
|
+
"primaryKey": false,
|
|
343
|
+
"notNull": true,
|
|
344
|
+
"default": "now()"
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
"indexes": {},
|
|
348
|
+
"foreignKeys": {
|
|
349
|
+
"system_health_checks_configuration_id_health_check_configurations_id_fk": {
|
|
350
|
+
"name": "system_health_checks_configuration_id_health_check_configurations_id_fk",
|
|
351
|
+
"tableFrom": "system_health_checks",
|
|
352
|
+
"tableTo": "health_check_configurations",
|
|
353
|
+
"columnsFrom": [
|
|
354
|
+
"configuration_id"
|
|
355
|
+
],
|
|
356
|
+
"columnsTo": [
|
|
357
|
+
"id"
|
|
358
|
+
],
|
|
359
|
+
"onDelete": "cascade",
|
|
360
|
+
"onUpdate": "no action"
|
|
361
|
+
}
|
|
362
|
+
},
|
|
363
|
+
"compositePrimaryKeys": {
|
|
364
|
+
"system_health_checks_system_id_configuration_id_pk": {
|
|
365
|
+
"name": "system_health_checks_system_id_configuration_id_pk",
|
|
366
|
+
"columns": [
|
|
367
|
+
"system_id",
|
|
368
|
+
"configuration_id"
|
|
369
|
+
]
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
"uniqueConstraints": {},
|
|
373
|
+
"policies": {},
|
|
374
|
+
"checkConstraints": {},
|
|
375
|
+
"isRLSEnabled": false
|
|
376
|
+
}
|
|
377
|
+
},
|
|
378
|
+
"enums": {
|
|
379
|
+
"public.bucket_size": {
|
|
380
|
+
"name": "bucket_size",
|
|
381
|
+
"schema": "public",
|
|
382
|
+
"values": [
|
|
383
|
+
"hourly",
|
|
384
|
+
"daily"
|
|
385
|
+
]
|
|
386
|
+
},
|
|
387
|
+
"public.health_check_status": {
|
|
388
|
+
"name": "health_check_status",
|
|
389
|
+
"schema": "public",
|
|
390
|
+
"values": [
|
|
391
|
+
"healthy",
|
|
392
|
+
"unhealthy",
|
|
393
|
+
"degraded"
|
|
394
|
+
]
|
|
395
|
+
}
|
|
396
|
+
},
|
|
397
|
+
"schemas": {},
|
|
398
|
+
"sequences": {},
|
|
399
|
+
"roles": {},
|
|
400
|
+
"policies": {},
|
|
401
|
+
"views": {},
|
|
402
|
+
"_meta": {
|
|
403
|
+
"columns": {},
|
|
404
|
+
"schemas": {},
|
|
405
|
+
"tables": {}
|
|
406
|
+
}
|
|
407
|
+
}
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -90,6 +90,7 @@ export default createBackendPlugin({
|
|
|
90
90
|
deps: {
|
|
91
91
|
logger: coreServices.logger,
|
|
92
92
|
healthCheckRegistry: coreServices.healthCheckRegistry,
|
|
93
|
+
collectorRegistry: coreServices.collectorRegistry,
|
|
93
94
|
rpc: coreServices.rpc,
|
|
94
95
|
rpcClient: coreServices.rpcClient,
|
|
95
96
|
queueManager: coreServices.queueManager,
|
|
@@ -100,6 +101,7 @@ export default createBackendPlugin({
|
|
|
100
101
|
logger,
|
|
101
102
|
database,
|
|
102
103
|
healthCheckRegistry,
|
|
104
|
+
collectorRegistry,
|
|
103
105
|
rpc,
|
|
104
106
|
rpcClient,
|
|
105
107
|
queueManager,
|
|
@@ -114,6 +116,7 @@ export default createBackendPlugin({
|
|
|
114
116
|
await setupHealthCheckWorker({
|
|
115
117
|
db: database,
|
|
116
118
|
registry: healthCheckRegistry,
|
|
119
|
+
collectorRegistry,
|
|
117
120
|
logger,
|
|
118
121
|
queueManager,
|
|
119
122
|
signalService,
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
} from "@checkstack/backend-api";
|
|
19
19
|
import { mock } from "bun:test";
|
|
20
20
|
|
|
21
|
-
// Helper to create mock health check registry
|
|
21
|
+
// Helper to create mock health check registry with createClient pattern
|
|
22
22
|
const createMockRegistry = (): HealthCheckRegistry => ({
|
|
23
23
|
getStrategy: mock((id: string) => ({
|
|
24
24
|
id,
|
|
@@ -28,19 +28,32 @@ const createMockRegistry = (): HealthCheckRegistry => ({
|
|
|
28
28
|
version: 1,
|
|
29
29
|
schema: z.object({}),
|
|
30
30
|
}),
|
|
31
|
+
result: new Versioned({
|
|
32
|
+
version: 1,
|
|
33
|
+
schema: z.object({}),
|
|
34
|
+
}),
|
|
31
35
|
aggregatedResult: new Versioned({
|
|
32
36
|
version: 1,
|
|
33
37
|
schema: z.object({}),
|
|
34
38
|
}),
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
+
createClient: mock(async () => ({
|
|
40
|
+
client: {
|
|
41
|
+
exec: mock(async () => ({})),
|
|
42
|
+
},
|
|
43
|
+
close: mock(() => {}),
|
|
39
44
|
})),
|
|
40
45
|
aggregateResult: mock(() => ({})),
|
|
41
46
|
})),
|
|
42
47
|
register: mock(() => {}),
|
|
43
48
|
getStrategies: mock(() => []),
|
|
49
|
+
getStrategiesWithMeta: mock(() => []),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Helper to create mock collector registry
|
|
53
|
+
const createMockCollectorRegistry = () => ({
|
|
54
|
+
register: mock(() => {}),
|
|
55
|
+
getCollector: mock(() => undefined),
|
|
56
|
+
getCollectors: mock(() => []),
|
|
44
57
|
});
|
|
45
58
|
|
|
46
59
|
// Helper to create mock catalog client for notification delegation
|
|
@@ -120,6 +133,10 @@ describe("Queue-Based Health Check Executor", () => {
|
|
|
120
133
|
typeof setupHealthCheckWorker
|
|
121
134
|
>[0]["db"],
|
|
122
135
|
registry: mockRegistry,
|
|
136
|
+
collectorRegistry:
|
|
137
|
+
createMockCollectorRegistry() as unknown as Parameters<
|
|
138
|
+
typeof setupHealthCheckWorker
|
|
139
|
+
>[0]["collectorRegistry"],
|
|
123
140
|
logger: mockLogger,
|
|
124
141
|
queueManager: mockQueueManager,
|
|
125
142
|
signalService: createMockSignalService(),
|
package/src/queue-executor.ts
CHANGED
|
@@ -2,6 +2,8 @@ import {
|
|
|
2
2
|
HealthCheckRegistry,
|
|
3
3
|
Logger,
|
|
4
4
|
type EmitHookFn,
|
|
5
|
+
type CollectorRegistry,
|
|
6
|
+
evaluateAssertions,
|
|
5
7
|
} from "@checkstack/backend-api";
|
|
6
8
|
import { QueueManager } from "@checkstack/queue-api";
|
|
7
9
|
import {
|
|
@@ -159,6 +161,7 @@ async function executeHealthCheckJob(props: {
|
|
|
159
161
|
payload: HealthCheckJobPayload;
|
|
160
162
|
db: Db;
|
|
161
163
|
registry: HealthCheckRegistry;
|
|
164
|
+
collectorRegistry: CollectorRegistry;
|
|
162
165
|
logger: Logger;
|
|
163
166
|
signalService: SignalService;
|
|
164
167
|
catalogClient: CatalogClient;
|
|
@@ -168,6 +171,7 @@ async function executeHealthCheckJob(props: {
|
|
|
168
171
|
payload,
|
|
169
172
|
db,
|
|
170
173
|
registry,
|
|
174
|
+
collectorRegistry,
|
|
171
175
|
logger,
|
|
172
176
|
signalService,
|
|
173
177
|
catalogClient,
|
|
@@ -190,6 +194,7 @@ async function executeHealthCheckJob(props: {
|
|
|
190
194
|
configName: healthCheckConfigurations.name,
|
|
191
195
|
strategyId: healthCheckConfigurations.strategyId,
|
|
192
196
|
config: healthCheckConfigurations.config,
|
|
197
|
+
collectors: healthCheckConfigurations.collectors,
|
|
193
198
|
interval: healthCheckConfigurations.intervalSeconds,
|
|
194
199
|
enabled: systemHealthChecks.enabled,
|
|
195
200
|
})
|
|
@@ -234,10 +239,156 @@ async function executeHealthCheckJob(props: {
|
|
|
234
239
|
return;
|
|
235
240
|
}
|
|
236
241
|
|
|
237
|
-
// Execute health check
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
242
|
+
// Execute health check using createClient pattern
|
|
243
|
+
const start = performance.now();
|
|
244
|
+
let connectedClient;
|
|
245
|
+
try {
|
|
246
|
+
connectedClient = await strategy.createClient(
|
|
247
|
+
configRow.config as Record<string, unknown>
|
|
248
|
+
);
|
|
249
|
+
} catch (error) {
|
|
250
|
+
// Connection failed
|
|
251
|
+
const latencyMs = Math.round(performance.now() - start);
|
|
252
|
+
const errorMessage =
|
|
253
|
+
error instanceof Error ? error.message : "Connection failed";
|
|
254
|
+
|
|
255
|
+
const result = {
|
|
256
|
+
status: "unhealthy" as const,
|
|
257
|
+
latencyMs,
|
|
258
|
+
message: errorMessage,
|
|
259
|
+
metadata: { connected: false, error: errorMessage },
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
await db.insert(healthCheckRuns).values({
|
|
263
|
+
configurationId: configId,
|
|
264
|
+
systemId,
|
|
265
|
+
status: result.status,
|
|
266
|
+
latencyMs: result.latencyMs,
|
|
267
|
+
result: { ...result } as Record<string, unknown>,
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
logger.debug(
|
|
271
|
+
`Health check ${configId} for system ${systemId} failed: ${errorMessage}`
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
// Broadcast failure signal
|
|
275
|
+
await signalService.broadcast(HEALTH_CHECK_RUN_COMPLETED, {
|
|
276
|
+
systemId,
|
|
277
|
+
systemName,
|
|
278
|
+
configurationId: configId,
|
|
279
|
+
configurationName: configRow.configName,
|
|
280
|
+
status: result.status,
|
|
281
|
+
latencyMs: result.latencyMs,
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// Check and notify state change
|
|
285
|
+
const newState = await service.getSystemHealthStatus(systemId);
|
|
286
|
+
if (newState.status !== previousStatus) {
|
|
287
|
+
await notifyStateChange({
|
|
288
|
+
systemId,
|
|
289
|
+
previousStatus,
|
|
290
|
+
newStatus: newState.status,
|
|
291
|
+
catalogClient,
|
|
292
|
+
logger,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const connectionTimeMs = Math.round(performance.now() - start);
|
|
300
|
+
|
|
301
|
+
// Execute collectors
|
|
302
|
+
const collectors = configRow.collectors ?? [];
|
|
303
|
+
const collectorResults: Record<string, unknown> = {};
|
|
304
|
+
let hasCollectorError = false;
|
|
305
|
+
let errorMessage: string | undefined;
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
for (const collectorEntry of collectors) {
|
|
309
|
+
const registered = collectorRegistry.getCollector(
|
|
310
|
+
collectorEntry.collectorId
|
|
311
|
+
);
|
|
312
|
+
if (!registered) {
|
|
313
|
+
logger.warn(
|
|
314
|
+
`Collector ${collectorEntry.collectorId} not found, skipping`
|
|
315
|
+
);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
const collectorResult = await registered.collector.execute({
|
|
321
|
+
config: collectorEntry.config,
|
|
322
|
+
client: connectedClient.client,
|
|
323
|
+
pluginId: configRow.strategyId,
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// Store result under collector ID
|
|
327
|
+
collectorResults[collectorEntry.collectorId] = collectorResult.result;
|
|
328
|
+
|
|
329
|
+
// Check for collector-level error
|
|
330
|
+
if (collectorResult.error) {
|
|
331
|
+
hasCollectorError = true;
|
|
332
|
+
errorMessage = collectorResult.error;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Evaluate per-collector assertions
|
|
336
|
+
if (
|
|
337
|
+
collectorEntry.assertions &&
|
|
338
|
+
collectorEntry.assertions.length > 0 &&
|
|
339
|
+
collectorResult.result
|
|
340
|
+
) {
|
|
341
|
+
const assertions = collectorEntry.assertions;
|
|
342
|
+
const failedAssertion = evaluateAssertions(
|
|
343
|
+
assertions,
|
|
344
|
+
collectorResult.result as Record<string, unknown>
|
|
345
|
+
);
|
|
346
|
+
if (failedAssertion) {
|
|
347
|
+
hasCollectorError = true;
|
|
348
|
+
errorMessage = `Assertion failed: ${failedAssertion.field} ${
|
|
349
|
+
failedAssertion.operator
|
|
350
|
+
} ${failedAssertion.value ?? ""}`;
|
|
351
|
+
logger.debug(
|
|
352
|
+
`Collector ${collectorEntry.collectorId} assertion failed: ${errorMessage}`
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
} catch (error) {
|
|
357
|
+
hasCollectorError = true;
|
|
358
|
+
errorMessage = error instanceof Error ? error.message : String(error);
|
|
359
|
+
collectorResults[collectorEntry.collectorId] = {
|
|
360
|
+
error: errorMessage,
|
|
361
|
+
};
|
|
362
|
+
logger.debug(
|
|
363
|
+
`Collector ${collectorEntry.collectorId} failed: ${errorMessage}`
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
} finally {
|
|
368
|
+
// Clean up connection
|
|
369
|
+
try {
|
|
370
|
+
connectedClient.close();
|
|
371
|
+
} catch {
|
|
372
|
+
// Ignore close errors
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Determine health status based on collector results
|
|
377
|
+
const status = hasCollectorError ? "unhealthy" : "healthy";
|
|
378
|
+
const totalLatencyMs = Math.round(performance.now() - start);
|
|
379
|
+
|
|
380
|
+
const result = {
|
|
381
|
+
status: status as "healthy" | "unhealthy",
|
|
382
|
+
latencyMs: totalLatencyMs,
|
|
383
|
+
message: hasCollectorError
|
|
384
|
+
? `Check failed: ${errorMessage}`
|
|
385
|
+
: `Completed in ${totalLatencyMs}ms`,
|
|
386
|
+
metadata: {
|
|
387
|
+
connected: true,
|
|
388
|
+
connectionTimeMs,
|
|
389
|
+
collectors: collectorResults,
|
|
390
|
+
},
|
|
391
|
+
};
|
|
241
392
|
|
|
242
393
|
// Store result (spread to convert structured type to plain record for jsonb)
|
|
243
394
|
await db.insert(healthCheckRuns).values({
|
|
@@ -412,6 +563,7 @@ async function executeHealthCheckJob(props: {
|
|
|
412
563
|
export async function setupHealthCheckWorker(props: {
|
|
413
564
|
db: Db;
|
|
414
565
|
registry: HealthCheckRegistry;
|
|
566
|
+
collectorRegistry: CollectorRegistry;
|
|
415
567
|
logger: Logger;
|
|
416
568
|
queueManager: QueueManager;
|
|
417
569
|
signalService: SignalService;
|
|
@@ -421,6 +573,7 @@ export async function setupHealthCheckWorker(props: {
|
|
|
421
573
|
const {
|
|
422
574
|
db,
|
|
423
575
|
registry,
|
|
576
|
+
collectorRegistry,
|
|
424
577
|
logger,
|
|
425
578
|
queueManager,
|
|
426
579
|
signalService,
|
|
@@ -438,6 +591,7 @@ export async function setupHealthCheckWorker(props: {
|
|
|
438
591
|
payload: job.data,
|
|
439
592
|
db,
|
|
440
593
|
registry,
|
|
594
|
+
collectorRegistry,
|
|
441
595
|
logger,
|
|
442
596
|
signalService,
|
|
443
597
|
catalogClient,
|
package/src/router.test.ts
CHANGED
|
@@ -40,6 +40,7 @@ describe("HealthCheck Router", () => {
|
|
|
40
40
|
register: mock(),
|
|
41
41
|
getStrategy: mock(),
|
|
42
42
|
getStrategies: mock(() => []),
|
|
43
|
+
getStrategiesWithMeta: mock(() => []),
|
|
43
44
|
};
|
|
44
45
|
|
|
45
46
|
const router = createHealthCheckRouter(mockDb as never, mockRegistry);
|
|
@@ -48,26 +49,31 @@ describe("HealthCheck Router", () => {
|
|
|
48
49
|
const context = createMockRpcContext({
|
|
49
50
|
user: mockUser,
|
|
50
51
|
healthCheckRegistry: {
|
|
51
|
-
|
|
52
|
+
getStrategiesWithMeta: mock().mockReturnValue([
|
|
52
53
|
{
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
54
|
+
strategy: {
|
|
55
|
+
id: "http",
|
|
56
|
+
displayName: "HTTP",
|
|
57
|
+
description: "Check HTTP",
|
|
58
|
+
config: {
|
|
59
|
+
version: 1,
|
|
60
|
+
schema: z.object({}),
|
|
61
|
+
},
|
|
62
|
+
aggregatedResult: {
|
|
63
|
+
schema: z.object({}),
|
|
64
|
+
},
|
|
62
65
|
},
|
|
66
|
+
qualifiedId: "healthcheck-http.http",
|
|
67
|
+
ownerPluginId: "healthcheck-http",
|
|
63
68
|
},
|
|
64
69
|
]),
|
|
65
|
-
|
|
70
|
+
getStrategies: mock().mockReturnValue([]),
|
|
71
|
+
} as never,
|
|
66
72
|
});
|
|
67
73
|
|
|
68
74
|
const result = await call(router.getStrategies, undefined, { context });
|
|
69
75
|
expect(result).toHaveLength(1);
|
|
70
|
-
expect(result[0].id).toBe("http");
|
|
76
|
+
expect(result[0].id).toBe("healthcheck-http.http");
|
|
71
77
|
});
|
|
72
78
|
|
|
73
79
|
it("getConfigurations calls service", async () => {
|
|
@@ -78,4 +84,61 @@ describe("HealthCheck Router", () => {
|
|
|
78
84
|
const result = await call(router.getConfigurations, undefined, { context });
|
|
79
85
|
expect(Array.isArray(result)).toBe(true);
|
|
80
86
|
});
|
|
87
|
+
|
|
88
|
+
it("getCollectors returns collectors for strategy", async () => {
|
|
89
|
+
const mockCollector = {
|
|
90
|
+
collector: {
|
|
91
|
+
id: "cpu",
|
|
92
|
+
displayName: "CPU Metrics",
|
|
93
|
+
description: "Collect CPU stats",
|
|
94
|
+
supportedPlugins: [{ pluginId: "healthcheck-ssh" }],
|
|
95
|
+
allowMultiple: false,
|
|
96
|
+
config: { version: 1, schema: z.object({}) },
|
|
97
|
+
result: { version: 1, schema: z.object({}) },
|
|
98
|
+
aggregatedResult: { version: 1, schema: z.object({}) },
|
|
99
|
+
},
|
|
100
|
+
ownerPlugin: { pluginId: "collector-hardware" },
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const context = createMockRpcContext({
|
|
104
|
+
user: mockUser,
|
|
105
|
+
healthCheckRegistry: {
|
|
106
|
+
getStrategy: mock().mockReturnValue({ id: "healthcheck-ssh" }),
|
|
107
|
+
getStrategies: mock().mockReturnValue([]),
|
|
108
|
+
} as never,
|
|
109
|
+
collectorRegistry: {
|
|
110
|
+
getCollectorsForPlugin: mock().mockReturnValue([mockCollector]),
|
|
111
|
+
getCollector: mock(),
|
|
112
|
+
getCollectors: mock().mockReturnValue([]),
|
|
113
|
+
register: mock(),
|
|
114
|
+
} as never,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const result = await call(
|
|
118
|
+
router.getCollectors,
|
|
119
|
+
{ strategyId: "healthcheck-ssh" },
|
|
120
|
+
{ context }
|
|
121
|
+
);
|
|
122
|
+
expect(result).toHaveLength(1);
|
|
123
|
+
expect(result[0].id).toBe("collector-hardware.cpu");
|
|
124
|
+
expect(result[0].displayName).toBe("CPU Metrics");
|
|
125
|
+
expect(result[0].allowMultiple).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("getCollectors returns empty for unknown strategy", async () => {
|
|
129
|
+
const context = createMockRpcContext({
|
|
130
|
+
user: mockUser,
|
|
131
|
+
healthCheckRegistry: {
|
|
132
|
+
getStrategy: mock().mockReturnValue(undefined),
|
|
133
|
+
getStrategies: mock().mockReturnValue([]),
|
|
134
|
+
} as never,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const result = await call(
|
|
138
|
+
router.getCollectors,
|
|
139
|
+
{ strategyId: "unknown" },
|
|
140
|
+
{ context }
|
|
141
|
+
);
|
|
142
|
+
expect(result).toHaveLength(0);
|
|
143
|
+
});
|
|
81
144
|
});
|
package/src/router.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { implement, ORPCError } from "@orpc/server";
|
|
2
2
|
import {
|
|
3
3
|
autoAuthMiddleware,
|
|
4
|
-
|
|
4
|
+
toJsonSchema,
|
|
5
5
|
type RpcContext,
|
|
6
6
|
type HealthCheckRegistry,
|
|
7
7
|
} from "@checkstack/backend-api";
|
|
@@ -9,6 +9,7 @@ import { healthCheckContract } from "@checkstack/healthcheck-common";
|
|
|
9
9
|
import { HealthCheckService } from "./service";
|
|
10
10
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
11
11
|
import * as schema from "./schema";
|
|
12
|
+
import { toJsonSchemaWithChartMeta } from "./schema-utils";
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Creates the healthcheck router using contract-based implementation.
|
|
@@ -30,13 +31,51 @@ export const createHealthCheckRouter = (
|
|
|
30
31
|
|
|
31
32
|
return os.router({
|
|
32
33
|
getStrategies: os.getStrategies.handler(async ({ context }) => {
|
|
33
|
-
return context.healthCheckRegistry.
|
|
34
|
-
id:
|
|
35
|
-
displayName:
|
|
36
|
-
description:
|
|
37
|
-
configSchema:
|
|
38
|
-
resultSchema:
|
|
39
|
-
|
|
34
|
+
return context.healthCheckRegistry.getStrategiesWithMeta().map((r) => ({
|
|
35
|
+
id: r.qualifiedId, // Return fully qualified ID
|
|
36
|
+
displayName: r.strategy.displayName,
|
|
37
|
+
description: r.strategy.description,
|
|
38
|
+
configSchema: toJsonSchema(r.strategy.config.schema),
|
|
39
|
+
resultSchema: r.strategy.result
|
|
40
|
+
? toJsonSchemaWithChartMeta(r.strategy.result.schema)
|
|
41
|
+
: undefined,
|
|
42
|
+
aggregatedResultSchema: toJsonSchemaWithChartMeta(
|
|
43
|
+
r.strategy.aggregatedResult.schema
|
|
44
|
+
),
|
|
45
|
+
}));
|
|
46
|
+
}),
|
|
47
|
+
|
|
48
|
+
getCollectors: os.getCollectors.handler(async ({ input, context }) => {
|
|
49
|
+
// Get strategy to verify it exists
|
|
50
|
+
const strategy = context.healthCheckRegistry.getStrategy(
|
|
51
|
+
input.strategyId
|
|
52
|
+
);
|
|
53
|
+
if (!strategy) {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Strategy ID is fully qualified: pluginId.strategyId
|
|
58
|
+
// Extract the plugin ID (everything before the last dot)
|
|
59
|
+
const lastDotIndex = input.strategyId.lastIndexOf(".");
|
|
60
|
+
const pluginId =
|
|
61
|
+
lastDotIndex > 0
|
|
62
|
+
? input.strategyId.slice(0, lastDotIndex)
|
|
63
|
+
: input.strategyId;
|
|
64
|
+
|
|
65
|
+
// Get collectors that support this strategy's plugin
|
|
66
|
+
const registeredCollectors =
|
|
67
|
+
context.collectorRegistry.getCollectorsForPlugin({
|
|
68
|
+
pluginId,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
return registeredCollectors.map(({ collector, ownerPlugin }) => ({
|
|
72
|
+
// Fully-qualified ID: ownerPluginId.collectorId
|
|
73
|
+
id: `${ownerPlugin.pluginId}.${collector.id}`,
|
|
74
|
+
displayName: collector.displayName,
|
|
75
|
+
description: collector.description,
|
|
76
|
+
configSchema: toJsonSchema(collector.config.schema),
|
|
77
|
+
resultSchema: toJsonSchemaWithChartMeta(collector.result.schema),
|
|
78
|
+
allowMultiple: collector.allowMultiple ?? false,
|
|
40
79
|
}));
|
|
41
80
|
}),
|
|
42
81
|
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema utilities for health check JSON Schema conversion.
|
|
3
|
+
*
|
|
4
|
+
* Extends the base toJsonSchema to also include chart metadata from the
|
|
5
|
+
* healthResultRegistry for auto-chart rendering.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { z, toJsonSchema } from "@checkstack/backend-api";
|
|
9
|
+
import { getHealthResultMeta } from "@checkstack/healthcheck-common";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Adds health result chart metadata to JSON Schema properties.
|
|
13
|
+
* Recursively processes nested objects and arrays.
|
|
14
|
+
*/
|
|
15
|
+
function addHealthResultMeta(
|
|
16
|
+
zodSchema: z.ZodTypeAny,
|
|
17
|
+
jsonSchema: Record<string, unknown>
|
|
18
|
+
): void {
|
|
19
|
+
// Handle arrays - recurse into items
|
|
20
|
+
if (zodSchema instanceof z.ZodArray) {
|
|
21
|
+
const itemsSchema = (zodSchema as z.ZodArray<z.ZodTypeAny>).element;
|
|
22
|
+
const jsonItems = jsonSchema.items as Record<string, unknown> | undefined;
|
|
23
|
+
if (jsonItems) {
|
|
24
|
+
addHealthResultMeta(itemsSchema, jsonItems);
|
|
25
|
+
}
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Handle optional - unwrap and recurse
|
|
30
|
+
if (zodSchema instanceof z.ZodOptional) {
|
|
31
|
+
const innerSchema = zodSchema.unwrap() as z.ZodTypeAny;
|
|
32
|
+
addHealthResultMeta(innerSchema, jsonSchema);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Type guard to check if this is an object schema
|
|
37
|
+
if (!("shape" in zodSchema)) return;
|
|
38
|
+
|
|
39
|
+
const objectSchema = zodSchema as z.ZodObject<z.ZodRawShape>;
|
|
40
|
+
const properties = jsonSchema.properties as
|
|
41
|
+
| Record<string, Record<string, unknown>>
|
|
42
|
+
| undefined;
|
|
43
|
+
|
|
44
|
+
if (!properties) return;
|
|
45
|
+
|
|
46
|
+
for (const [key, fieldSchema] of Object.entries(objectSchema.shape)) {
|
|
47
|
+
const zodField = fieldSchema as z.ZodTypeAny;
|
|
48
|
+
const jsonField = properties[key];
|
|
49
|
+
|
|
50
|
+
if (!jsonField) continue;
|
|
51
|
+
|
|
52
|
+
// Get health result metadata from registry (x-chart-type, etc.)
|
|
53
|
+
const healthMeta = getHealthResultMeta(zodField);
|
|
54
|
+
if (healthMeta) {
|
|
55
|
+
if (healthMeta["x-chart-type"])
|
|
56
|
+
jsonField["x-chart-type"] = healthMeta["x-chart-type"];
|
|
57
|
+
if (healthMeta["x-chart-label"])
|
|
58
|
+
jsonField["x-chart-label"] = healthMeta["x-chart-label"];
|
|
59
|
+
if (healthMeta["x-chart-unit"])
|
|
60
|
+
jsonField["x-chart-unit"] = healthMeta["x-chart-unit"];
|
|
61
|
+
if (healthMeta["x-jsonpath"])
|
|
62
|
+
jsonField["x-jsonpath"] = healthMeta["x-jsonpath"];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Recurse into nested objects and arrays
|
|
66
|
+
addHealthResultMeta(zodField, jsonField);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Converts a Zod schema to JSON Schema with chart metadata.
|
|
72
|
+
*
|
|
73
|
+
* This extends the base toJsonSchema to also include health check
|
|
74
|
+
* chart metadata (x-chart-type, x-chart-label, x-chart-unit) from
|
|
75
|
+
* the healthResultRegistry for auto-chart rendering.
|
|
76
|
+
*/
|
|
77
|
+
export function toJsonSchemaWithChartMeta(
|
|
78
|
+
zodSchema: z.ZodTypeAny
|
|
79
|
+
): Record<string, unknown> {
|
|
80
|
+
// Use the base toJsonSchema which handles config metadata
|
|
81
|
+
const jsonSchema = toJsonSchema(zodSchema);
|
|
82
|
+
// Add health result chart metadata
|
|
83
|
+
addHealthResultMeta(zodSchema, jsonSchema);
|
|
84
|
+
return jsonSchema;
|
|
85
|
+
}
|
package/src/schema.ts
CHANGED
|
@@ -10,7 +10,10 @@ import {
|
|
|
10
10
|
primaryKey,
|
|
11
11
|
uniqueIndex,
|
|
12
12
|
} from "drizzle-orm/pg-core";
|
|
13
|
-
import type {
|
|
13
|
+
import type {
|
|
14
|
+
StateThresholds,
|
|
15
|
+
CollectorConfigEntry,
|
|
16
|
+
} from "@checkstack/healthcheck-common";
|
|
14
17
|
import type { VersionedRecord } from "@checkstack/backend-api";
|
|
15
18
|
|
|
16
19
|
/**
|
|
@@ -38,6 +41,8 @@ export const healthCheckConfigurations = pgTable(
|
|
|
38
41
|
name: text("name").notNull(),
|
|
39
42
|
strategyId: text("strategy_id").notNull(),
|
|
40
43
|
config: jsonb("config").$type<Record<string, unknown>>().notNull(),
|
|
44
|
+
/** Collector configurations for this health check */
|
|
45
|
+
collectors: jsonb("collectors").$type<CollectorConfigEntry[]>(),
|
|
41
46
|
intervalSeconds: integer("interval_seconds").notNull(),
|
|
42
47
|
isTemplate: boolean("is_template").default(false),
|
|
43
48
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
package/src/service.ts
CHANGED
|
@@ -49,6 +49,7 @@ export class HealthCheckService {
|
|
|
49
49
|
name: data.name,
|
|
50
50
|
strategyId: data.strategyId,
|
|
51
51
|
config: data.config,
|
|
52
|
+
collectors: data.collectors ?? undefined,
|
|
52
53
|
intervalSeconds: data.intervalSeconds,
|
|
53
54
|
isTemplate: false, // Defaulting for now
|
|
54
55
|
})
|
|
@@ -614,10 +615,15 @@ export class HealthCheckService {
|
|
|
614
615
|
if (!bucketMap.has(key)) {
|
|
615
616
|
bucketMap.set(key, { bucketStart, runs: [] });
|
|
616
617
|
}
|
|
618
|
+
// run.result is StoredHealthCheckResult: { status, latencyMs, message, metadata }
|
|
619
|
+
// Strategy's aggregateResult expects metadata to be the strategy-specific fields
|
|
620
|
+
const storedResult = run.result as {
|
|
621
|
+
metadata?: Record<string, unknown>;
|
|
622
|
+
} | null;
|
|
617
623
|
bucketMap.get(key)!.runs.push({
|
|
618
624
|
status: run.status,
|
|
619
625
|
latencyMs: run.latencyMs ?? undefined,
|
|
620
|
-
metadata:
|
|
626
|
+
metadata: storedResult?.metadata ?? undefined,
|
|
621
627
|
});
|
|
622
628
|
}
|
|
623
629
|
|
|
@@ -637,7 +643,7 @@ export class HealthCheckService {
|
|
|
637
643
|
|
|
638
644
|
const latencies = bucket.runs
|
|
639
645
|
.map((r) => r.latencyMs)
|
|
640
|
-
.filter((l): l is number => l
|
|
646
|
+
.filter((l): l is number => typeof l === "number");
|
|
641
647
|
const avgLatencyMs =
|
|
642
648
|
latencies.length > 0
|
|
643
649
|
? Math.round(latencies.reduce((a, b) => a + b, 0) / latencies.length)
|
|
@@ -710,6 +716,7 @@ export class HealthCheckService {
|
|
|
710
716
|
name: row.name,
|
|
711
717
|
strategyId: row.strategyId,
|
|
712
718
|
config: row.config,
|
|
719
|
+
collectors: row.collectors ?? undefined,
|
|
713
720
|
intervalSeconds: row.intervalSeconds,
|
|
714
721
|
createdAt: row.createdAt,
|
|
715
722
|
updatedAt: row.updatedAt,
|