@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.
- package/CHANGELOG.md +181 -0
- package/drizzle/0000_stormy_slayback.sql +33 -0
- package/drizzle/0001_thin_shotgun.sql +1 -0
- package/drizzle/0002_closed_lucky_pierre.sql +19 -0
- package/drizzle/0003_powerful_rage.sql +1 -0
- package/drizzle/0004_short_ezekiel.sql +1 -0
- package/drizzle/0005_glossy_longshot.sql +1 -0
- package/drizzle/meta/0000_snapshot.json +234 -0
- package/drizzle/meta/0001_snapshot.json +240 -0
- package/drizzle/meta/0002_snapshot.json +361 -0
- package/drizzle/meta/0003_snapshot.json +367 -0
- package/drizzle/meta/0004_snapshot.json +401 -0
- package/drizzle/meta/0005_snapshot.json +401 -0
- package/drizzle/meta/_journal.json +48 -0
- package/drizzle.config.ts +7 -0
- package/package.json +37 -0
- package/src/aggregation.test.ts +373 -0
- package/src/hooks.test.ts +16 -0
- package/src/hooks.ts +35 -0
- package/src/index.ts +195 -0
- package/src/queue-executor.test.ts +229 -0
- package/src/queue-executor.ts +569 -0
- package/src/retention-job.ts +404 -0
- package/src/router.test.ts +81 -0
- package/src/router.ts +157 -0
- package/src/schema.ts +153 -0
- package/src/service.ts +718 -0
- package/src/state-evaluator.test.ts +237 -0
- package/src/state-evaluator.ts +105 -0
- package/src/state-thresholds-migrations.ts +15 -0
- package/tsconfig.json +6 -0
|
@@ -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";
|