@checkstack/healthcheck-backend 0.0.2

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.
@@ -0,0 +1,373 @@
1
+ import { describe, it, expect, mock, beforeEach } from "bun:test";
2
+ import { HealthCheckService } from "./service";
3
+
4
+ describe("HealthCheckService.getAggregatedHistory", () => {
5
+ // Mock database and registry
6
+ let mockDb: ReturnType<typeof createMockDb>;
7
+ let mockRegistry: ReturnType<typeof createMockRegistry>;
8
+ let service: HealthCheckService;
9
+
10
+ function createMockDb() {
11
+ return {
12
+ select: mock(() => ({
13
+ from: mock(() => ({
14
+ where: mock(() => ({
15
+ orderBy: mock(() => Promise.resolve([])),
16
+ })),
17
+ })),
18
+ })),
19
+ query: {
20
+ healthCheckConfigurations: {
21
+ findFirst: mock(() => Promise.resolve(null)) as ReturnType<
22
+ typeof mock<
23
+ () => Promise<{ id: string; strategyId: string } | null>
24
+ >
25
+ >,
26
+ },
27
+ },
28
+ };
29
+ }
30
+
31
+ function createMockRegistry() {
32
+ return {
33
+ register: mock(),
34
+ getStrategies: mock(() => []),
35
+ getStrategy: mock(() => ({
36
+ id: "http",
37
+ displayName: "HTTP",
38
+ config: { version: 1, schema: {} },
39
+ aggregatedResult: { version: 1, schema: {} },
40
+ execute: mock(),
41
+ aggregateResult: mock((runs: unknown[]) => ({
42
+ totalRuns: runs.length,
43
+ customMetric: "aggregated",
44
+ })),
45
+ })),
46
+ };
47
+ }
48
+
49
+ beforeEach(() => {
50
+ mockDb = createMockDb();
51
+ mockRegistry = createMockRegistry();
52
+ service = new HealthCheckService(mockDb as never, mockRegistry as never);
53
+ });
54
+
55
+ describe("bucket size selection", () => {
56
+ it("auto-selects hourly for ranges <= 7 days", async () => {
57
+ const startDate = new Date("2024-01-01T00:00:00Z");
58
+ const endDate = new Date("2024-01-07T00:00:00Z");
59
+
60
+ const result = await service.getAggregatedHistory(
61
+ {
62
+ systemId: "sys-1",
63
+ configurationId: "config-1",
64
+ startDate,
65
+ endDate,
66
+ bucketSize: "auto",
67
+ },
68
+ { includeAggregatedResult: true }
69
+ );
70
+
71
+ expect(result.buckets).toEqual([]);
72
+ });
73
+
74
+ it("auto-selects daily for ranges > 7 days", async () => {
75
+ const startDate = new Date("2024-01-01T00:00:00Z");
76
+ const endDate = new Date("2024-01-15T00:00:00Z");
77
+
78
+ const result = await service.getAggregatedHistory(
79
+ {
80
+ systemId: "sys-1",
81
+ configurationId: "config-1",
82
+ startDate,
83
+ endDate,
84
+ bucketSize: "auto",
85
+ },
86
+ { includeAggregatedResult: true }
87
+ );
88
+
89
+ expect(result.buckets).toEqual([]);
90
+ });
91
+ });
92
+
93
+ describe("bucketing and metrics calculation", () => {
94
+ it("groups runs into hourly buckets and calculates metrics", async () => {
95
+ const runs = [
96
+ {
97
+ id: "run-1",
98
+ systemId: "sys-1",
99
+ configurationId: "config-1",
100
+ status: "healthy" as const,
101
+ latencyMs: 100,
102
+ result: { statusCode: 200 },
103
+ timestamp: new Date("2024-01-01T10:15:00Z"),
104
+ },
105
+ {
106
+ id: "run-2",
107
+ systemId: "sys-1",
108
+ configurationId: "config-1",
109
+ status: "healthy" as const,
110
+ latencyMs: 150,
111
+ result: { statusCode: 200 },
112
+ timestamp: new Date("2024-01-01T10:30:00Z"),
113
+ },
114
+ {
115
+ id: "run-3",
116
+ systemId: "sys-1",
117
+ configurationId: "config-1",
118
+ status: "unhealthy" as const,
119
+ latencyMs: 300,
120
+ result: { statusCode: 500 },
121
+ timestamp: new Date("2024-01-01T11:00:00Z"),
122
+ },
123
+ ];
124
+
125
+ // Setup mock to return runs
126
+ mockDb.select = mock(() => ({
127
+ from: mock(() => ({
128
+ where: mock(() => ({
129
+ orderBy: mock(() => Promise.resolve(runs)),
130
+ })),
131
+ })),
132
+ }));
133
+
134
+ mockDb.query.healthCheckConfigurations.findFirst = mock(() =>
135
+ Promise.resolve({ id: "config-1", strategyId: "http" })
136
+ );
137
+
138
+ const result = await service.getAggregatedHistory(
139
+ {
140
+ systemId: "sys-1",
141
+ configurationId: "config-1",
142
+ startDate: new Date("2024-01-01T00:00:00Z"),
143
+ endDate: new Date("2024-01-01T23:59:59Z"),
144
+ bucketSize: "hourly",
145
+ },
146
+ { includeAggregatedResult: true }
147
+ );
148
+
149
+ expect(result.buckets).toHaveLength(2);
150
+
151
+ // First bucket (10:00)
152
+ const bucket10 = result.buckets.find(
153
+ (b) => b.bucketStart.getHours() === 10
154
+ );
155
+ expect(bucket10).toBeDefined();
156
+ expect(bucket10!.runCount).toBe(2);
157
+ expect(bucket10!.healthyCount).toBe(2);
158
+ expect(bucket10!.unhealthyCount).toBe(0);
159
+ expect(bucket10!.successRate).toBe(1);
160
+ expect(bucket10!.avgLatencyMs).toBe(125);
161
+
162
+ // Second bucket (11:00)
163
+ const bucket11 = result.buckets.find(
164
+ (b) => b.bucketStart.getHours() === 11
165
+ );
166
+ expect(bucket11).toBeDefined();
167
+ expect(bucket11!.runCount).toBe(1);
168
+ expect(bucket11!.healthyCount).toBe(0);
169
+ expect(bucket11!.unhealthyCount).toBe(1);
170
+ expect(bucket11!.successRate).toBe(0);
171
+ });
172
+
173
+ it("calculates p95 latency correctly", async () => {
174
+ // Create 20 runs with latencies 100-200 (step 5)
175
+ const runs = Array.from({ length: 20 }, (_, i) => ({
176
+ id: `run-${i}`,
177
+ systemId: "sys-1",
178
+ configurationId: "config-1",
179
+ status: "healthy" as const,
180
+ latencyMs: 100 + i * 5,
181
+ result: {},
182
+ timestamp: new Date("2024-01-01T10:00:00Z"),
183
+ }));
184
+
185
+ mockDb.select = mock(() => ({
186
+ from: mock(() => ({
187
+ where: mock(() => ({
188
+ orderBy: mock(() => Promise.resolve(runs)),
189
+ })),
190
+ })),
191
+ }));
192
+
193
+ mockDb.query.healthCheckConfigurations.findFirst = mock(() =>
194
+ Promise.resolve({ id: "config-1", strategyId: "http" })
195
+ );
196
+
197
+ const result = await service.getAggregatedHistory(
198
+ {
199
+ systemId: "sys-1",
200
+ configurationId: "config-1",
201
+ startDate: new Date("2024-01-01T00:00:00Z"),
202
+ endDate: new Date("2024-01-01T23:59:59Z"),
203
+ bucketSize: "hourly",
204
+ },
205
+ { includeAggregatedResult: true }
206
+ );
207
+
208
+ expect(result.buckets).toHaveLength(1);
209
+ expect(result.buckets[0].p95LatencyMs).toBe(190); // 95th percentile of 100-195
210
+ });
211
+ });
212
+
213
+ describe("strategy metadata aggregation", () => {
214
+ it("calls strategy.aggregateResult for each bucket", async () => {
215
+ const runs = [
216
+ {
217
+ id: "run-1",
218
+ systemId: "sys-1",
219
+ configurationId: "config-1",
220
+ status: "healthy" as const,
221
+ latencyMs: 100,
222
+ result: { statusCode: 200 },
223
+ timestamp: new Date("2024-01-01T10:00:00Z"),
224
+ },
225
+ ];
226
+
227
+ mockDb.select = mock(() => ({
228
+ from: mock(() => ({
229
+ where: mock(() => ({
230
+ orderBy: mock(() => Promise.resolve(runs)),
231
+ })),
232
+ })),
233
+ }));
234
+
235
+ mockDb.query.healthCheckConfigurations.findFirst = mock(() =>
236
+ Promise.resolve({ id: "config-1", strategyId: "http" })
237
+ );
238
+
239
+ const result = await service.getAggregatedHistory(
240
+ {
241
+ systemId: "sys-1",
242
+ configurationId: "config-1",
243
+ startDate: new Date("2024-01-01T00:00:00Z"),
244
+ endDate: new Date("2024-01-01T23:59:59Z"),
245
+ bucketSize: "hourly",
246
+ },
247
+ { includeAggregatedResult: true }
248
+ );
249
+
250
+ const bucket = result.buckets[0];
251
+ expect("aggregatedResult" in bucket && bucket.aggregatedResult).toEqual({
252
+ totalRuns: 1,
253
+ customMetric: "aggregated",
254
+ });
255
+
256
+ // Verify getStrategy was called to look up the strategy
257
+ expect(mockRegistry.getStrategy).toHaveBeenCalled();
258
+ });
259
+
260
+ it("returns undefined aggregatedResult when no strategy found", async () => {
261
+ const runs = [
262
+ {
263
+ id: "run-1",
264
+ systemId: "sys-1",
265
+ configurationId: "config-1",
266
+ status: "healthy" as const,
267
+ latencyMs: 100,
268
+ result: {},
269
+ timestamp: new Date("2024-01-01T10:00:00Z"),
270
+ },
271
+ ];
272
+
273
+ mockDb.select = mock(() => ({
274
+ from: mock(() => ({
275
+ where: mock(() => ({
276
+ orderBy: mock(() => Promise.resolve(runs)),
277
+ })),
278
+ })),
279
+ }));
280
+
281
+ // No config found means no strategy
282
+ mockDb.query.healthCheckConfigurations.findFirst = mock(() =>
283
+ Promise.resolve(null)
284
+ );
285
+
286
+ const result = await service.getAggregatedHistory(
287
+ {
288
+ systemId: "sys-1",
289
+ configurationId: "config-1",
290
+ startDate: new Date("2024-01-01T00:00:00Z"),
291
+ endDate: new Date("2024-01-01T23:59:59Z"),
292
+ bucketSize: "hourly",
293
+ },
294
+ { includeAggregatedResult: true }
295
+ );
296
+
297
+ const bucket = result.buckets[0];
298
+ expect(
299
+ "aggregatedResult" in bucket ? bucket.aggregatedResult : undefined
300
+ ).toBeUndefined();
301
+ });
302
+ });
303
+
304
+ describe("daily bucketing", () => {
305
+ it("groups runs into daily buckets", async () => {
306
+ const runs = [
307
+ {
308
+ id: "run-1",
309
+ systemId: "sys-1",
310
+ configurationId: "config-1",
311
+ status: "healthy" as const,
312
+ latencyMs: 100,
313
+ result: {},
314
+ timestamp: new Date("2024-01-01T10:00:00Z"),
315
+ },
316
+ {
317
+ id: "run-2",
318
+ systemId: "sys-1",
319
+ configurationId: "config-1",
320
+ status: "healthy" as const,
321
+ latencyMs: 150,
322
+ result: {},
323
+ timestamp: new Date("2024-01-01T22:00:00Z"),
324
+ },
325
+ {
326
+ id: "run-3",
327
+ systemId: "sys-1",
328
+ configurationId: "config-1",
329
+ status: "unhealthy" as const,
330
+ latencyMs: 200,
331
+ result: {},
332
+ timestamp: new Date("2024-01-02T05:00:00Z"),
333
+ },
334
+ ];
335
+
336
+ mockDb.select = mock(() => ({
337
+ from: mock(() => ({
338
+ where: mock(() => ({
339
+ orderBy: mock(() => Promise.resolve(runs)),
340
+ })),
341
+ })),
342
+ }));
343
+
344
+ mockDb.query.healthCheckConfigurations.findFirst = mock(() =>
345
+ Promise.resolve({ id: "config-1", strategyId: "http" })
346
+ );
347
+
348
+ const result = await service.getAggregatedHistory(
349
+ {
350
+ systemId: "sys-1",
351
+ configurationId: "config-1",
352
+ startDate: new Date("2024-01-01T00:00:00Z"),
353
+ endDate: new Date("2024-01-03T00:00:00Z"),
354
+ bucketSize: "daily",
355
+ },
356
+ { includeAggregatedResult: true }
357
+ );
358
+
359
+ expect(result.buckets).toHaveLength(2);
360
+
361
+ // Jan 1 bucket
362
+ const jan1 = result.buckets.find((b) => b.bucketStart.getDate() === 1);
363
+ expect(jan1).toBeDefined();
364
+ expect(jan1!.runCount).toBe(2);
365
+ expect(jan1!.bucketSize).toBe("daily");
366
+
367
+ // Jan 2 bucket
368
+ const jan2 = result.buckets.find((b) => b.bucketStart.getDate() === 2);
369
+ expect(jan2).toBeDefined();
370
+ expect(jan2!.runCount).toBe(1);
371
+ });
372
+ });
373
+ });
@@ -0,0 +1,16 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { healthCheckHooks } from "./hooks";
3
+
4
+ describe("Health Check Hooks", () => {
5
+ it("should have systemDegraded hook with correct ID", () => {
6
+ expect(healthCheckHooks.systemDegraded.id).toBe(
7
+ "healthcheck.system.degraded"
8
+ );
9
+ });
10
+
11
+ it("should have systemHealthy hook with correct ID", () => {
12
+ expect(healthCheckHooks.systemHealthy.id).toBe(
13
+ "healthcheck.system.healthy"
14
+ );
15
+ });
16
+ });
package/src/hooks.ts ADDED
@@ -0,0 +1,35 @@
1
+ import { createHook } from "@checkstack/backend-api";
2
+
3
+ /**
4
+ * Health check hooks for cross-plugin communication and external integrations.
5
+ * These hooks are registered as integration events for webhook subscriptions.
6
+ */
7
+ export const healthCheckHooks = {
8
+ /**
9
+ * Emitted when a system's aggregated health status degrades.
10
+ * This fires when status changes from healthy to degraded/unhealthy,
11
+ * or from degraded to unhealthy.
12
+ */
13
+ systemDegraded: createHook<{
14
+ systemId: string;
15
+ systemName?: string;
16
+ previousStatus: string;
17
+ newStatus: string;
18
+ healthyChecks: number;
19
+ totalChecks: number;
20
+ timestamp: string;
21
+ }>("healthcheck.system.degraded"),
22
+
23
+ /**
24
+ * Emitted when a system's aggregated health status recovers to healthy.
25
+ * This fires when status changes from degraded/unhealthy to healthy.
26
+ */
27
+ systemHealthy: createHook<{
28
+ systemId: string;
29
+ systemName?: string;
30
+ previousStatus: string;
31
+ healthyChecks: number;
32
+ totalChecks: number;
33
+ timestamp: string;
34
+ }>("healthcheck.system.healthy"),
35
+ } as const;
package/src/index.ts ADDED
@@ -0,0 +1,195 @@
1
+ import {
2
+ setupHealthCheckWorker,
3
+ bootstrapHealthChecks,
4
+ } from "./queue-executor";
5
+ import * as schema from "./schema";
6
+ import { NodePgDatabase } from "drizzle-orm/node-postgres";
7
+ import {
8
+ permissionList,
9
+ pluginMetadata,
10
+ healthCheckContract,
11
+ healthcheckRoutes,
12
+ permissions,
13
+ } from "@checkstack/healthcheck-common";
14
+ import {
15
+ createBackendPlugin,
16
+ coreServices,
17
+ type EmitHookFn,
18
+ } from "@checkstack/backend-api";
19
+ import { integrationEventExtensionPoint } from "@checkstack/integration-backend";
20
+ import { z } from "zod";
21
+ import { createHealthCheckRouter } from "./router";
22
+ import { HealthCheckService } from "./service";
23
+ import { catalogHooks } from "@checkstack/catalog-backend";
24
+ import { CatalogApi } from "@checkstack/catalog-common";
25
+ import { healthCheckHooks } from "./hooks";
26
+ import { registerSearchProvider } from "@checkstack/command-backend";
27
+ import { resolveRoute } from "@checkstack/common";
28
+
29
+ // =============================================================================
30
+ // Integration Event Payload Schemas
31
+ // =============================================================================
32
+
33
+ const systemDegradedPayloadSchema = z.object({
34
+ systemId: z.string(),
35
+ systemName: z.string().optional(),
36
+ previousStatus: z.string(),
37
+ newStatus: z.string(),
38
+ healthyChecks: z.number(),
39
+ totalChecks: z.number(),
40
+ timestamp: z.string(),
41
+ });
42
+
43
+ const systemHealthyPayloadSchema = z.object({
44
+ systemId: z.string(),
45
+ systemName: z.string().optional(),
46
+ previousStatus: z.string(),
47
+ healthyChecks: z.number(),
48
+ totalChecks: z.number(),
49
+ timestamp: z.string(),
50
+ });
51
+
52
+ // Store emitHook reference for use during Phase 2 init
53
+ let storedEmitHook: EmitHookFn | undefined;
54
+
55
+ export default createBackendPlugin({
56
+ metadata: pluginMetadata,
57
+ register(env) {
58
+ env.registerPermissions(permissionList);
59
+
60
+ // Register hooks as integration events
61
+ const integrationEvents = env.getExtensionPoint(
62
+ integrationEventExtensionPoint
63
+ );
64
+
65
+ integrationEvents.registerEvent(
66
+ {
67
+ hook: healthCheckHooks.systemDegraded,
68
+ displayName: "System Health Degraded",
69
+ description:
70
+ "Fired when a system's health status transitions from healthy to degraded/unhealthy",
71
+ category: "Health",
72
+ payloadSchema: systemDegradedPayloadSchema,
73
+ },
74
+ pluginMetadata
75
+ );
76
+
77
+ integrationEvents.registerEvent(
78
+ {
79
+ hook: healthCheckHooks.systemHealthy,
80
+ displayName: "System Health Restored",
81
+ description: "Fired when a system's health status recovers to healthy",
82
+ category: "Health",
83
+ payloadSchema: systemHealthyPayloadSchema,
84
+ },
85
+ pluginMetadata
86
+ );
87
+
88
+ env.registerInit({
89
+ schema,
90
+ deps: {
91
+ logger: coreServices.logger,
92
+ healthCheckRegistry: coreServices.healthCheckRegistry,
93
+ rpc: coreServices.rpc,
94
+ rpcClient: coreServices.rpcClient,
95
+ queueManager: coreServices.queueManager,
96
+ signalService: coreServices.signalService,
97
+ },
98
+ // Phase 2: Register router and setup worker
99
+ init: async ({
100
+ logger,
101
+ database,
102
+ healthCheckRegistry,
103
+ rpc,
104
+ rpcClient,
105
+ queueManager,
106
+ signalService,
107
+ }) => {
108
+ logger.debug("🏥 Initializing Health Check Backend...");
109
+
110
+ // Create catalog client for notification delegation
111
+ const catalogClient = rpcClient.forPlugin(CatalogApi);
112
+
113
+ // Setup queue-based health check worker
114
+ await setupHealthCheckWorker({
115
+ db: database,
116
+ registry: healthCheckRegistry,
117
+ logger,
118
+ queueManager,
119
+ signalService,
120
+ catalogClient,
121
+ getEmitHook: () => storedEmitHook,
122
+ });
123
+
124
+ const healthCheckRouter = createHealthCheckRouter(
125
+ database as NodePgDatabase<typeof schema>,
126
+ healthCheckRegistry
127
+ );
128
+ rpc.registerRouter(healthCheckRouter, healthCheckContract);
129
+
130
+ // Register command palette commands
131
+ registerSearchProvider({
132
+ pluginMetadata,
133
+ commands: [
134
+ {
135
+ id: "create",
136
+ title: "Create Health Check",
137
+ subtitle: "Create a new health check configuration",
138
+ iconName: "HeartPulse",
139
+ route:
140
+ resolveRoute(healthcheckRoutes.routes.config) +
141
+ "?action=create",
142
+ requiredPermissions: [permissions.healthCheckManage],
143
+ },
144
+ {
145
+ id: "manage",
146
+ title: "Manage Health Checks",
147
+ subtitle: "Manage health check configurations",
148
+ iconName: "HeartPulse",
149
+ shortcuts: ["meta+shift+h", "ctrl+shift+h"],
150
+ route: resolveRoute(healthcheckRoutes.routes.config),
151
+ requiredPermissions: [permissions.healthCheckManage],
152
+ },
153
+ ],
154
+ });
155
+
156
+ logger.debug("✅ Health Check Backend initialized.");
157
+ },
158
+ afterPluginsReady: async ({
159
+ database,
160
+ queueManager,
161
+ logger,
162
+ onHook,
163
+ emitHook,
164
+ healthCheckRegistry,
165
+ }) => {
166
+ // Store emitHook for the queue worker (Closure-based Hook Getter pattern)
167
+ storedEmitHook = emitHook;
168
+ // Bootstrap all enabled health checks
169
+ await bootstrapHealthChecks({
170
+ db: database,
171
+ queueManager,
172
+ logger,
173
+ });
174
+
175
+ // Subscribe to catalog system deletion to clean up associations
176
+ const service = new HealthCheckService(database, healthCheckRegistry);
177
+ onHook(
178
+ catalogHooks.systemDeleted,
179
+ async (payload) => {
180
+ logger.debug(
181
+ `Cleaning up health check associations for deleted system: ${payload.systemId}`
182
+ );
183
+ await service.removeAllSystemAssociations(payload.systemId);
184
+ },
185
+ { mode: "work-queue", workerGroup: "system-cleanup" }
186
+ );
187
+
188
+ logger.debug("✅ Health Check Backend afterPluginsReady complete.");
189
+ },
190
+ });
191
+ },
192
+ });
193
+
194
+ // Re-export hooks for other plugins to use
195
+ export { healthCheckHooks } from "./hooks";