@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 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
+ }
@@ -43,6 +43,13 @@
43
43
  "when": 1767538124052,
44
44
  "tag": "0005_glossy_longshot",
45
45
  "breakpoints": true
46
+ },
47
+ {
48
+ "idx": 6,
49
+ "version": "7",
50
+ "when": 1768012161285,
51
+ "tag": "0006_reflective_power_pack",
52
+ "breakpoints": true
46
53
  }
47
54
  ]
48
55
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-backend",
3
- "version": "0.0.2",
3
+ "version": "0.1.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "scripts": {
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
- execute: mock(async () => ({
36
- status: "healthy" as const,
37
- message: "Mock check passed",
38
- timestamp: new Date().toISOString(),
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(),
@@ -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 result = await strategy.execute(
239
- configRow.config as Record<string, unknown>
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,
@@ -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
- getStrategies: mock().mockReturnValue([
52
+ getStrategiesWithMeta: mock().mockReturnValue([
52
53
  {
53
- id: "http",
54
- displayName: "HTTP",
55
- description: "Check HTTP",
56
- config: {
57
- version: 1,
58
- schema: z.object({}),
59
- },
60
- aggregatedResult: {
61
- schema: z.object({}),
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
- } as any,
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
- zod,
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.getStrategies().map((s) => ({
34
- id: s.id,
35
- displayName: s.displayName,
36
- description: s.description,
37
- configSchema: zod.toJSONSchema(s.config.schema),
38
- resultSchema: s.result ? zod.toJSONSchema(s.result.schema) : undefined,
39
- aggregatedResultSchema: zod.toJSONSchema(s.aggregatedResult.schema),
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 { StateThresholds } from "@checkstack/healthcheck-common";
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: run.result ?? undefined,
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 !== null);
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,