@checkstack/healthcheck-backend 0.0.3 → 0.2.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,154 @@
1
1
  # @checkstack/healthcheck-backend
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8e43507: # Teams and Resource-Level Access Control
8
+
9
+ This release introduces a comprehensive Teams system for organizing users and controlling access to resources at a granular level.
10
+
11
+ ## Features
12
+
13
+ ### Team Management
14
+
15
+ - Create, update, and delete teams with name and description
16
+ - Add/remove users from teams
17
+ - Designate team managers with elevated privileges
18
+ - View team membership and manager status
19
+
20
+ ### Resource-Level Access Control
21
+
22
+ - Grant teams access to specific resources (systems, health checks, incidents, maintenances)
23
+ - Configure read-only or manage permissions per team
24
+ - Resource-level "Team Only" mode that restricts access exclusively to team members
25
+ - Separate `resourceAccessSettings` table for resource-level settings (not per-grant)
26
+ - Automatic cleanup of grants when teams are deleted (database cascade)
27
+
28
+ ### Middleware Integration
29
+
30
+ - Extended `autoAuthMiddleware` to support resource access checks
31
+ - Single-resource pre-handler validation for detail endpoints
32
+ - Automatic list filtering for collection endpoints
33
+ - S2S endpoints for access verification
34
+
35
+ ### Frontend Components
36
+
37
+ - `TeamsTab` component for managing teams in Auth Settings
38
+ - `TeamAccessEditor` component for assigning team access to resources
39
+ - Resource-level "Team Only" toggle in `TeamAccessEditor`
40
+ - Integration into System, Health Check, Incident, and Maintenance editors
41
+
42
+ ## Breaking Changes
43
+
44
+ ### API Response Format Changes
45
+
46
+ List endpoints now return objects with named keys instead of arrays directly:
47
+
48
+ ```typescript
49
+ // Before
50
+ const systems = await catalogApi.getSystems();
51
+
52
+ // After
53
+ const { systems } = await catalogApi.getSystems();
54
+ ```
55
+
56
+ Affected endpoints:
57
+
58
+ - `catalog.getSystems` → `{ systems: [...] }`
59
+ - `healthcheck.getConfigurations` → `{ configurations: [...] }`
60
+ - `incident.listIncidents` → `{ incidents: [...] }`
61
+ - `maintenance.listMaintenances` → `{ maintenances: [...] }`
62
+
63
+ ### User Identity Enrichment
64
+
65
+ `RealUser` and `ApplicationUser` types now include `teamIds: string[]` field with team memberships.
66
+
67
+ ## Documentation
68
+
69
+ See `docs/backend/teams.md` for complete API reference and integration guide.
70
+
71
+ - 97c5a6b: Add UUID-based collector identification for better multiple collector support
72
+
73
+ **Breaking Change**: Existing health check configurations with collectors need to be recreated.
74
+
75
+ - Each collector instance now has a unique UUID assigned on creation
76
+ - Collector results are stored under the UUID key with `_collectorId` and `_assertionFailed` metadata
77
+ - Auto-charts correctly display separate charts for each collector instance
78
+ - Charts are now grouped by collector instance with clear headings
79
+ - Assertion status card shows pass/fail for each collector
80
+ - Renamed "Success" to "HTTP Success" to clarify it's about HTTP request success
81
+ - Fixed deletion of collectors not persisting to database
82
+ - Fixed duplicate React key warnings in auto-chart grid
83
+
84
+ ### Patch Changes
85
+
86
+ - 97c5a6b: Fix collector lookup when health check is assigned to a system
87
+
88
+ Collectors are now stored in the registry with their fully-qualified ID format (ownerPluginId.collectorId) to match how they are referenced in health check configurations. Added `qualifiedId` field to `RegisteredCollector` interface to avoid re-constructing the ID at query time. This fixes the "Collector not found" warning that occurred when executing health checks with assigned systems.
89
+
90
+ - Updated dependencies [97c5a6b]
91
+ - Updated dependencies [8e43507]
92
+ - Updated dependencies [8e43507]
93
+ - Updated dependencies [97c5a6b]
94
+ - @checkstack/backend-api@0.2.0
95
+ - @checkstack/catalog-common@1.0.0
96
+ - @checkstack/catalog-backend@0.1.0
97
+ - @checkstack/common@0.1.0
98
+ - @checkstack/healthcheck-common@0.2.0
99
+ - @checkstack/command-backend@0.0.4
100
+ - @checkstack/integration-backend@0.0.4
101
+ - @checkstack/queue-api@0.0.4
102
+ - @checkstack/signal-common@0.0.4
103
+
104
+ ## 0.1.0
105
+
106
+ ### Minor Changes
107
+
108
+ - f5b1f49: Extended health check system with per-collector assertion support.
109
+
110
+ - Added `collectors` column to `healthCheckConfigurations` schema for storing collector configs
111
+ - Updated queue-executor to run configured collectors and evaluate per-collector assertions
112
+ - Added `CollectorAssertionSchema` to healthcheck-common for assertion validation
113
+ - Results now stored with `metadata.collectors` containing per-collector result data
114
+
115
+ ### Patch Changes
116
+
117
+ - f5b1f49: Added JSONPath assertions for response body validation and fully qualified strategy IDs.
118
+
119
+ **JSONPath Assertions:**
120
+
121
+ - Added `healthResultJSONPath()` factory in healthcheck-common for fields supporting JSONPath queries
122
+ - Extended AssertionBuilder with jsonpath field type showing path input (e.g., `$.data.status`)
123
+ - Added `jsonPath` field to `CollectorAssertionSchema` for persistence
124
+ - HTTP Request collector body field now supports JSONPath assertions
125
+
126
+ **Fully Qualified Strategy IDs:**
127
+
128
+ - HealthCheckRegistry now uses scoped factories like CollectorRegistry
129
+ - Strategies are stored with `pluginId.strategyId` format
130
+ - Added `getStrategiesWithMeta()` method to HealthCheckRegistry interface
131
+ - Router returns qualified IDs so frontend can correctly fetch collectors
132
+
133
+ **UI Improvements:**
134
+
135
+ - Save button disabled when collector configs have invalid required fields
136
+ - Fixed nested button warning in CollectorList accordion
137
+
138
+ - Updated dependencies [f5b1f49]
139
+ - Updated dependencies [f5b1f49]
140
+ - Updated dependencies [f5b1f49]
141
+ - Updated dependencies [f5b1f49]
142
+ - @checkstack/backend-api@0.1.0
143
+ - @checkstack/healthcheck-common@0.1.0
144
+ - @checkstack/common@0.0.3
145
+ - @checkstack/catalog-backend@0.0.3
146
+ - @checkstack/command-backend@0.0.3
147
+ - @checkstack/integration-backend@0.0.3
148
+ - @checkstack/queue-api@0.0.3
149
+ - @checkstack/catalog-common@0.0.3
150
+ - @checkstack/signal-common@0.0.3
151
+
3
152
  ## 0.0.3
4
153
 
5
154
  ### 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.3",
3
+ "version": "0.2.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
@@ -48,7 +61,7 @@ const createMockCatalogClient = () => ({
48
61
  notifySystemSubscribers: mock(async () => ({ notifiedCount: 0 })),
49
62
  // Other methods not used in queue-executor
50
63
  getEntities: mock(async () => ({ systems: [], groups: [] })),
51
- getSystems: mock(async () => []),
64
+ getSystems: mock(async () => ({ systems: [] })),
52
65
  getGroups: mock(async () => []),
53
66
  createSystem: mock(async () => ({})),
54
67
  updateSystem: mock(async () => ({})),
@@ -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,165 @@ 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
+ // Use the collector's UUID as the storage key
320
+ const storageKey = collectorEntry.id;
321
+
322
+ try {
323
+ const collectorResult = await registered.collector.execute({
324
+ config: collectorEntry.config,
325
+ client: connectedClient.client,
326
+ pluginId: configRow.strategyId,
327
+ });
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
+ let assertionFailed: string | undefined;
337
+ if (
338
+ collectorEntry.assertions &&
339
+ collectorEntry.assertions.length > 0 &&
340
+ collectorResult.result
341
+ ) {
342
+ const assertions = collectorEntry.assertions;
343
+ const failedAssertion = evaluateAssertions(
344
+ assertions,
345
+ collectorResult.result as Record<string, unknown>
346
+ );
347
+ if (failedAssertion) {
348
+ hasCollectorError = true;
349
+ assertionFailed = `${failedAssertion.field} ${
350
+ failedAssertion.operator
351
+ } ${failedAssertion.value ?? ""}`;
352
+ errorMessage = `Assertion failed: ${assertionFailed}`;
353
+ logger.debug(
354
+ `Collector ${storageKey} assertion failed: ${errorMessage}`
355
+ );
356
+ }
357
+ }
358
+
359
+ // Store result under the collector's UUID, with collector type and assertion metadata
360
+ collectorResults[storageKey] = {
361
+ _collectorId: collectorEntry.collectorId, // Store the type for frontend schema linking
362
+ _assertionFailed: assertionFailed, // null if no assertion failed
363
+ ...collectorResult.result,
364
+ };
365
+ } catch (error) {
366
+ hasCollectorError = true;
367
+ errorMessage = error instanceof Error ? error.message : String(error);
368
+ collectorResults[storageKey] = {
369
+ _collectorId: collectorEntry.collectorId,
370
+ _assertionFailed: undefined,
371
+ error: errorMessage,
372
+ };
373
+ logger.debug(`Collector ${storageKey} failed: ${errorMessage}`);
374
+ }
375
+ }
376
+ } finally {
377
+ // Clean up connection
378
+ try {
379
+ connectedClient.close();
380
+ } catch {
381
+ // Ignore close errors
382
+ }
383
+ }
384
+
385
+ // Determine health status based on collector results
386
+ const status = hasCollectorError ? "unhealthy" : "healthy";
387
+ const totalLatencyMs = Math.round(performance.now() - start);
388
+
389
+ const result = {
390
+ status: status as "healthy" | "unhealthy",
391
+ latencyMs: totalLatencyMs,
392
+ message: hasCollectorError
393
+ ? `Check failed: ${errorMessage}`
394
+ : `Completed in ${totalLatencyMs}ms`,
395
+ metadata: {
396
+ connected: true,
397
+ connectionTimeMs,
398
+ collectors: collectorResults,
399
+ },
400
+ };
241
401
 
242
402
  // Store result (spread to convert structured type to plain record for jsonb)
243
403
  await db.insert(healthCheckRuns).values({
@@ -412,6 +572,7 @@ async function executeHealthCheckJob(props: {
412
572
  export async function setupHealthCheckWorker(props: {
413
573
  db: Db;
414
574
  registry: HealthCheckRegistry;
575
+ collectorRegistry: CollectorRegistry;
415
576
  logger: Logger;
416
577
  queueManager: QueueManager;
417
578
  signalService: SignalService;
@@ -421,6 +582,7 @@ export async function setupHealthCheckWorker(props: {
421
582
  const {
422
583
  db,
423
584
  registry,
585
+ collectorRegistry,
424
586
  logger,
425
587
  queueManager,
426
588
  signalService,
@@ -438,6 +600,7 @@ export async function setupHealthCheckWorker(props: {
438
600
  payload: job.data,
439
601
  db,
440
602
  registry,
603
+ collectorRegistry,
441
604
  logger,
442
605
  signalService,
443
606
  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 () => {
@@ -76,6 +82,65 @@ describe("HealthCheck Router", () => {
76
82
  });
77
83
 
78
84
  const result = await call(router.getConfigurations, undefined, { context });
79
- expect(Array.isArray(result)).toBe(true);
85
+ expect(result).toHaveProperty("configurations");
86
+ expect(Array.isArray(result.configurations)).toBe(true);
87
+ });
88
+
89
+ it("getCollectors returns collectors for strategy", async () => {
90
+ const mockCollector = {
91
+ qualifiedId: "collector-hardware.cpu",
92
+ collector: {
93
+ id: "cpu",
94
+ displayName: "CPU Metrics",
95
+ description: "Collect CPU stats",
96
+ supportedPlugins: [{ pluginId: "healthcheck-ssh" }],
97
+ allowMultiple: false,
98
+ config: { version: 1, schema: z.object({}) },
99
+ result: { version: 1, schema: z.object({}) },
100
+ aggregatedResult: { version: 1, schema: z.object({}) },
101
+ },
102
+ ownerPlugin: { pluginId: "collector-hardware" },
103
+ };
104
+
105
+ const context = createMockRpcContext({
106
+ user: mockUser,
107
+ healthCheckRegistry: {
108
+ getStrategy: mock().mockReturnValue({ id: "healthcheck-ssh" }),
109
+ getStrategies: mock().mockReturnValue([]),
110
+ } as never,
111
+ collectorRegistry: {
112
+ getCollectorsForPlugin: mock().mockReturnValue([mockCollector]),
113
+ getCollector: mock(),
114
+ getCollectors: mock().mockReturnValue([]),
115
+ register: mock(),
116
+ } as never,
117
+ });
118
+
119
+ const result = await call(
120
+ router.getCollectors,
121
+ { strategyId: "healthcheck-ssh" },
122
+ { context }
123
+ );
124
+ expect(result).toHaveLength(1);
125
+ expect(result[0].id).toBe("collector-hardware.cpu");
126
+ expect(result[0].displayName).toBe("CPU Metrics");
127
+ expect(result[0].allowMultiple).toBe(false);
128
+ });
129
+
130
+ it("getCollectors returns empty for unknown strategy", async () => {
131
+ const context = createMockRpcContext({
132
+ user: mockUser,
133
+ healthCheckRegistry: {
134
+ getStrategy: mock().mockReturnValue(undefined),
135
+ getStrategies: mock().mockReturnValue([]),
136
+ } as never,
137
+ });
138
+
139
+ const result = await call(
140
+ router.getCollectors,
141
+ { strategyId: "unknown" },
142
+ { context }
143
+ );
144
+ expect(result).toHaveLength(0);
80
145
  });
81
146
  });
package/src/router.ts CHANGED
@@ -31,22 +31,55 @@ export const createHealthCheckRouter = (
31
31
 
32
32
  return os.router({
33
33
  getStrategies: os.getStrategies.handler(async ({ context }) => {
34
- return context.healthCheckRegistry.getStrategies().map((s) => ({
35
- id: s.id,
36
- displayName: s.displayName,
37
- description: s.description,
38
- configSchema: toJsonSchema(s.config.schema),
39
- resultSchema: s.result
40
- ? toJsonSchemaWithChartMeta(s.result.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
41
  : undefined,
42
42
  aggregatedResultSchema: toJsonSchemaWithChartMeta(
43
- s.aggregatedResult.schema
43
+ r.strategy.aggregatedResult.schema
44
44
  ),
45
45
  }));
46
46
  }),
47
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(({ qualifiedId, collector }) => ({
72
+ id: qualifiedId,
73
+ displayName: collector.displayName,
74
+ description: collector.description,
75
+ configSchema: toJsonSchema(collector.config.schema),
76
+ resultSchema: toJsonSchemaWithChartMeta(collector.result.schema),
77
+ allowMultiple: collector.allowMultiple ?? false,
78
+ }));
79
+ }),
80
+
48
81
  getConfigurations: os.getConfigurations.handler(async () => {
49
- return service.getConfigurations();
82
+ return { configurations: await service.getConfigurations() };
50
83
  }),
51
84
 
52
85
  createConfiguration: os.createConfiguration.handler(async ({ input }) => {
@@ -58,6 +58,8 @@ function addHealthResultMeta(
58
58
  jsonField["x-chart-label"] = healthMeta["x-chart-label"];
59
59
  if (healthMeta["x-chart-unit"])
60
60
  jsonField["x-chart-unit"] = healthMeta["x-chart-unit"];
61
+ if (healthMeta["x-jsonpath"])
62
+ jsonField["x-jsonpath"] = healthMeta["x-jsonpath"];
61
63
  }
62
64
 
63
65
  // Recurse into nested objects and arrays
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
  })
@@ -715,6 +716,7 @@ export class HealthCheckService {
715
716
  name: row.name,
716
717
  strategyId: row.strategyId,
717
718
  config: row.config,
719
+ collectors: row.collectors ?? undefined,
718
720
  intervalSeconds: row.intervalSeconds,
719
721
  createdAt: row.createdAt,
720
722
  updatedAt: row.updatedAt,