@checkstack/healthcheck-backend 1.16.0 → 1.17.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 +134 -0
- package/package.json +16 -16
- package/src/index.ts +29 -0
- package/src/router.ts +92 -5
- package/src/service.ts +93 -0
- package/src/status-page/widgets.ts +11 -1
- package/src/system-health-override.test.ts +94 -0
- package/src/system-health-override.ts +93 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,139 @@
|
|
|
1
1
|
# @checkstack/healthcheck-backend
|
|
2
2
|
|
|
3
|
+
## 1.17.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 390d9cf: Add a **Container** health-check strategy for monitoring Docker and Podman
|
|
8
|
+
containers that expose no external service of their own. It reports container
|
|
9
|
+
existence, running state, healthcheck status, exit code, restart count, and
|
|
10
|
+
OOM-killed via the **Container Status** collector, and CPU/memory usage via the
|
|
11
|
+
**Container Stats** collector. Both collectors issue only read (GET) requests
|
|
12
|
+
against the runtime REST API.
|
|
13
|
+
|
|
14
|
+
The check runs wherever the executor runs: locally on the core instance (the
|
|
15
|
+
default) to watch containers that share a host with Checkstack, or on a
|
|
16
|
+
satellite pinned to another host.
|
|
17
|
+
|
|
18
|
+
Critically, Checkstack never touches the raw container socket. The strategy
|
|
19
|
+
talks the Docker Engine / Podman libpod API over either a unix socket path or an
|
|
20
|
+
`http(s)` endpoint, so operators point it at a **read-only socket-proxy**
|
|
21
|
+
(`lscr.io/linuxserver/socket-proxy` with `POST=0`) running next to whichever
|
|
22
|
+
Checkstack instance runs the check - core or a satellite - or at a rootless
|
|
23
|
+
Podman socket. The raw socket is mounted only into the proxy; even a compromised
|
|
24
|
+
instance can only read container state, never control the host. A stopped or missing container is a successful collection whose metrics
|
|
25
|
+
feed assertions (following the transport-failure-vs-metric rule) - only an
|
|
26
|
+
unreachable runtime endpoint fails the check. Container `exec` probes are
|
|
27
|
+
intentionally not offered because they would require write access to the socket.
|
|
28
|
+
|
|
29
|
+
To support in-product setup guidance, the health-check strategy contract gains
|
|
30
|
+
an optional `setupInstructions` (Markdown) field, surfaced in the DTO and
|
|
31
|
+
rendered as a collapsible "Setup guide" callout above the strategy config fields
|
|
32
|
+
in the editor. The Container strategy populates it with the secure proxy setup.
|
|
33
|
+
|
|
34
|
+
The hardened socket-proxy compose is maintained as a single canonical file
|
|
35
|
+
(`deploy/socket-proxy/docker-compose.yml`) that operators `include:` from their
|
|
36
|
+
core or satellite compose, so the read-only / `POST=0` / internal-network
|
|
37
|
+
hardening is defined in exactly one place; the docs and the in-product setup
|
|
38
|
+
guide reference it rather than duplicating the YAML.
|
|
39
|
+
|
|
40
|
+
Also removes a stale hand-written `HealthCheckStrategyDto` interface in
|
|
41
|
+
`@checkstack/healthcheck-common` that shadowed (and lagged behind) the
|
|
42
|
+
Zod-inferred DTO; the inferred type from `schemas.ts` is now the single source
|
|
43
|
+
of truth and correctly carries `resultSchema`, `aggregatedResultSchema`, and the
|
|
44
|
+
new `setupInstructions`.
|
|
45
|
+
|
|
46
|
+
Thanks to [@stuajnht](https://github.com/stuajnht) for the valuable feedback
|
|
47
|
+
that shaped this release.
|
|
48
|
+
|
|
49
|
+
- fc64fad: Dependencies can now be scoped to a specific environment and/or health check of
|
|
50
|
+
the upstream system, each with its own severity - a "matrix" of scope cells.
|
|
51
|
+
|
|
52
|
+
Previously a dependency watched the upstream's overall health (any check, any
|
|
53
|
+
environment) at the edge's impact type, with optional per-check rules. That
|
|
54
|
+
default is unchanged: with no scope cells configured, the dependency behaves
|
|
55
|
+
exactly as before. Now each cell pins a check (a specific configuration, or
|
|
56
|
+
"any"), an environment (a specific environment, or "any"), and a severity
|
|
57
|
+
(informational / degraded / critical). When a dependency has any cells, only
|
|
58
|
+
those slices are watched (they replace the whole-system watch) and the worst
|
|
59
|
+
result across cells wins. This lets you express, e.g., "System A depends on
|
|
60
|
+
System B only in `prod`", or "only when B's TLS check in `prod` fails", and lets
|
|
61
|
+
different cells carry different severities.
|
|
62
|
+
|
|
63
|
+
Because each environment is evaluated on its own slice, a scoped dependency
|
|
64
|
+
catches an environment-specific outage that the upstream's overall status
|
|
65
|
+
(worst-wins across environments) would otherwise hide. The dependency evaluator
|
|
66
|
+
now reads per-(check, environment) health via a new
|
|
67
|
+
`@checkstack/healthcheck-common` bulk contract `getBulkSystemHealthMatrix` (and
|
|
68
|
+
its `@checkstack/healthcheck-backend` implementation), which returns each
|
|
69
|
+
system's cross-environment rollup plus a per-environment slice. Incident
|
|
70
|
+
overrides still fold into the overall rollup, so incident-forced statuses keep
|
|
71
|
+
propagating through dependencies.
|
|
72
|
+
|
|
73
|
+
The scope-cell store gains a nullable `environment_id` column and makes
|
|
74
|
+
`health_check_id` nullable (forward-only migration; existing rows keep working
|
|
75
|
+
as "any check, any environment"). The dependency editor's per-check panel
|
|
76
|
+
becomes a scope-matrix editor with check + environment + severity rows.
|
|
77
|
+
|
|
78
|
+
Transitive (multi-hop) dependencies still cascade using the upstream's overall
|
|
79
|
+
status; per-environment cascades across multiple hops are not yet propagated.
|
|
80
|
+
|
|
81
|
+
- 9d30324: Incidents can now optionally override the health status of their affected
|
|
82
|
+
systems. When creating or editing an incident you can pick "Override system
|
|
83
|
+
health" (Degraded or Unhealthy); while the incident is active (not resolved)
|
|
84
|
+
that status is folded into every affected system's derived health via
|
|
85
|
+
worst-wins, so it shows on every health surface (status pages, dashboards,
|
|
86
|
+
dependency map, catalog badges). A health check reporting a worse status still
|
|
87
|
+
wins, and the override lifts automatically when the incident resolves. This
|
|
88
|
+
covers components that no automated check can monitor (e.g. a running app whose
|
|
89
|
+
licenses were revoked so it won't open).
|
|
90
|
+
|
|
91
|
+
The override is a deliberate operator choice, independent of the incident's
|
|
92
|
+
severity. A new service-typed incident RPC `getActiveHealthOverrides` exposes
|
|
93
|
+
active overrides per system, which `@checkstack/healthcheck-backend` reads and
|
|
94
|
+
folds into `getSystemHealthStatus`. The system-health response gains an optional
|
|
95
|
+
`override` field naming the contributing incident so UIs can explain why a
|
|
96
|
+
system reads unhealthy when its checks look fine. The system health badge uses
|
|
97
|
+
it to show, on hover, when a status was forced by an incident.
|
|
98
|
+
|
|
99
|
+
The dashboard "problem system" signal attributes an override-forced status to
|
|
100
|
+
the incident ("Forced by incident: <title>") instead of misreporting
|
|
101
|
+
"0 of N checks failing", while a genuinely worse health check still drives the
|
|
102
|
+
signal and its detail. Public status pages reflect the forced status but never
|
|
103
|
+
carry the incident title (the widget DTOs project only the status), so an
|
|
104
|
+
override cannot leak the name of a hidden incident.
|
|
105
|
+
|
|
106
|
+
Behavior change: a system's derived health now reflects active incident
|
|
107
|
+
overrides in addition to its health checks. Adds a forward-only migration for
|
|
108
|
+
the new nullable `incidents.health_override` column.
|
|
109
|
+
|
|
110
|
+
Thanks to [@stuajnht](https://github.com/stuajnht) for the valuable feedback
|
|
111
|
+
that shaped this release.
|
|
112
|
+
|
|
113
|
+
### Patch Changes
|
|
114
|
+
|
|
115
|
+
- Updated dependencies [390d9cf]
|
|
116
|
+
- Updated dependencies [390d9cf]
|
|
117
|
+
- Updated dependencies [fc64fad]
|
|
118
|
+
- Updated dependencies [fc64fad]
|
|
119
|
+
- Updated dependencies [9d30324]
|
|
120
|
+
- Updated dependencies [9d30324]
|
|
121
|
+
- Updated dependencies [b218e3e]
|
|
122
|
+
- @checkstack/ai-backend@0.10.8
|
|
123
|
+
- @checkstack/backend-api@0.30.0
|
|
124
|
+
- @checkstack/healthcheck-common@1.14.0
|
|
125
|
+
- @checkstack/incident-common@1.8.0
|
|
126
|
+
- @checkstack/incident-backend@1.10.0
|
|
127
|
+
- @checkstack/automation-backend@0.10.10
|
|
128
|
+
- @checkstack/catalog-backend@1.6.8
|
|
129
|
+
- @checkstack/command-backend@0.2.20
|
|
130
|
+
- @checkstack/gitops-backend@0.5.20
|
|
131
|
+
- @checkstack/satellite-backend@0.8.2
|
|
132
|
+
- @checkstack/script-packages-backend@0.3.24
|
|
133
|
+
- @checkstack/secrets-backend@0.3.2
|
|
134
|
+
- @checkstack/status-page-backend@0.4.7
|
|
135
|
+
- @checkstack/sdk@0.125.1
|
|
136
|
+
|
|
3
137
|
## 1.16.0
|
|
4
138
|
|
|
5
139
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/healthcheck-backend",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.17.0",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -14,31 +14,31 @@
|
|
|
14
14
|
"lint:code": "eslint . --max-warnings 0"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@checkstack/ai-backend": "0.10.
|
|
17
|
+
"@checkstack/ai-backend": "0.10.8",
|
|
18
18
|
"@checkstack/ai-common": "0.6.5",
|
|
19
|
-
"@checkstack/automation-backend": "0.10.
|
|
20
|
-
"@checkstack/backend-api": "0.
|
|
19
|
+
"@checkstack/automation-backend": "0.10.10",
|
|
20
|
+
"@checkstack/backend-api": "0.30.0",
|
|
21
21
|
"@checkstack/cache-api": "0.3.18",
|
|
22
22
|
"@checkstack/cache-utils": "0.2.23",
|
|
23
|
-
"@checkstack/catalog-backend": "1.6.
|
|
23
|
+
"@checkstack/catalog-backend": "1.6.8",
|
|
24
24
|
"@checkstack/catalog-common": "2.6.2",
|
|
25
|
-
"@checkstack/command-backend": "0.2.
|
|
25
|
+
"@checkstack/command-backend": "0.2.20",
|
|
26
26
|
"@checkstack/common": "0.21.0",
|
|
27
|
-
"@checkstack/gitops-backend": "0.5.
|
|
27
|
+
"@checkstack/gitops-backend": "0.5.20",
|
|
28
28
|
"@checkstack/gitops-common": "0.7.2",
|
|
29
|
-
"@checkstack/healthcheck-common": "1.
|
|
30
|
-
"@checkstack/incident-backend": "1.
|
|
31
|
-
"@checkstack/incident-common": "1.
|
|
29
|
+
"@checkstack/healthcheck-common": "1.14.0",
|
|
30
|
+
"@checkstack/incident-backend": "1.10.0",
|
|
31
|
+
"@checkstack/incident-common": "1.8.0",
|
|
32
32
|
"@checkstack/maintenance-common": "1.8.2",
|
|
33
33
|
"@checkstack/notification-common": "1.5.2",
|
|
34
34
|
"@checkstack/queue-api": "0.3.18",
|
|
35
|
-
"@checkstack/satellite-backend": "0.8.
|
|
36
|
-
"@checkstack/script-packages-backend": "0.3.
|
|
37
|
-
"@checkstack/sdk": "0.
|
|
38
|
-
"@checkstack/secrets-backend": "0.3.
|
|
35
|
+
"@checkstack/satellite-backend": "0.8.2",
|
|
36
|
+
"@checkstack/script-packages-backend": "0.3.24",
|
|
37
|
+
"@checkstack/sdk": "0.125.1",
|
|
38
|
+
"@checkstack/secrets-backend": "0.3.2",
|
|
39
39
|
"@checkstack/secrets-common": "0.3.1",
|
|
40
40
|
"@checkstack/signal-common": "0.2.16",
|
|
41
|
-
"@checkstack/status-page-backend": "0.4.
|
|
41
|
+
"@checkstack/status-page-backend": "0.4.7",
|
|
42
42
|
"@checkstack/status-page-common": "0.5.2",
|
|
43
43
|
"@hono/zod-validator": "^0.7.6",
|
|
44
44
|
"@orpc/contract": "^1.14.4",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@checkstack/drizzle-helper": "0.0.6",
|
|
55
55
|
"@checkstack/scripts": "0.7.2",
|
|
56
|
-
"@checkstack/test-utils-backend": "0.1.
|
|
56
|
+
"@checkstack/test-utils-backend": "0.1.54",
|
|
57
57
|
"@checkstack/tsconfig": "0.0.7",
|
|
58
58
|
"@types/bun": "^1.0.0",
|
|
59
59
|
"@types/tdigest": "^0.1.5",
|
package/src/index.ts
CHANGED
|
@@ -524,6 +524,35 @@ export default createBackendPlugin({
|
|
|
524
524
|
logger,
|
|
525
525
|
signalService,
|
|
526
526
|
configSecrets,
|
|
527
|
+
// Fold active incident health overrides into the user-facing system
|
|
528
|
+
// health reads (worst-wins). Reuses the incident client already built
|
|
529
|
+
// above; maps the incident rows to the source-agnostic override shape
|
|
530
|
+
// the fold expects. Kept out of the shared deriver so SLO/AI/entity
|
|
531
|
+
// paths stay checks-only (see router.ts).
|
|
532
|
+
incidentHealthOverrideReader: {
|
|
533
|
+
getActiveOverrides: async (systemIds) => {
|
|
534
|
+
const { overrides } =
|
|
535
|
+
await incidentClient.getActiveHealthOverrides({ systemIds });
|
|
536
|
+
const mapped: Record<
|
|
537
|
+
string,
|
|
538
|
+
{
|
|
539
|
+
status: "degraded" | "unhealthy";
|
|
540
|
+
source: string;
|
|
541
|
+
reason: string;
|
|
542
|
+
sourceId?: string;
|
|
543
|
+
}[]
|
|
544
|
+
> = {};
|
|
545
|
+
for (const [systemId, list] of Object.entries(overrides)) {
|
|
546
|
+
mapped[systemId] = list.map((o) => ({
|
|
547
|
+
status: o.status,
|
|
548
|
+
source: "incident",
|
|
549
|
+
reason: o.incidentTitle,
|
|
550
|
+
sourceId: o.incidentId,
|
|
551
|
+
}));
|
|
552
|
+
}
|
|
553
|
+
return mapped;
|
|
554
|
+
},
|
|
555
|
+
},
|
|
527
556
|
recomputeSystemRollupHealth: (systemId) =>
|
|
528
557
|
recomputeSystemRollupHealth({
|
|
529
558
|
systemId,
|
package/src/router.ts
CHANGED
|
@@ -39,6 +39,11 @@ import { CatalogApi } from "@checkstack/catalog-common";
|
|
|
39
39
|
import { MaintenanceApi } from "@checkstack/maintenance-common";
|
|
40
40
|
import type { Logger } from "@checkstack/backend-api";
|
|
41
41
|
import type { HealthCheckCache } from "./cache";
|
|
42
|
+
import {
|
|
43
|
+
applySystemHealthOverrides,
|
|
44
|
+
type SystemHealthOverrideReader,
|
|
45
|
+
} from "./system-health-override";
|
|
46
|
+
import type { SystemHealthStatusResponse } from "@checkstack/healthcheck-common";
|
|
42
47
|
|
|
43
48
|
/**
|
|
44
49
|
* Creates the healthcheck router using contract-based implementation.
|
|
@@ -81,6 +86,17 @@ export const createHealthCheckRouter = (opts: {
|
|
|
81
86
|
* router MUST receive it or writes would store inline secrets verbatim.
|
|
82
87
|
*/
|
|
83
88
|
configSecrets?: HealthCheckSecretsDeps;
|
|
89
|
+
/**
|
|
90
|
+
* Reads active incident health overrides and folds them into the two
|
|
91
|
+
* user-facing system-health reads (single + bulk) via worst-wins, so a system
|
|
92
|
+
* shows the status an active incident forces even when its checks look fine.
|
|
93
|
+
* Applied OUTSIDE the status cache (always live, so an override lifts the
|
|
94
|
+
* instant its incident resolves) and ONLY in these RPC handlers - never in the
|
|
95
|
+
* shared `getSystemHealthStatus` deriver, whose other callers (SLO downtime,
|
|
96
|
+
* the AI signals scan, the persisted `health` entity) must stay checks-only.
|
|
97
|
+
* Optional so tests / no-incident deployments simply skip the fold.
|
|
98
|
+
*/
|
|
99
|
+
incidentHealthOverrideReader?: SystemHealthOverrideReader;
|
|
84
100
|
}) => {
|
|
85
101
|
const {
|
|
86
102
|
database,
|
|
@@ -94,6 +110,7 @@ export const createHealthCheckRouter = (opts: {
|
|
|
94
110
|
logger,
|
|
95
111
|
signalService,
|
|
96
112
|
recomputeSystemRollupHealth,
|
|
113
|
+
incidentHealthOverrideReader,
|
|
97
114
|
} = opts;
|
|
98
115
|
// Create service instance once - shared across all handlers
|
|
99
116
|
const service = new HealthCheckService(
|
|
@@ -105,6 +122,43 @@ export const createHealthCheckRouter = (opts: {
|
|
|
105
122
|
opts.configSecrets,
|
|
106
123
|
);
|
|
107
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Fold active incident health overrides into a batch of checks-only system
|
|
127
|
+
* statuses via worst-wins. Reads overrides for all systems in ONE incident RPC
|
|
128
|
+
* (or none, when no reader is wired). Resilient by design: incidents are a
|
|
129
|
+
* best-effort enrichment of health, so if the read fails the checks-only
|
|
130
|
+
* statuses are returned unchanged rather than failing the whole health read.
|
|
131
|
+
*/
|
|
132
|
+
const foldIncidentOverrides = async (
|
|
133
|
+
statuses: Record<string, SystemHealthStatusResponse>,
|
|
134
|
+
): Promise<Record<string, SystemHealthStatusResponse>> => {
|
|
135
|
+
const systemIds = Object.keys(statuses);
|
|
136
|
+
if (!incidentHealthOverrideReader || systemIds.length === 0) {
|
|
137
|
+
return statuses;
|
|
138
|
+
}
|
|
139
|
+
let overridesBySystem: Awaited<
|
|
140
|
+
ReturnType<SystemHealthOverrideReader["getActiveOverrides"]>
|
|
141
|
+
>;
|
|
142
|
+
try {
|
|
143
|
+
overridesBySystem =
|
|
144
|
+
await incidentHealthOverrideReader.getActiveOverrides(systemIds);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
logger.warn(
|
|
147
|
+
"Failed to read incident health overrides; returning checks-only status",
|
|
148
|
+
{ error: extractErrorMessage(error) },
|
|
149
|
+
);
|
|
150
|
+
return statuses;
|
|
151
|
+
}
|
|
152
|
+
const folded: Record<string, SystemHealthStatusResponse> = {};
|
|
153
|
+
for (const [systemId, base] of Object.entries(statuses)) {
|
|
154
|
+
folded[systemId] = applySystemHealthOverrides({
|
|
155
|
+
base,
|
|
156
|
+
overrides: overridesBySystem[systemId] ?? [],
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return folded;
|
|
160
|
+
};
|
|
161
|
+
|
|
108
162
|
// Create contract implementer with context type AND auto auth middleware
|
|
109
163
|
const os = implement(healthCheckContract)
|
|
110
164
|
.$context<RpcContext>()
|
|
@@ -201,6 +255,7 @@ export const createHealthCheckRouter = (opts: {
|
|
|
201
255
|
displayName: r.strategy.displayName,
|
|
202
256
|
description: r.strategy.description,
|
|
203
257
|
category: (r.strategy.category ?? "other") as StrategyCategory,
|
|
258
|
+
setupInstructions: r.strategy.setupInstructions,
|
|
204
259
|
configSchema: toJsonSchema(r.strategy.config.schema),
|
|
205
260
|
resultSchema: r.strategy.result
|
|
206
261
|
? toJsonSchemaWithChartMeta(r.strategy.result.schema)
|
|
@@ -622,9 +677,13 @@ export const createHealthCheckRouter = (opts: {
|
|
|
622
677
|
),
|
|
623
678
|
getSystemHealthStatus: os.getSystemHealthStatus.handler(
|
|
624
679
|
async ({ input }) => {
|
|
625
|
-
|
|
680
|
+
const base = await cache.wrapSystemHealthStatus(input.systemId, () =>
|
|
626
681
|
service.getSystemHealthStatus(input.systemId),
|
|
627
682
|
);
|
|
683
|
+
const folded = await foldIncidentOverrides({
|
|
684
|
+
[input.systemId]: base,
|
|
685
|
+
});
|
|
686
|
+
return folded[input.systemId]!;
|
|
628
687
|
},
|
|
629
688
|
),
|
|
630
689
|
|
|
@@ -634,10 +693,7 @@ export const createHealthCheckRouter = (opts: {
|
|
|
634
693
|
// and invalidated by id on mutations, so dashboards with overlapping
|
|
635
694
|
// (but non-identical) system sets share cache entries. See
|
|
636
695
|
// ./cache.ts for the key/TTL/invalidation contract.
|
|
637
|
-
const statuses: Record<
|
|
638
|
-
string,
|
|
639
|
-
Awaited<ReturnType<typeof service.getSystemHealthStatus>>
|
|
640
|
-
> = {};
|
|
696
|
+
const statuses: Record<string, SystemHealthStatusResponse> = {};
|
|
641
697
|
await Promise.all(
|
|
642
698
|
input.systemIds.map(async (systemId) => {
|
|
643
699
|
statuses[systemId] = await cache.wrapSystemHealthStatus(
|
|
@@ -646,6 +702,37 @@ export const createHealthCheckRouter = (opts: {
|
|
|
646
702
|
);
|
|
647
703
|
}),
|
|
648
704
|
);
|
|
705
|
+
return { statuses: await foldIncidentOverrides(statuses) };
|
|
706
|
+
},
|
|
707
|
+
),
|
|
708
|
+
|
|
709
|
+
getBulkSystemHealthMatrix: os.getBulkSystemHealthMatrix.handler(
|
|
710
|
+
async ({ input }) => {
|
|
711
|
+
const matrix = await service.getBulkSystemHealthMatrix(input.systemIds);
|
|
712
|
+
|
|
713
|
+
// Fold active incident overrides into each system's OVERALL rollup, so
|
|
714
|
+
// an incident-forced status still propagates through dependencies (as
|
|
715
|
+
// it does via getBulkSystemHealthStatus). Per-environment slices track
|
|
716
|
+
// health-check status only - incidents force whole-system health, which
|
|
717
|
+
// any-environment (env=null) dependency cells read from this rollup.
|
|
718
|
+
const overallOnly: Record<string, SystemHealthStatusResponse> = {};
|
|
719
|
+
for (const [systemId, m] of Object.entries(matrix)) {
|
|
720
|
+
overallOnly[systemId] = {
|
|
721
|
+
status: m.status,
|
|
722
|
+
evaluatedAt: new Date(),
|
|
723
|
+
checkStatuses: m.checkStatuses,
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
const folded = await foldIncidentOverrides(overallOnly);
|
|
727
|
+
|
|
728
|
+
const statuses: Record<string, (typeof matrix)[string]> = {};
|
|
729
|
+
for (const [systemId, m] of Object.entries(matrix)) {
|
|
730
|
+
statuses[systemId] = {
|
|
731
|
+
status: folded[systemId]?.status ?? m.status,
|
|
732
|
+
checkStatuses: m.checkStatuses,
|
|
733
|
+
environments: m.environments,
|
|
734
|
+
};
|
|
735
|
+
}
|
|
649
736
|
return { statuses };
|
|
650
737
|
},
|
|
651
738
|
),
|
package/src/service.ts
CHANGED
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
gte,
|
|
42
42
|
lte,
|
|
43
43
|
isNull,
|
|
44
|
+
isNotNull,
|
|
44
45
|
inArray,
|
|
45
46
|
} from "drizzle-orm";
|
|
46
47
|
import { ORPCError } from "@orpc/server";
|
|
@@ -1341,6 +1342,98 @@ export class HealthCheckService {
|
|
|
1341
1342
|
return Object.fromEntries(entries);
|
|
1342
1343
|
}
|
|
1343
1344
|
|
|
1345
|
+
/**
|
|
1346
|
+
* Bulk per-(system, check, environment) health for the given systems.
|
|
1347
|
+
*
|
|
1348
|
+
* For each system returns the cross-environment rollup (status +
|
|
1349
|
+
* checkStatuses, same as {@link getSystemHealthStatus}) PLUS a slice per
|
|
1350
|
+
* environment the system has runs for. Consumers that scope by environment
|
|
1351
|
+
* (the dependency map) must read the per-environment slice, because the
|
|
1352
|
+
* rollup deliberately hides a single failing environment.
|
|
1353
|
+
*
|
|
1354
|
+
* Cost scales with the number of environments each system actually fans out
|
|
1355
|
+
* to (`1 + #envs` status evaluations per system); systems with only env-less
|
|
1356
|
+
* runs cost the same as a plain rollup read. Not on any per-run hot path.
|
|
1357
|
+
*/
|
|
1358
|
+
async getBulkSystemHealthMatrix(systemIds: string[]): Promise<
|
|
1359
|
+
Record<
|
|
1360
|
+
string,
|
|
1361
|
+
{
|
|
1362
|
+
status: HealthCheckStatus;
|
|
1363
|
+
checkStatuses: SystemHealthStatusResponse["checkStatuses"];
|
|
1364
|
+
environments: Record<
|
|
1365
|
+
string,
|
|
1366
|
+
{
|
|
1367
|
+
status: HealthCheckStatus;
|
|
1368
|
+
checkStatuses: SystemHealthStatusResponse["checkStatuses"];
|
|
1369
|
+
}
|
|
1370
|
+
>;
|
|
1371
|
+
}
|
|
1372
|
+
>
|
|
1373
|
+
> {
|
|
1374
|
+
const result: Record<
|
|
1375
|
+
string,
|
|
1376
|
+
{
|
|
1377
|
+
status: HealthCheckStatus;
|
|
1378
|
+
checkStatuses: SystemHealthStatusResponse["checkStatuses"];
|
|
1379
|
+
environments: Record<
|
|
1380
|
+
string,
|
|
1381
|
+
{
|
|
1382
|
+
status: HealthCheckStatus;
|
|
1383
|
+
checkStatuses: SystemHealthStatusResponse["checkStatuses"];
|
|
1384
|
+
}
|
|
1385
|
+
>;
|
|
1386
|
+
}
|
|
1387
|
+
> = {};
|
|
1388
|
+
|
|
1389
|
+
await Promise.all(
|
|
1390
|
+
systemIds.map(async (systemId) => {
|
|
1391
|
+
const overall = await this.getSystemHealthStatus(systemId);
|
|
1392
|
+
|
|
1393
|
+
// Environments this system actually has runs for (env-less excluded -
|
|
1394
|
+
// it is folded into the rollup and never a real environment id).
|
|
1395
|
+
const envRows = await this.db
|
|
1396
|
+
.selectDistinct({ environmentId: healthCheckRuns.environmentId })
|
|
1397
|
+
.from(healthCheckRuns)
|
|
1398
|
+
.where(
|
|
1399
|
+
and(
|
|
1400
|
+
eq(healthCheckRuns.systemId, systemId),
|
|
1401
|
+
isNotNull(healthCheckRuns.environmentId),
|
|
1402
|
+
),
|
|
1403
|
+
);
|
|
1404
|
+
|
|
1405
|
+
const environments: Record<
|
|
1406
|
+
string,
|
|
1407
|
+
{
|
|
1408
|
+
status: HealthCheckStatus;
|
|
1409
|
+
checkStatuses: SystemHealthStatusResponse["checkStatuses"];
|
|
1410
|
+
}
|
|
1411
|
+
> = {};
|
|
1412
|
+
await Promise.all(
|
|
1413
|
+
envRows.map(async ({ environmentId }) => {
|
|
1414
|
+
if (!environmentId) return;
|
|
1415
|
+
const envStatus = await this.getSystemHealthStatus(
|
|
1416
|
+
systemId,
|
|
1417
|
+
environmentId,
|
|
1418
|
+
);
|
|
1419
|
+
environments[environmentId] = {
|
|
1420
|
+
status: envStatus.status,
|
|
1421
|
+
checkStatuses: envStatus.checkStatuses,
|
|
1422
|
+
};
|
|
1423
|
+
}),
|
|
1424
|
+
);
|
|
1425
|
+
|
|
1426
|
+
result[systemId] = {
|
|
1427
|
+
status: overall.status,
|
|
1428
|
+
checkStatuses: overall.checkStatuses,
|
|
1429
|
+
environments,
|
|
1430
|
+
};
|
|
1431
|
+
}),
|
|
1432
|
+
);
|
|
1433
|
+
|
|
1434
|
+
return result;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1344
1437
|
/**
|
|
1345
1438
|
* Get comprehensive health overview for a system.
|
|
1346
1439
|
* Returns all health checks with their last 25 runs for sparkline visualization.
|
|
@@ -91,7 +91,17 @@ async function healthStatuses(
|
|
|
91
91
|
const { statuses } = await ctx.rpcClient
|
|
92
92
|
.forPlugin(HealthCheckApi)
|
|
93
93
|
.getBulkSystemHealthStatus({ systemIds: ids });
|
|
94
|
-
|
|
94
|
+
// Project to ONLY the derived status. `getBulkSystemHealthStatus` folds active
|
|
95
|
+
// incident overrides into `status` (so the public page shows the forced
|
|
96
|
+
// status), but the response also carries `override.reason` = the incident
|
|
97
|
+
// TITLE. Status pages are public and incidents may be hidden, so we drop
|
|
98
|
+
// everything but the status here - the incident name must never reach a public
|
|
99
|
+
// widget DTO.
|
|
100
|
+
const projected: Record<string, { status: string } | undefined> = {};
|
|
101
|
+
for (const [systemId, value] of Object.entries(statuses)) {
|
|
102
|
+
projected[systemId] = value ? { status: value.status } : undefined;
|
|
103
|
+
}
|
|
104
|
+
return projected;
|
|
95
105
|
}
|
|
96
106
|
|
|
97
107
|
function publicStatus({
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
applySystemHealthOverrides,
|
|
4
|
+
worstHealthStatus,
|
|
5
|
+
type SystemHealthOverrideInput,
|
|
6
|
+
} from "./system-health-override";
|
|
7
|
+
import type { SystemHealthStatusResponse } from "@checkstack/healthcheck-common";
|
|
8
|
+
|
|
9
|
+
const at = new Date("2026-07-05T00:00:00.000Z");
|
|
10
|
+
|
|
11
|
+
function base(
|
|
12
|
+
status: SystemHealthStatusResponse["status"],
|
|
13
|
+
): SystemHealthStatusResponse {
|
|
14
|
+
return { status, evaluatedAt: at, checkStatuses: [] };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function override(
|
|
18
|
+
status: "degraded" | "unhealthy",
|
|
19
|
+
sourceId: string,
|
|
20
|
+
): SystemHealthOverrideInput {
|
|
21
|
+
return {
|
|
22
|
+
status,
|
|
23
|
+
source: "incident",
|
|
24
|
+
reason: `Incident ${sourceId}`,
|
|
25
|
+
sourceId,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("worstHealthStatus", () => {
|
|
30
|
+
it("orders unhealthy > degraded > healthy", () => {
|
|
31
|
+
expect(worstHealthStatus("healthy", "degraded")).toBe("degraded");
|
|
32
|
+
expect(worstHealthStatus("degraded", "unhealthy")).toBe("unhealthy");
|
|
33
|
+
expect(worstHealthStatus("unhealthy", "degraded")).toBe("unhealthy");
|
|
34
|
+
expect(worstHealthStatus("healthy", "healthy")).toBe("healthy");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("applySystemHealthOverrides", () => {
|
|
39
|
+
it("returns the base unchanged (no override field) when there are no overrides", () => {
|
|
40
|
+
const result = applySystemHealthOverrides({
|
|
41
|
+
base: base("healthy"),
|
|
42
|
+
overrides: [],
|
|
43
|
+
});
|
|
44
|
+
expect(result.status).toBe("healthy");
|
|
45
|
+
expect(result.override).toBeUndefined();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("raises a healthy system to the override status and records the source", () => {
|
|
49
|
+
const result = applySystemHealthOverrides({
|
|
50
|
+
base: base("healthy"),
|
|
51
|
+
overrides: [override("unhealthy", "inc-1")],
|
|
52
|
+
});
|
|
53
|
+
expect(result.status).toBe("unhealthy");
|
|
54
|
+
expect(result.override).toEqual({
|
|
55
|
+
status: "unhealthy",
|
|
56
|
+
source: "incident",
|
|
57
|
+
reason: "Incident inc-1",
|
|
58
|
+
sourceId: "inc-1",
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("applies an override even when the system has no health checks", () => {
|
|
63
|
+
const result = applySystemHealthOverrides({
|
|
64
|
+
base: base("healthy"), // no-checks default is healthy
|
|
65
|
+
overrides: [override("degraded", "inc-2")],
|
|
66
|
+
});
|
|
67
|
+
expect(result.status).toBe("degraded");
|
|
68
|
+
expect(result.override?.status).toBe("degraded");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("keeps the worse HEALTH CHECK status when a check is worse than the override", () => {
|
|
72
|
+
// Override says degraded, but a check reports unhealthy -> unhealthy wins.
|
|
73
|
+
const result = applySystemHealthOverrides({
|
|
74
|
+
base: base("unhealthy"),
|
|
75
|
+
overrides: [override("degraded", "inc-3")],
|
|
76
|
+
});
|
|
77
|
+
expect(result.status).toBe("unhealthy");
|
|
78
|
+
// The override is still surfaced (it contributed), at its own status.
|
|
79
|
+
expect(result.override?.status).toBe("degraded");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("picks the worst override when several incidents override the same system", () => {
|
|
83
|
+
const result = applySystemHealthOverrides({
|
|
84
|
+
base: base("healthy"),
|
|
85
|
+
overrides: [
|
|
86
|
+
override("degraded", "inc-a"),
|
|
87
|
+
override("unhealthy", "inc-b"),
|
|
88
|
+
override("degraded", "inc-c"),
|
|
89
|
+
],
|
|
90
|
+
});
|
|
91
|
+
expect(result.status).toBe("unhealthy");
|
|
92
|
+
expect(result.override?.sourceId).toBe("inc-b");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
HealthCheckStatus,
|
|
3
|
+
SystemHealthStatusResponse,
|
|
4
|
+
SystemHealthOverride,
|
|
5
|
+
} from "@checkstack/healthcheck-common";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Worst-wins ordering for the derived health vocabulary: a higher rank is a
|
|
9
|
+
* worse status. Mirrors the inline `unhealthy > degraded > healthy` ordering the
|
|
10
|
+
* per-check rollup uses in `service.ts`, extracted so the incident-override fold
|
|
11
|
+
* shares the exact same comparison.
|
|
12
|
+
*/
|
|
13
|
+
const HEALTH_RANK: Record<HealthCheckStatus, number> = {
|
|
14
|
+
healthy: 0,
|
|
15
|
+
degraded: 1,
|
|
16
|
+
unhealthy: 2,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Returns whichever status is worse (ties return `a`). */
|
|
20
|
+
export function worstHealthStatus(
|
|
21
|
+
a: HealthCheckStatus,
|
|
22
|
+
b: HealthCheckStatus,
|
|
23
|
+
): HealthCheckStatus {
|
|
24
|
+
return HEALTH_RANK[b] > HEALTH_RANK[a] ? b : a;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A non-health-check contribution to a system's health, as consumed by the
|
|
29
|
+
* fold. Kept source-agnostic (`source`/`sourceId`) so the health plugin does not
|
|
30
|
+
* hard-code incident semantics; the incident-backed reader maps its rows into
|
|
31
|
+
* this shape.
|
|
32
|
+
*/
|
|
33
|
+
export interface SystemHealthOverrideInput {
|
|
34
|
+
/** The status this contributor forces. Never `healthy` in practice. */
|
|
35
|
+
status: HealthCheckStatus;
|
|
36
|
+
/** Contributor kind, e.g. "incident". */
|
|
37
|
+
source: string;
|
|
38
|
+
/** Human-readable reason, e.g. the incident title. */
|
|
39
|
+
reason: string;
|
|
40
|
+
/** Opaque id of the contributing record, e.g. the incident id. */
|
|
41
|
+
sourceId?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Reads active health overrides for a set of systems. Implemented in the plugin
|
|
46
|
+
* wiring over the incident RPC; injected into the health service so the service
|
|
47
|
+
* stays free of a direct incident dependency and tests can stub it.
|
|
48
|
+
*/
|
|
49
|
+
export interface SystemHealthOverrideReader {
|
|
50
|
+
getActiveOverrides(
|
|
51
|
+
systemIds: string[],
|
|
52
|
+
): Promise<Record<string, SystemHealthOverrideInput[]>>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Fold a system's active overrides into its health-check-derived status via
|
|
57
|
+
* worst-wins. The overall status becomes the worst of the checks-derived status
|
|
58
|
+
* and every override, so an override that raises a system to `degraded` never
|
|
59
|
+
* masks a health check reporting `unhealthy` (the worse status always wins), and
|
|
60
|
+
* an override applies even when the system has no health checks at all.
|
|
61
|
+
*
|
|
62
|
+
* The worst contributing override (if any) is surfaced on `override` so a UI can
|
|
63
|
+
* explain why a system reads worse than its checks alone. When the overrides are
|
|
64
|
+
* empty the base response is returned unchanged (no `override`).
|
|
65
|
+
*/
|
|
66
|
+
export function applySystemHealthOverrides({
|
|
67
|
+
base,
|
|
68
|
+
overrides,
|
|
69
|
+
}: {
|
|
70
|
+
base: SystemHealthStatusResponse;
|
|
71
|
+
overrides: SystemHealthOverrideInput[];
|
|
72
|
+
}): SystemHealthStatusResponse {
|
|
73
|
+
if (overrides.length === 0) return base;
|
|
74
|
+
|
|
75
|
+
// The override that reads worst wins the `override` slot; a tie keeps the
|
|
76
|
+
// first (query order), which for incidents is a stable, arbitrary pick.
|
|
77
|
+
let worst = overrides[0]!;
|
|
78
|
+
for (const candidate of overrides) {
|
|
79
|
+
if (HEALTH_RANK[candidate.status] > HEALTH_RANK[worst.status]) {
|
|
80
|
+
worst = candidate;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const status = worstHealthStatus(base.status, worst.status);
|
|
85
|
+
const override: SystemHealthOverride = {
|
|
86
|
+
status: worst.status,
|
|
87
|
+
source: worst.source,
|
|
88
|
+
reason: worst.reason,
|
|
89
|
+
sourceId: worst.sourceId,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
return { ...base, status, override };
|
|
93
|
+
}
|