@checkstack/healthcheck-backend 1.18.0 → 1.19.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 +294 -0
- package/package.json +22 -20
- package/src/health-notification-content.test.ts +89 -0
- package/src/health-notification-content.ts +138 -0
- package/src/queue-executor.ts +31 -68
- package/src/router.ts +36 -0
- package/src/service-batching.test.ts +8 -0
- package/src/service-bulk-counts.it.test.ts +144 -0
- package/src/service-bulk-run-stats.it.test.ts +197 -0
- package/src/service-ordering.test.ts +6 -2
- package/src/service-paused-filter.test.ts +13 -0
- package/src/service-rollup-worst-wins.test.ts +209 -145
- package/src/service.ts +366 -185
- package/src/status-page/rollup.test.ts +40 -0
- package/src/status-page/rollup.ts +27 -0
- package/src/status-page/widgets.test.ts +303 -0
- package/src/status-page/widgets.ts +155 -39
package/src/queue-executor.ts
CHANGED
|
@@ -35,20 +35,17 @@ import {
|
|
|
35
35
|
} from "@checkstack/healthcheck-common";
|
|
36
36
|
import {
|
|
37
37
|
CatalogApi,
|
|
38
|
-
catalogRoutes,
|
|
39
|
-
createSystemSubject,
|
|
40
38
|
type Environment,
|
|
41
39
|
} from "@checkstack/catalog-common";
|
|
42
40
|
import {
|
|
43
41
|
resolveEffectiveEnvironments,
|
|
44
42
|
type EffectiveEnvironment,
|
|
45
43
|
} from "./effective-environments";
|
|
46
|
-
import {
|
|
44
|
+
import { buildHealthTransitionNotification } from "./health-notification-content";
|
|
47
45
|
import { MaintenanceApi } from "@checkstack/maintenance-common";
|
|
48
46
|
import { IncidentApi } from "@checkstack/incident-common";
|
|
49
47
|
import { NotificationApi } from "@checkstack/notification-common";
|
|
50
|
-
import {
|
|
51
|
-
import { resolveRoute, type InferClient, extractErrorMessage} from "@checkstack/common";
|
|
48
|
+
import { type InferClient, extractErrorMessage} from "@checkstack/common";
|
|
52
49
|
import { secretEnvMappingSchema } from "@checkstack/secrets-common";
|
|
53
50
|
import type {
|
|
54
51
|
SecretResolverService,
|
|
@@ -465,6 +462,13 @@ async function notifyStateChange(props: {
|
|
|
465
462
|
systemId: string;
|
|
466
463
|
systemName: string;
|
|
467
464
|
configurationId: string;
|
|
465
|
+
/**
|
|
466
|
+
* Human-readable name of the health check whose run drove this transition.
|
|
467
|
+
* Named in the body and surfaced as a `healthcheck.healthcheck` subject so
|
|
468
|
+
* subscribers see WHICH check failed, not just which system. Best-effort:
|
|
469
|
+
* falls back to the `configurationId` when the name could not be resolved.
|
|
470
|
+
*/
|
|
471
|
+
configurationName?: string;
|
|
468
472
|
previousStatus: HealthCheckStatus;
|
|
469
473
|
newStatus: HealthCheckStatus;
|
|
470
474
|
/**
|
|
@@ -493,6 +497,7 @@ async function notifyStateChange(props: {
|
|
|
493
497
|
systemId,
|
|
494
498
|
systemName,
|
|
495
499
|
configurationId,
|
|
500
|
+
configurationName,
|
|
496
501
|
previousStatus,
|
|
497
502
|
newStatus,
|
|
498
503
|
environmentId,
|
|
@@ -505,8 +510,9 @@ async function notifyStateChange(props: {
|
|
|
505
510
|
logger,
|
|
506
511
|
} = props;
|
|
507
512
|
|
|
508
|
-
|
|
509
|
-
|
|
513
|
+
// The check that just ran is the one driving this aggregate transition, so
|
|
514
|
+
// its name is the authoritative check to blame. Fall back to the id.
|
|
515
|
+
const checkName = configurationName ?? configurationId;
|
|
510
516
|
|
|
511
517
|
const transition = classifyTransition(previousStatus, newStatus);
|
|
512
518
|
if (transition === "none") {
|
|
@@ -572,70 +578,24 @@ async function notifyStateChange(props: {
|
|
|
572
578
|
);
|
|
573
579
|
}
|
|
574
580
|
|
|
575
|
-
let title: string;
|
|
576
|
-
let body: string;
|
|
577
|
-
let importance: "info" | "warning" | "critical";
|
|
578
|
-
|
|
579
|
-
if (transition === "recovery") {
|
|
580
|
-
title = `System health restored${envSuffix}: ${systemName}`;
|
|
581
|
-
body = envScoped
|
|
582
|
-
? `Health checks for **${systemName}** in environment **${environmentName ?? environmentId}** are now passing. The system has returned to normal operation in that environment.`
|
|
583
|
-
: `All health checks for **${systemName}** are now passing. The system has returned to normal operation.`;
|
|
584
|
-
importance = "info";
|
|
585
|
-
} else if (newStatus === "unhealthy") {
|
|
586
|
-
title = `System health critical${envSuffix}: ${systemName}`;
|
|
587
|
-
body = envScoped
|
|
588
|
-
? `Health checks indicate **${systemName}** is unhealthy in environment **${environmentName ?? environmentId}** and may be down in that environment.`
|
|
589
|
-
: `Health checks indicate **${systemName}** is unhealthy and may be down.`;
|
|
590
|
-
importance = "critical";
|
|
591
|
-
} else {
|
|
592
|
-
// degraded — either an escalation from healthy or a partial recovery
|
|
593
|
-
title = `System health degraded${envSuffix}: ${systemName}`;
|
|
594
|
-
body = envScoped
|
|
595
|
-
? `Some health checks for **${systemName}** in environment **${environmentName ?? environmentId}** are failing. That environment may be experiencing issues.`
|
|
596
|
-
: `Some health checks for **${systemName}** are failing. The system may be experiencing issues.`;
|
|
597
|
-
importance = "warning";
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
const systemDetailPath = resolveRoute(catalogRoutes.routes.systemDetail, {
|
|
601
|
-
systemId,
|
|
602
|
-
});
|
|
603
|
-
// Recovery lands on the default (all) view; failing transitions deep-link
|
|
604
|
-
// operators into the failing-checks filter so they can debug immediately.
|
|
605
|
-
const actionUrl =
|
|
606
|
-
transition === "recovery"
|
|
607
|
-
? systemDetailPath
|
|
608
|
-
: `${systemDetailPath}?filter=failing`;
|
|
609
|
-
const actionLabel =
|
|
610
|
-
transition === "recovery" ? "View System" : "View failing checks";
|
|
611
|
-
|
|
612
581
|
void catalogClient; // parents are resolved server-side via stored target edges
|
|
613
582
|
|
|
614
583
|
try {
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
subjects: [
|
|
631
|
-
createSystemSubject({
|
|
632
|
-
id: systemId,
|
|
633
|
-
name: systemName,
|
|
634
|
-
url: systemDetailPath,
|
|
635
|
-
status: newStatus,
|
|
636
|
-
}),
|
|
637
|
-
],
|
|
638
|
-
});
|
|
584
|
+
// Content (title/body/subjects/collapseKey) is built by a pure, unit-tested
|
|
585
|
+
// helper so the wording - which now NAMES the failing check and pushes a
|
|
586
|
+
// `healthcheck.healthcheck` subject - can be verified without the executor.
|
|
587
|
+
await notificationClient.notifyForSubscription(
|
|
588
|
+
buildHealthTransitionNotification({
|
|
589
|
+
transition,
|
|
590
|
+
systemId,
|
|
591
|
+
systemName,
|
|
592
|
+
configurationId,
|
|
593
|
+
checkName,
|
|
594
|
+
newStatus,
|
|
595
|
+
environmentId,
|
|
596
|
+
environmentName,
|
|
597
|
+
}),
|
|
598
|
+
);
|
|
639
599
|
logger.debug(
|
|
640
600
|
`Notified subscribers: ${previousStatus} → ${newStatus} for system ${systemId}`,
|
|
641
601
|
);
|
|
@@ -1390,6 +1350,7 @@ async function executeHealthCheckJob(props: {
|
|
|
1390
1350
|
systemId,
|
|
1391
1351
|
systemName,
|
|
1392
1352
|
configurationId: configId,
|
|
1353
|
+
configurationName: configRow.configName,
|
|
1393
1354
|
previousStatus,
|
|
1394
1355
|
newStatus: newState.status,
|
|
1395
1356
|
environmentId,
|
|
@@ -1557,6 +1518,7 @@ async function executeHealthCheckJob(props: {
|
|
|
1557
1518
|
systemId,
|
|
1558
1519
|
systemName,
|
|
1559
1520
|
configurationId: configId,
|
|
1521
|
+
configurationName: configRow.configName,
|
|
1560
1522
|
previousStatus,
|
|
1561
1523
|
newStatus: newState.status,
|
|
1562
1524
|
environmentId,
|
|
@@ -1723,6 +1685,7 @@ async function executeHealthCheckJob(props: {
|
|
|
1723
1685
|
systemId,
|
|
1724
1686
|
systemName,
|
|
1725
1687
|
configurationId: configId,
|
|
1688
|
+
configurationName: configName,
|
|
1726
1689
|
previousStatus,
|
|
1727
1690
|
newStatus: newState.status,
|
|
1728
1691
|
service,
|
package/src/router.ts
CHANGED
|
@@ -227,6 +227,27 @@ export const createHealthCheckRouter = (opts: {
|
|
|
227
227
|
});
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
+
// Recompute the rollup `health` entity for this system NOW. Changing an
|
|
231
|
+
// assignment's environment set can make a previously-effective env slice
|
|
232
|
+
// orphaned (env disabled/removed from `environmentIds`): that slice stops
|
|
233
|
+
// producing runs, so NO per-env health-change event will ever fire for it,
|
|
234
|
+
// and the event-driven rollup consumer would never recompute the disabled
|
|
235
|
+
// env's last (unhealthy) status away. `getSystemHealthStatus` now excludes
|
|
236
|
+
// non-effective slices, so this recompute promptly drops the stale slice
|
|
237
|
+
// from the persisted entity - closing any SLO downtime it was holding open
|
|
238
|
+
// and clearing the badge - instead of waiting for its runs to age out of
|
|
239
|
+
// the window. Best-effort: a recompute failure is logged inside the helper
|
|
240
|
+
// and never breaks the mutation (the live RPC reads are already correct).
|
|
241
|
+
if (recomputeSystemRollupHealth) {
|
|
242
|
+
try {
|
|
243
|
+
await recomputeSystemRollupHealth(args.systemId);
|
|
244
|
+
} catch (error) {
|
|
245
|
+
logger.warn(
|
|
246
|
+
`Failed to recompute rollup health after assignment change for system ${args.systemId}: ${extractErrorMessage(error, "unknown")}`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
230
251
|
// Notify subscribers (e.g., satellite-backend) that assignments changed.
|
|
231
252
|
const emitHook = getEmitHook();
|
|
232
253
|
if (emitHook) {
|
|
@@ -473,6 +494,17 @@ export const createHealthCheckRouter = (opts: {
|
|
|
473
494
|
},
|
|
474
495
|
),
|
|
475
496
|
|
|
497
|
+
getBulkAssignedHealthCheckCounts:
|
|
498
|
+
os.getBulkAssignedHealthCheckCounts.handler(async ({ input }) => {
|
|
499
|
+
// ONE grouped query for the whole visible system list (replaces the
|
|
500
|
+
// per-row getSystemAssociations N+1). recordKey gating on the contract
|
|
501
|
+
// drops counts for systems the caller may not read.
|
|
502
|
+
const counts = await service.getBulkAssignedHealthCheckCounts(
|
|
503
|
+
input.systemIds,
|
|
504
|
+
);
|
|
505
|
+
return { counts };
|
|
506
|
+
}),
|
|
507
|
+
|
|
476
508
|
associateSystem: os.associateSystem.handler(async ({ input, context }) => {
|
|
477
509
|
await enforceNotGitOpsLocked("System", input.systemId);
|
|
478
510
|
await service.associateSystem({
|
|
@@ -573,6 +605,10 @@ export const createHealthCheckRouter = (opts: {
|
|
|
573
605
|
return service.getRunStats(input);
|
|
574
606
|
}),
|
|
575
607
|
|
|
608
|
+
getBulkRunStats: os.getBulkRunStats.handler(async ({ input }) => {
|
|
609
|
+
return { stats: await service.getBulkRunStats(input) };
|
|
610
|
+
}),
|
|
611
|
+
|
|
576
612
|
getDetailedHistory: os.getDetailedHistory.handler(
|
|
577
613
|
async ({ input, context }) => {
|
|
578
614
|
// Handler-side authorization (the contract's `access` is deliberately
|
|
@@ -18,6 +18,8 @@ describe("HealthCheckService.getSystemHealthStatus - read batching", () => {
|
|
|
18
18
|
enabled: true,
|
|
19
19
|
paused: false,
|
|
20
20
|
stateThresholds: null,
|
|
21
|
+
// All-environments selector; each check has only the env-less slice below.
|
|
22
|
+
environmentIds: null,
|
|
21
23
|
}));
|
|
22
24
|
const assocWhere = mock(() => Promise.resolve(associations));
|
|
23
25
|
const assocInnerJoin = Object.assign(Promise.resolve([]), {
|
|
@@ -39,6 +41,11 @@ describe("HealthCheckService.getSystemHealthStatus - read batching", () => {
|
|
|
39
41
|
orderBy: runsOrderBy,
|
|
40
42
|
});
|
|
41
43
|
|
|
44
|
+
// Distinct env keys query per check: a single env-less (null) slice.
|
|
45
|
+
const distinctFrom = Object.assign(Promise.resolve([]), {
|
|
46
|
+
where: mock(() => Promise.resolve([{ environmentId: null }])),
|
|
47
|
+
});
|
|
48
|
+
|
|
42
49
|
let selectCallCount = 0;
|
|
43
50
|
const db = withTransactionMock({
|
|
44
51
|
select: mock(() => {
|
|
@@ -46,6 +53,7 @@ describe("HealthCheckService.getSystemHealthStatus - read batching", () => {
|
|
|
46
53
|
if (selectCallCount === 1) return { from: mock(() => assocFrom) };
|
|
47
54
|
return { from: mock(() => runsFrom) };
|
|
48
55
|
}),
|
|
56
|
+
selectDistinct: mock(() => ({ from: mock(() => distinctFrom) })),
|
|
49
57
|
insert: mock(() => ({ values: mock(() => Promise.resolve()) })),
|
|
50
58
|
update: mock(() => ({
|
|
51
59
|
set: mock(() => ({ where: mock(() => Promise.resolve()) })),
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test for `HealthCheckService.getBulkAssignedHealthCheckCounts`
|
|
3
|
+
* against a REAL Postgres. The method's whole point is the GROUP BY / COUNT and
|
|
4
|
+
* the zero-fill for systems with no rows - behaviour a mocked db cannot prove
|
|
5
|
+
* (the database does the grouping). These tests pin: counts are grouped per
|
|
6
|
+
* system, systems with no assignments report 0, requested-but-absent systems
|
|
7
|
+
* report 0, and non-requested systems never leak in.
|
|
8
|
+
*
|
|
9
|
+
* Gated on CHECKSTACK_IT so it runs in CI (shared compose Postgres) and is
|
|
10
|
+
* skipped in the default `bun test` run, matching the other *.it.test.ts here.
|
|
11
|
+
*/
|
|
12
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test";
|
|
13
|
+
import { drizzle } from "drizzle-orm/node-postgres";
|
|
14
|
+
import { Pool } from "pg";
|
|
15
|
+
import type {
|
|
16
|
+
SafeDatabase,
|
|
17
|
+
HealthCheckRegistry,
|
|
18
|
+
CollectorRegistry,
|
|
19
|
+
} from "@checkstack/backend-api";
|
|
20
|
+
import * as schema from "./schema";
|
|
21
|
+
import { HealthCheckService } from "./service";
|
|
22
|
+
|
|
23
|
+
const PG_URL =
|
|
24
|
+
process.env.CHECKSTACK_IT_PG_URL ??
|
|
25
|
+
"postgres://postgres:postgres@localhost:5432/postgres";
|
|
26
|
+
const SCHEMA = "healthcheck_it_bulk_counts";
|
|
27
|
+
|
|
28
|
+
let admin: Pool;
|
|
29
|
+
let pool: Pool;
|
|
30
|
+
let service: HealthCheckService;
|
|
31
|
+
|
|
32
|
+
async function insertAssignment(row: {
|
|
33
|
+
systemId: string;
|
|
34
|
+
configurationId: string;
|
|
35
|
+
}): Promise<void> {
|
|
36
|
+
await pool.query(
|
|
37
|
+
`INSERT INTO "${SCHEMA}".system_health_checks (system_id, configuration_id)
|
|
38
|
+
VALUES ($1, $2)`,
|
|
39
|
+
[row.systemId, row.configurationId],
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe.skipIf(!process.env.CHECKSTACK_IT)(
|
|
44
|
+
"HealthCheckService.getBulkAssignedHealthCheckCounts (shared Postgres)",
|
|
45
|
+
() => {
|
|
46
|
+
beforeAll(async () => {
|
|
47
|
+
admin = new Pool({ connectionString: PG_URL });
|
|
48
|
+
await admin.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`);
|
|
49
|
+
await admin.query(`CREATE SCHEMA "${SCHEMA}"`);
|
|
50
|
+
// Only the columns the grouped COUNT touches; no FK to configurations so
|
|
51
|
+
// the DDL stays minimal and focused on the aggregation behaviour.
|
|
52
|
+
await admin.query(
|
|
53
|
+
`CREATE TABLE "${SCHEMA}".system_health_checks (
|
|
54
|
+
system_id text NOT NULL,
|
|
55
|
+
configuration_id uuid NOT NULL,
|
|
56
|
+
PRIMARY KEY (system_id, configuration_id)
|
|
57
|
+
)`,
|
|
58
|
+
);
|
|
59
|
+
pool = new Pool({
|
|
60
|
+
connectionString: PG_URL,
|
|
61
|
+
options: `-c search_path=${SCHEMA}`,
|
|
62
|
+
});
|
|
63
|
+
const db = drizzle(pool, {
|
|
64
|
+
schema,
|
|
65
|
+
}) as unknown as SafeDatabase<typeof schema>;
|
|
66
|
+
// registry / collectorRegistry are unused by the count method under test;
|
|
67
|
+
// stub them so the constructor is satisfied without wiring real registries.
|
|
68
|
+
const service_ = new HealthCheckService(
|
|
69
|
+
db,
|
|
70
|
+
{} as unknown as HealthCheckRegistry,
|
|
71
|
+
{} as unknown as CollectorRegistry,
|
|
72
|
+
);
|
|
73
|
+
service = service_;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
afterAll(async () => {
|
|
77
|
+
await pool?.end();
|
|
78
|
+
await admin.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`);
|
|
79
|
+
await admin.end();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
beforeEach(async () => {
|
|
83
|
+
await pool.query(`TRUNCATE "${SCHEMA}".system_health_checks`);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("groups counts per system and zero-fills systems with no assignments", async () => {
|
|
87
|
+
// sys-a: 2 assignments, sys-b: 1, sys-c: none.
|
|
88
|
+
await insertAssignment({
|
|
89
|
+
systemId: "sys-a",
|
|
90
|
+
configurationId: crypto.randomUUID(),
|
|
91
|
+
});
|
|
92
|
+
await insertAssignment({
|
|
93
|
+
systemId: "sys-a",
|
|
94
|
+
configurationId: crypto.randomUUID(),
|
|
95
|
+
});
|
|
96
|
+
await insertAssignment({
|
|
97
|
+
systemId: "sys-b",
|
|
98
|
+
configurationId: crypto.randomUUID(),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const counts = await service.getBulkAssignedHealthCheckCounts([
|
|
102
|
+
"sys-a",
|
|
103
|
+
"sys-b",
|
|
104
|
+
"sys-c",
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
expect(counts).toEqual({ "sys-a": 2, "sys-b": 1, "sys-c": 0 });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("returns 0 for a requested system that has no rows at all", async () => {
|
|
111
|
+
await insertAssignment({
|
|
112
|
+
systemId: "sys-a",
|
|
113
|
+
configurationId: crypto.randomUUID(),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const counts = await service.getBulkAssignedHealthCheckCounts([
|
|
117
|
+
"sys-a",
|
|
118
|
+
"missing",
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
expect(counts).toEqual({ "sys-a": 1, missing: 0 });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("never leaks counts for systems not in the requested set", async () => {
|
|
125
|
+
await insertAssignment({
|
|
126
|
+
systemId: "sys-a",
|
|
127
|
+
configurationId: crypto.randomUUID(),
|
|
128
|
+
});
|
|
129
|
+
await insertAssignment({
|
|
130
|
+
systemId: "sys-other",
|
|
131
|
+
configurationId: crypto.randomUUID(),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const counts = await service.getBulkAssignedHealthCheckCounts(["sys-a"]);
|
|
135
|
+
|
|
136
|
+
expect(counts).toEqual({ "sys-a": 1 });
|
|
137
|
+
expect(Object.keys(counts)).not.toContain("sys-other");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("returns an empty map for an empty request without querying", async () => {
|
|
141
|
+
expect(await service.getBulkAssignedHealthCheckCounts([])).toEqual({});
|
|
142
|
+
});
|
|
143
|
+
},
|
|
144
|
+
);
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test for `HealthCheckService.getBulkRunStats` against a REAL
|
|
3
|
+
* Postgres. The method's whole point is the single grouped read over
|
|
4
|
+
* `health_check_runs` for MANY systems, then per-system summarization - so each
|
|
5
|
+
* entry MUST be byte-identical to what the single `getRunStats({ systemId })`
|
|
6
|
+
* would return for the same window. A mocked db cannot prove the `inArray`
|
|
7
|
+
* grouping / windowing, so this real-DB guard pins the equivalence, the
|
|
8
|
+
* per-system isolation, and the "systems with no runs are omitted" behaviour
|
|
9
|
+
* the status-page uptime column relies on.
|
|
10
|
+
*
|
|
11
|
+
* Gated on CHECKSTACK_IT so it runs in CI (shared compose Postgres) and is
|
|
12
|
+
* skipped in the default `bun test` run, matching the other *.it.test.ts here.
|
|
13
|
+
*/
|
|
14
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test";
|
|
15
|
+
import { drizzle } from "drizzle-orm/node-postgres";
|
|
16
|
+
import { Pool } from "pg";
|
|
17
|
+
import type {
|
|
18
|
+
SafeDatabase,
|
|
19
|
+
HealthCheckRegistry,
|
|
20
|
+
CollectorRegistry,
|
|
21
|
+
} from "@checkstack/backend-api";
|
|
22
|
+
import * as schema from "./schema";
|
|
23
|
+
import { HealthCheckService } from "./service";
|
|
24
|
+
|
|
25
|
+
const PG_URL =
|
|
26
|
+
process.env.CHECKSTACK_IT_PG_URL ??
|
|
27
|
+
"postgres://postgres:postgres@localhost:5432/postgres";
|
|
28
|
+
const SCHEMA = "healthcheck_it_bulk_run_stats";
|
|
29
|
+
|
|
30
|
+
const START = new Date("2026-06-01T00:00:00.000Z");
|
|
31
|
+
const END = new Date("2026-06-01T23:59:59.000Z");
|
|
32
|
+
|
|
33
|
+
let admin: Pool;
|
|
34
|
+
let pool: Pool;
|
|
35
|
+
let service: HealthCheckService;
|
|
36
|
+
|
|
37
|
+
async function insertRun(row: {
|
|
38
|
+
systemId: string;
|
|
39
|
+
status: string;
|
|
40
|
+
latencyMs?: number | null;
|
|
41
|
+
at: string;
|
|
42
|
+
environmentId?: string | null;
|
|
43
|
+
}): Promise<void> {
|
|
44
|
+
await pool.query(
|
|
45
|
+
`INSERT INTO "${SCHEMA}".health_check_runs
|
|
46
|
+
(id, configuration_id, system_id, environment_id, status, latency_ms, timestamp)
|
|
47
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
|
48
|
+
[
|
|
49
|
+
crypto.randomUUID(),
|
|
50
|
+
crypto.randomUUID(),
|
|
51
|
+
row.systemId,
|
|
52
|
+
row.environmentId ?? null,
|
|
53
|
+
row.status,
|
|
54
|
+
row.latencyMs ?? null,
|
|
55
|
+
row.at,
|
|
56
|
+
],
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe.skipIf(!process.env.CHECKSTACK_IT)(
|
|
61
|
+
"HealthCheckService.getBulkRunStats (shared Postgres)",
|
|
62
|
+
() => {
|
|
63
|
+
beforeAll(async () => {
|
|
64
|
+
admin = new Pool({ connectionString: PG_URL });
|
|
65
|
+
await admin.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`);
|
|
66
|
+
await admin.query(`CREATE SCHEMA "${SCHEMA}"`);
|
|
67
|
+
// Minimal DDL: only the columns the grouped read touches; status is plain
|
|
68
|
+
// text and configuration_id carries no FK, keeping the schema
|
|
69
|
+
// self-contained like the sibling *.it.test.ts files.
|
|
70
|
+
await admin.query(
|
|
71
|
+
`CREATE TABLE "${SCHEMA}".health_check_runs (
|
|
72
|
+
id uuid PRIMARY KEY,
|
|
73
|
+
configuration_id uuid NOT NULL,
|
|
74
|
+
system_id text NOT NULL,
|
|
75
|
+
environment_id text,
|
|
76
|
+
status text NOT NULL,
|
|
77
|
+
latency_ms integer,
|
|
78
|
+
result jsonb,
|
|
79
|
+
source_id text,
|
|
80
|
+
source_label text,
|
|
81
|
+
timestamp timestamp NOT NULL DEFAULT now()
|
|
82
|
+
)`,
|
|
83
|
+
);
|
|
84
|
+
pool = new Pool({
|
|
85
|
+
connectionString: PG_URL,
|
|
86
|
+
options: `-c search_path=${SCHEMA}`,
|
|
87
|
+
});
|
|
88
|
+
const db = drizzle(pool, {
|
|
89
|
+
schema,
|
|
90
|
+
}) as unknown as SafeDatabase<typeof schema>;
|
|
91
|
+
// registry / collectorRegistry are unused by the stats read under test;
|
|
92
|
+
// stub them so the constructor is satisfied.
|
|
93
|
+
service = new HealthCheckService(
|
|
94
|
+
db,
|
|
95
|
+
{} as unknown as HealthCheckRegistry,
|
|
96
|
+
{} as unknown as CollectorRegistry,
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
afterAll(async () => {
|
|
101
|
+
await pool?.end();
|
|
102
|
+
await admin.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`);
|
|
103
|
+
await admin.end();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
beforeEach(async () => {
|
|
107
|
+
await pool.query(`TRUNCATE "${SCHEMA}".health_check_runs`);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("returns per-system stats identical to getRunStats for the same window", async () => {
|
|
111
|
+
// sys-a: 3 healthy + 1 unhealthy; sys-b: 2 healthy; sys-c: no runs.
|
|
112
|
+
await insertRun({ systemId: "sys-a", status: "healthy", latencyMs: 10, at: "2026-06-01T01:00:00Z" });
|
|
113
|
+
await insertRun({ systemId: "sys-a", status: "healthy", latencyMs: 20, at: "2026-06-01T02:00:00Z" });
|
|
114
|
+
await insertRun({ systemId: "sys-a", status: "healthy", latencyMs: 30, at: "2026-06-01T03:00:00Z" });
|
|
115
|
+
await insertRun({ systemId: "sys-a", status: "unhealthy", latencyMs: 40, at: "2026-06-01T04:00:00Z" });
|
|
116
|
+
await insertRun({ systemId: "sys-b", status: "healthy", latencyMs: 5, at: "2026-06-01T05:00:00Z" });
|
|
117
|
+
await insertRun({ systemId: "sys-b", status: "healthy", latencyMs: 7, at: "2026-06-01T06:00:00Z" });
|
|
118
|
+
// A run OUTSIDE the window must be ignored by both endpoints.
|
|
119
|
+
await insertRun({ systemId: "sys-a", status: "unhealthy", at: "2026-05-01T00:00:00Z" });
|
|
120
|
+
|
|
121
|
+
const ids = ["sys-a", "sys-b", "sys-c"];
|
|
122
|
+
const stats = await service.getBulkRunStats({
|
|
123
|
+
systemIds: ids,
|
|
124
|
+
startDate: START,
|
|
125
|
+
endDate: END,
|
|
126
|
+
maxBuckets: 24,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
for (const systemId of ids) {
|
|
130
|
+
const single = await service.getRunStats({ systemId, startDate: START, endDate: END, maxBuckets: 24 });
|
|
131
|
+
if (single.total.runCount === 0) {
|
|
132
|
+
// Zero-run systems are omitted from the bulk record.
|
|
133
|
+
expect(stats[systemId]).toBeUndefined();
|
|
134
|
+
} else {
|
|
135
|
+
expect(stats[systemId]).toEqual(single);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// sys-c had no runs => omitted; only populated systems are keys.
|
|
140
|
+
expect(Object.keys(stats).toSorted()).toEqual(["sys-a", "sys-b"]);
|
|
141
|
+
// Sanity: sys-a uptime = 3/4 healthy = 75%.
|
|
142
|
+
expect(stats["sys-a"]?.total.uptimePct).toBe(75);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("scopes uptime to the selected environments (status-page env filter)", async () => {
|
|
146
|
+
// sys-a: prod 2 healthy; staging 1 healthy + 1 unhealthy; env-less 1
|
|
147
|
+
// unhealthy. A prod-only page must count ONLY the two prod runs (100%),
|
|
148
|
+
// never the staging or env-less runs.
|
|
149
|
+
await insertRun({ systemId: "sys-a", status: "healthy", environmentId: "env-prod", at: "2026-06-01T01:00:00Z" });
|
|
150
|
+
await insertRun({ systemId: "sys-a", status: "healthy", environmentId: "env-prod", at: "2026-06-01T02:00:00Z" });
|
|
151
|
+
await insertRun({ systemId: "sys-a", status: "healthy", environmentId: "env-stage", at: "2026-06-01T03:00:00Z" });
|
|
152
|
+
await insertRun({ systemId: "sys-a", status: "unhealthy", environmentId: "env-stage", at: "2026-06-01T04:00:00Z" });
|
|
153
|
+
await insertRun({ systemId: "sys-a", status: "unhealthy", environmentId: null, at: "2026-06-01T05:00:00Z" });
|
|
154
|
+
|
|
155
|
+
const prodOnly = await service.getBulkRunStats({
|
|
156
|
+
systemIds: ["sys-a"],
|
|
157
|
+
startDate: START,
|
|
158
|
+
endDate: END,
|
|
159
|
+
environmentIds: ["env-prod"],
|
|
160
|
+
maxBuckets: 24,
|
|
161
|
+
});
|
|
162
|
+
expect(prodOnly["sys-a"]?.total.runCount).toBe(2);
|
|
163
|
+
expect(prodOnly["sys-a"]?.total.uptimePct).toBe(100);
|
|
164
|
+
|
|
165
|
+
// No env filter counts every run (5 total, 3 healthy = 60%).
|
|
166
|
+
const all = await service.getBulkRunStats({
|
|
167
|
+
systemIds: ["sys-a"],
|
|
168
|
+
startDate: START,
|
|
169
|
+
endDate: END,
|
|
170
|
+
maxBuckets: 24,
|
|
171
|
+
});
|
|
172
|
+
expect(all["sys-a"]?.total.runCount).toBe(5);
|
|
173
|
+
expect(all["sys-a"]?.total.uptimePct).toBe(60);
|
|
174
|
+
|
|
175
|
+
// getRunStats honors the same set filter for the single-system uptime widget.
|
|
176
|
+
const single = await service.getRunStats({
|
|
177
|
+
systemId: "sys-a",
|
|
178
|
+
startDate: START,
|
|
179
|
+
endDate: END,
|
|
180
|
+
environmentIds: ["env-prod", "env-stage"],
|
|
181
|
+
maxBuckets: 24,
|
|
182
|
+
});
|
|
183
|
+
expect(single.total.runCount).toBe(4);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("returns an empty record for an empty request without querying", async () => {
|
|
187
|
+
expect(
|
|
188
|
+
await service.getBulkRunStats({
|
|
189
|
+
systemIds: [],
|
|
190
|
+
startDate: START,
|
|
191
|
+
endDate: END,
|
|
192
|
+
maxBuckets: 1,
|
|
193
|
+
}),
|
|
194
|
+
).toEqual({});
|
|
195
|
+
});
|
|
196
|
+
},
|
|
197
|
+
);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
|
2
2
|
import type { InferSelectModel } from "drizzle-orm";
|
|
3
|
+
import { withTransactionMock } from "@checkstack/test-utils-backend";
|
|
3
4
|
import { HealthCheckService } from "./service";
|
|
4
5
|
import {
|
|
5
6
|
healthCheckRuns,
|
|
@@ -78,10 +79,13 @@ describe("HealthCheckService data ordering", () => {
|
|
|
78
79
|
orderBy: orderByMock,
|
|
79
80
|
}));
|
|
80
81
|
|
|
81
|
-
|
|
82
|
+
// getSystemHealthOverview batches its reads in ONE scoped transaction, so
|
|
83
|
+
// the mock must expose `.transaction(cb)` (runs `cb` against the same mock
|
|
84
|
+
// db). getHistory/getDetailedHistory run standalone and ignore it.
|
|
85
|
+
return withTransactionMock({
|
|
82
86
|
select: mock(() => ({ from: fromMock })),
|
|
83
87
|
$count: mock(() => Promise.resolve(mockRuns.length)),
|
|
84
|
-
};
|
|
88
|
+
});
|
|
85
89
|
}
|
|
86
90
|
|
|
87
91
|
beforeEach(() => {
|
|
@@ -93,6 +93,12 @@ describe("HealthCheckService - paused configuration filtering", () => {
|
|
|
93
93
|
})),
|
|
94
94
|
});
|
|
95
95
|
|
|
96
|
+
// Distinct env keys query (rollup): resolves to no env slices for these
|
|
97
|
+
// fixtures (runs are empty), so the rollup has nothing to evaluate.
|
|
98
|
+
const distinctFrom = Object.assign(Promise.resolve([]), {
|
|
99
|
+
where: mock(() => Promise.resolve([])),
|
|
100
|
+
});
|
|
101
|
+
|
|
96
102
|
let selectCallCount = 0;
|
|
97
103
|
return {
|
|
98
104
|
select: mock(() => {
|
|
@@ -104,6 +110,7 @@ describe("HealthCheckService - paused configuration filtering", () => {
|
|
|
104
110
|
}
|
|
105
111
|
return { from: mock(() => runsFrom) };
|
|
106
112
|
}),
|
|
113
|
+
selectDistinct: mock(() => ({ from: mock(() => distinctFrom) })),
|
|
107
114
|
insert: mock(() => ({
|
|
108
115
|
values: mock(() => ({
|
|
109
116
|
onConflictDoUpdate: mock(() => Promise.resolve()),
|
|
@@ -199,6 +206,11 @@ describe("HealthCheckService - paused configuration filtering", () => {
|
|
|
199
206
|
orderBy: runsOrderBy,
|
|
200
207
|
});
|
|
201
208
|
|
|
209
|
+
// Distinct env keys: a single env-less (null) slice for this env-less check.
|
|
210
|
+
const distinctFrom = Object.assign(Promise.resolve([]), {
|
|
211
|
+
where: mock(() => Promise.resolve([{ environmentId: null }])),
|
|
212
|
+
});
|
|
213
|
+
|
|
202
214
|
let selectCallCount = 0;
|
|
203
215
|
const mockDb = {
|
|
204
216
|
select: mock(() => {
|
|
@@ -208,6 +220,7 @@ describe("HealthCheckService - paused configuration filtering", () => {
|
|
|
208
220
|
}
|
|
209
221
|
return { from: mock(() => runsFrom) };
|
|
210
222
|
}),
|
|
223
|
+
selectDistinct: mock(() => ({ from: mock(() => distinctFrom) })),
|
|
211
224
|
insert: mock(() => ({
|
|
212
225
|
values: mock(() => ({
|
|
213
226
|
onConflictDoUpdate: mock(() => Promise.resolve()),
|