@checkstack/healthcheck-backend 1.18.0 → 1.20.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 +484 -0
- package/drizzle/0019_chemical_frightful_four.sql +8 -0
- package/drizzle/0020_certain_mordo.sql +2 -0
- package/drizzle/meta/0019_snapshot.json +661 -0
- package/drizzle/meta/0020_snapshot.json +711 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +23 -21
- package/src/ai/system-signals-contributor.test.ts +33 -9
- package/src/ai/system-signals-contributor.ts +38 -16
- package/src/cache-test-stub.ts +26 -0
- package/src/cache.test.ts +291 -0
- package/src/cache.ts +204 -34
- package/src/health-notification-content.test.ts +111 -0
- package/src/health-notification-content.ts +145 -0
- package/src/healthcheck-gitops-kinds.test.ts +14 -0
- package/src/healthcheck-gitops-kinds.ts +27 -0
- package/src/index.ts +31 -12
- package/src/queue-executor.test.ts +13 -26
- package/src/queue-executor.ts +125 -112
- package/src/retention-job.ts +8 -0
- package/src/rollup-consumer.test.ts +19 -8
- package/src/router-config-secrets.test.ts +2 -7
- package/src/router-create-and-assign.test.ts +2 -7
- package/src/router-pause-recompute.test.ts +2 -7
- package/src/router.test.ts +3 -8
- package/src/router.ts +43 -15
- package/src/schema.ts +74 -31
- 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 +408 -284
- package/src/status-fingerprint.test.ts +92 -0
- package/src/status-fingerprint.ts +66 -0
- package/src/status-page/rollup.test.ts +40 -0
- package/src/status-page/rollup.ts +27 -0
- package/src/status-page/widgets.test.ts +387 -0
- package/src/status-page/widgets.ts +236 -39
package/src/cache.ts
CHANGED
|
@@ -1,57 +1,145 @@
|
|
|
1
1
|
import type { CacheManager } from "@checkstack/cache-api";
|
|
2
|
-
import {
|
|
3
|
-
createCachedScope,
|
|
4
|
-
type CachedScope,
|
|
5
|
-
} from "@checkstack/cache-utils";
|
|
2
|
+
import { createCachedScope, type CachedScope } from "@checkstack/cache-utils";
|
|
6
3
|
import type { Logger } from "@checkstack/backend-api";
|
|
7
|
-
import type {
|
|
4
|
+
import type { SystemHealthStatusResponse } from "@checkstack/healthcheck-common";
|
|
5
|
+
import { statusVectorChanged } from "./status-fingerprint";
|
|
8
6
|
|
|
9
7
|
/**
|
|
10
|
-
* TTL
|
|
11
|
-
*
|
|
12
|
-
* a
|
|
8
|
+
* TTL for a cached status entry. With a distributed backend (Redis) configured,
|
|
9
|
+
* the TTL is only a natural refresh / safety net — cross-pod coherence comes
|
|
10
|
+
* from the SHARED store (a `delete` on one pod is visible to all), not from the
|
|
11
|
+
* TTL. On the default in-memory backend the entry is per-pod; that backend is
|
|
12
|
+
* for single-instance deployments (see the caching-architecture docs).
|
|
13
13
|
*/
|
|
14
14
|
const STATUS_TTL_MS = 15_000;
|
|
15
15
|
|
|
16
|
+
const STATUS_KEY_PREFIX = "status:";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Per-(system, environment) cache key.
|
|
20
|
+
*
|
|
21
|
+
* `environmentId` collapses `undefined` (the system ROLLUP, all runs) and `null`
|
|
22
|
+
* (the env-less slice) to the SAME bare key `status:<systemId>`, because an
|
|
23
|
+
* env-less run IS the rollup (it mutates the bare `<systemId>` entity). A real
|
|
24
|
+
* environment id gets its own `status:<systemId>:<environmentId>` key. This
|
|
25
|
+
* matches the (systemId, environmentId) tuple both the reader
|
|
26
|
+
* (`getSystemHealthStatus`) and the executor's per-env write use.
|
|
27
|
+
*/
|
|
28
|
+
function statusKey(systemId: string, environmentId?: string | null): string {
|
|
29
|
+
return environmentId === undefined || environmentId === null
|
|
30
|
+
? `${STATUS_KEY_PREFIX}${systemId}`
|
|
31
|
+
: `${STATUS_KEY_PREFIX}${systemId}:${environmentId}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Prefix covering a single system's rollup key AND all its per-env keys. */
|
|
35
|
+
function systemPrefix(systemId: string): string {
|
|
36
|
+
return `${STATUS_KEY_PREFIX}${systemId}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
16
39
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
40
|
+
* Minimal read surface the cache needs. `HealthCheckService` satisfies it
|
|
41
|
+
* structurally; a narrow interface keeps the cache testable with a stub and
|
|
42
|
+
* documents that the cache is the ONLY sanctioned caller of the raw
|
|
43
|
+
* `getSystemHealthStatus` read (enforced by the `no-direct-system-status-read`
|
|
44
|
+
* lint rule everywhere except this module and the executor/entity compute
|
|
45
|
+
* paths).
|
|
46
|
+
*/
|
|
47
|
+
export interface HealthStatusReader {
|
|
48
|
+
getSystemHealthStatus(
|
|
49
|
+
systemId: string,
|
|
50
|
+
environmentId?: string | null,
|
|
51
|
+
): Promise<SystemHealthStatusResponse>;
|
|
52
|
+
/** Distinct environment ids a system currently has runs for (env-less excluded). */
|
|
53
|
+
getSystemEnvironmentIds(systemId: string): Promise<string[]>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Per-(system, check, environment) matrix — see {@link HealthCheckCache.readMatrix}. */
|
|
57
|
+
export type SystemHealthMatrix = Record<
|
|
58
|
+
string,
|
|
59
|
+
{
|
|
60
|
+
status: SystemHealthStatusResponse["status"];
|
|
61
|
+
checkStatuses: SystemHealthStatusResponse["checkStatuses"];
|
|
62
|
+
environments: Record<
|
|
63
|
+
string,
|
|
64
|
+
{
|
|
65
|
+
status: SystemHealthStatusResponse["status"];
|
|
66
|
+
checkStatuses: SystemHealthStatusResponse["checkStatuses"];
|
|
67
|
+
}
|
|
68
|
+
>;
|
|
69
|
+
}
|
|
70
|
+
>;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The system-health status cache — a platform interface that is the SINGLE
|
|
74
|
+
* sanctioned reader AND invalidator of a system's derived health status.
|
|
75
|
+
*
|
|
76
|
+
* It is built on the platform {@link CacheManager} (via {@link createCachedScope}),
|
|
77
|
+
* so the active backend is a per-deployment choice: the default in-memory
|
|
78
|
+
* backend (per-pod, single-instance) or a distributed backend such as Redis
|
|
79
|
+
* (shared across pods). Cross-pod coherence therefore comes from the SHARED
|
|
80
|
+
* store — an `invalidate` is a `delete` every pod sees — NOT from any
|
|
81
|
+
* application-level broadcast. Horizontal scaling requires a distributed backend
|
|
82
|
+
* (see `docs/.../architecture/caching.md`).
|
|
83
|
+
*
|
|
84
|
+
* Reads (`read` / `readBulk` / `readMatrix`) serve the RAW (pre-incident-override)
|
|
85
|
+
* status; the router folds incident overrides downstream, so an incident change
|
|
86
|
+
* never touches this cache. `reconcile` is the hot-path invalidator: it evicts
|
|
87
|
+
* ONLY when the per-check status vector actually changed ({@link statusVectorChanged}),
|
|
88
|
+
* so a run that merely refreshes timestamps keeps the cache warm.
|
|
21
89
|
*/
|
|
22
90
|
export interface HealthCheckCache {
|
|
23
|
-
/** Read-through cache for one system
|
|
24
|
-
|
|
91
|
+
/** Read-through cache for one (system, environment) status (RAW, pre-override). */
|
|
92
|
+
read(
|
|
25
93
|
systemId: string,
|
|
26
|
-
|
|
27
|
-
)
|
|
94
|
+
environmentId?: string,
|
|
95
|
+
): Promise<SystemHealthStatusResponse>;
|
|
28
96
|
|
|
29
|
-
/**
|
|
30
|
-
|
|
97
|
+
/** Per-entity read-through cache for many systems' rollup status. */
|
|
98
|
+
readBulk(
|
|
99
|
+
systemIds: string[],
|
|
100
|
+
environmentId?: string,
|
|
101
|
+
): Promise<Record<string, SystemHealthStatusResponse>>;
|
|
31
102
|
|
|
32
103
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
104
|
+
* Per-(system, check, environment) matrix assembled from cached rollup +
|
|
105
|
+
* per-environment reads. The env set is discovered live per system; each
|
|
106
|
+
* slice read hits the same per-env cache the badge path warms.
|
|
36
107
|
*/
|
|
37
|
-
|
|
108
|
+
readMatrix(systemIds: string[]): Promise<SystemHealthMatrix>;
|
|
38
109
|
|
|
39
|
-
/**
|
|
40
|
-
|
|
41
|
-
|
|
110
|
+
/**
|
|
111
|
+
* Change-gated invalidator for the run hot path. Evicts the (system,
|
|
112
|
+
* environment) key ONLY when the derived status vector changed between
|
|
113
|
+
* `previous` and `next`; a no-op otherwise so a timestamp-only run keeps the
|
|
114
|
+
* cache warm. A per-environment change also evicts the system rollup key
|
|
115
|
+
* (the slice feeds the worst-wins rollup).
|
|
116
|
+
*/
|
|
117
|
+
reconcile(args: {
|
|
118
|
+
systemId: string;
|
|
119
|
+
/** `null`/absent = the rollup / env-less key; a string = that environment. */
|
|
120
|
+
environmentId?: string | null;
|
|
121
|
+
previous: SystemHealthStatusResponse;
|
|
122
|
+
next: SystemHealthStatusResponse;
|
|
123
|
+
}): Promise<void>;
|
|
42
124
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
125
|
+
/** Evict a system's rollup + every per-env key. */
|
|
126
|
+
invalidateSystem(systemId: string): Promise<void>;
|
|
127
|
+
|
|
128
|
+
/** Evict every system's status. Returns keys removed. */
|
|
129
|
+
invalidateAllSystems(): Promise<number>;
|
|
130
|
+
}
|
|
46
131
|
|
|
47
132
|
export function createHealthCheckCache({
|
|
48
133
|
cacheManager,
|
|
49
134
|
logger,
|
|
135
|
+
service,
|
|
50
136
|
}: {
|
|
51
137
|
cacheManager: CacheManager;
|
|
52
138
|
logger: Logger;
|
|
139
|
+
/** Read source for cache misses. `HealthCheckService` satisfies this. */
|
|
140
|
+
service: HealthStatusReader;
|
|
53
141
|
}): HealthCheckCache {
|
|
54
|
-
const scope = createCachedScope({
|
|
142
|
+
const scope: CachedScope = createCachedScope({
|
|
55
143
|
cacheManager,
|
|
56
144
|
pluginId: "healthcheck",
|
|
57
145
|
defaultTtlMs: STATUS_TTL_MS,
|
|
@@ -60,11 +148,93 @@ export function createHealthCheckCache({
|
|
|
60
148
|
},
|
|
61
149
|
});
|
|
62
150
|
|
|
151
|
+
const read: HealthCheckCache["read"] = (systemId, environmentId) =>
|
|
152
|
+
scope.wrap(statusKey(systemId, environmentId), () =>
|
|
153
|
+
service.getSystemHealthStatus(systemId, environmentId),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const readBulk: HealthCheckCache["readBulk"] = async (
|
|
157
|
+
systemIds,
|
|
158
|
+
environmentId,
|
|
159
|
+
) => {
|
|
160
|
+
const values = await scope.wrapMany(systemIds, {
|
|
161
|
+
keyFor: (id) => statusKey(id, environmentId),
|
|
162
|
+
loader: (id) => service.getSystemHealthStatus(id, environmentId),
|
|
163
|
+
});
|
|
164
|
+
const out: Record<string, SystemHealthStatusResponse> = {};
|
|
165
|
+
for (const [i, id] of systemIds.entries()) {
|
|
166
|
+
out[id] = values[i]!;
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const readMatrix: HealthCheckCache["readMatrix"] = async (systemIds) => {
|
|
172
|
+
const result: SystemHealthMatrix = {};
|
|
173
|
+
await Promise.all(
|
|
174
|
+
systemIds.map(async (systemId) => {
|
|
175
|
+
const overall = await read(systemId);
|
|
176
|
+
const envIds = await service.getSystemEnvironmentIds(systemId);
|
|
177
|
+
const environments: SystemHealthMatrix[string]["environments"] = {};
|
|
178
|
+
await Promise.all(
|
|
179
|
+
envIds.map(async (environmentId) => {
|
|
180
|
+
const slice = await read(systemId, environmentId);
|
|
181
|
+
environments[environmentId] = {
|
|
182
|
+
status: slice.status,
|
|
183
|
+
checkStatuses: slice.checkStatuses,
|
|
184
|
+
};
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
result[systemId] = {
|
|
188
|
+
status: overall.status,
|
|
189
|
+
checkStatuses: overall.checkStatuses,
|
|
190
|
+
environments,
|
|
191
|
+
};
|
|
192
|
+
}),
|
|
193
|
+
);
|
|
194
|
+
return result;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const reconcile: HealthCheckCache["reconcile"] = async ({
|
|
198
|
+
systemId,
|
|
199
|
+
environmentId,
|
|
200
|
+
previous,
|
|
201
|
+
next,
|
|
202
|
+
}) => {
|
|
203
|
+
if (!statusVectorChanged(previous, next)) return; // vector unchanged: keep warm.
|
|
204
|
+
// `scope.invalidate` is a `delete` on the active backend. With a distributed
|
|
205
|
+
// backend that delete is visible to every pod immediately, so no broadcast
|
|
206
|
+
// is needed for cross-pod coherence.
|
|
207
|
+
const isEnvScoped =
|
|
208
|
+
environmentId !== undefined && environmentId !== null;
|
|
209
|
+
if (isEnvScoped) {
|
|
210
|
+
// A per-environment slice changed. Evict its key AND the system rollup:
|
|
211
|
+
// the slice feeds the worst-wins rollup, so the rollup value may have
|
|
212
|
+
// moved even when its OWN per-check fingerprint can't see it (one slice
|
|
213
|
+
// recovering as another fails keeps `failingSliceCount` put). Sibling env
|
|
214
|
+
// keys stay warm.
|
|
215
|
+
await scope.invalidate(statusKey(systemId, environmentId));
|
|
216
|
+
await scope.invalidate(statusKey(systemId));
|
|
217
|
+
} else {
|
|
218
|
+
// Env-less / rollup change: the bare key IS the rollup.
|
|
219
|
+
await scope.invalidate(statusKey(systemId));
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const invalidateSystem: HealthCheckCache["invalidateSystem"] = async (
|
|
224
|
+
systemId,
|
|
225
|
+
) => {
|
|
226
|
+
await scope.invalidatePrefix(systemPrefix(systemId));
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const invalidateAllSystems: HealthCheckCache["invalidateAllSystems"] = () =>
|
|
230
|
+
scope.invalidatePrefix(STATUS_KEY_PREFIX);
|
|
231
|
+
|
|
63
232
|
return {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
233
|
+
read,
|
|
234
|
+
readBulk,
|
|
235
|
+
readMatrix,
|
|
236
|
+
reconcile,
|
|
237
|
+
invalidateSystem,
|
|
238
|
+
invalidateAllSystems,
|
|
69
239
|
};
|
|
70
240
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { buildHealthTransitionNotification } from "./health-notification-content";
|
|
3
|
+
|
|
4
|
+
describe("buildHealthTransitionNotification", () => {
|
|
5
|
+
const base = {
|
|
6
|
+
systemId: "sys-1",
|
|
7
|
+
systemName: "Payments API",
|
|
8
|
+
configurationId: "cfg-9",
|
|
9
|
+
checkName: "HTTP 200 probe",
|
|
10
|
+
newStatus: "unhealthy" as const,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
it("names the failing check in the body for an unhealthy transition", () => {
|
|
14
|
+
const payload = buildHealthTransitionNotification({
|
|
15
|
+
...base,
|
|
16
|
+
transition: "escalation",
|
|
17
|
+
});
|
|
18
|
+
expect(payload.body).toContain('Health check **"HTTP 200 probe"**');
|
|
19
|
+
expect(payload.body).toContain("**Payments API**");
|
|
20
|
+
expect(payload.importance).toBe("critical");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("names the failing check for a degraded transition", () => {
|
|
24
|
+
const payload = buildHealthTransitionNotification({
|
|
25
|
+
...base,
|
|
26
|
+
newStatus: "degraded",
|
|
27
|
+
transition: "escalation",
|
|
28
|
+
});
|
|
29
|
+
expect(payload.body).toContain('Health check **"HTTP 200 probe"**');
|
|
30
|
+
expect(payload.importance).toBe("warning");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("pushes a healthcheck.healthcheck subject alongside the system subject", () => {
|
|
34
|
+
const payload = buildHealthTransitionNotification({
|
|
35
|
+
...base,
|
|
36
|
+
transition: "escalation",
|
|
37
|
+
});
|
|
38
|
+
const subjects = payload.subjects ?? [];
|
|
39
|
+
expect(subjects).toHaveLength(2);
|
|
40
|
+
expect(subjects[0]).toMatchObject({
|
|
41
|
+
kind: "catalog.system",
|
|
42
|
+
id: "sys-1",
|
|
43
|
+
name: "Payments API",
|
|
44
|
+
});
|
|
45
|
+
expect(subjects[1]).toMatchObject({
|
|
46
|
+
kind: "healthcheck.healthcheck",
|
|
47
|
+
id: "cfg-9",
|
|
48
|
+
name: "HTTP 200 probe",
|
|
49
|
+
status: "unhealthy",
|
|
50
|
+
});
|
|
51
|
+
// Check subject deep-links to its run history.
|
|
52
|
+
expect(subjects[1]?.url).toContain("sys-1");
|
|
53
|
+
expect(subjects[1]?.url).toContain("cfg-9");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("falls back to the configuration id when no name is resolved", () => {
|
|
57
|
+
const payload = buildHealthTransitionNotification({
|
|
58
|
+
...base,
|
|
59
|
+
checkName: "cfg-9",
|
|
60
|
+
transition: "escalation",
|
|
61
|
+
});
|
|
62
|
+
expect(payload.body).toContain('Health check **"cfg-9"**');
|
|
63
|
+
expect((payload.subjects ?? [])[1]).toMatchObject({ name: "cfg-9" });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("qualifies the body with the environment name when env-scoped", () => {
|
|
67
|
+
const payload = buildHealthTransitionNotification({
|
|
68
|
+
...base,
|
|
69
|
+
transition: "escalation",
|
|
70
|
+
environmentId: "env-prod",
|
|
71
|
+
environmentName: "Production",
|
|
72
|
+
});
|
|
73
|
+
expect(payload.body).toContain("in environment **Production**");
|
|
74
|
+
expect(payload.title).toContain("(Production)");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("carries the origin environment id for an env-scoped transition", () => {
|
|
78
|
+
const payload = buildHealthTransitionNotification({
|
|
79
|
+
...base,
|
|
80
|
+
transition: "escalation",
|
|
81
|
+
environmentId: "env-prod",
|
|
82
|
+
environmentName: "Production",
|
|
83
|
+
});
|
|
84
|
+
// The status-page fan-out reads this to drop the change for a page that does
|
|
85
|
+
// not publish env-prod.
|
|
86
|
+
expect(payload.originEnvironmentId).toBe("env-prod");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("omits the origin environment id for a system-rollup transition", () => {
|
|
90
|
+
const payload = buildHealthTransitionNotification({
|
|
91
|
+
...base,
|
|
92
|
+
transition: "escalation",
|
|
93
|
+
});
|
|
94
|
+
// No environmentId => a whole-system rollup; it must reach every page that
|
|
95
|
+
// surfaces the system regardless of environment.
|
|
96
|
+
expect(payload.originEnvironmentId).toBeUndefined();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("stays system-level and omits the check subject on recovery", () => {
|
|
100
|
+
const payload = buildHealthTransitionNotification({
|
|
101
|
+
...base,
|
|
102
|
+
newStatus: "healthy",
|
|
103
|
+
transition: "recovery",
|
|
104
|
+
});
|
|
105
|
+
expect(payload.body).not.toContain("Health check **");
|
|
106
|
+
expect(payload.importance).toBe("info");
|
|
107
|
+
const subjects = payload.subjects ?? [];
|
|
108
|
+
expect(subjects).toHaveLength(1);
|
|
109
|
+
expect(subjects[0]).toMatchObject({ kind: "catalog.system" });
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { resolveRoute, type InferClient } from "@checkstack/common";
|
|
2
|
+
import { catalogRoutes, createSystemSubject } from "@checkstack/catalog-common";
|
|
3
|
+
import type { NotificationApi } from "@checkstack/notification-common";
|
|
4
|
+
import {
|
|
5
|
+
createHealthcheckSubject,
|
|
6
|
+
healthcheckRoutes,
|
|
7
|
+
systemHealthCollapseKey,
|
|
8
|
+
healthcheckSystemSubscription,
|
|
9
|
+
type HealthCheckStatus,
|
|
10
|
+
} from "@checkstack/healthcheck-common";
|
|
11
|
+
import type { TransitionKind } from "./notification-policy";
|
|
12
|
+
|
|
13
|
+
/** The subset of `notifyForSubscription`'s input this builder produces. */
|
|
14
|
+
type NotifyForSubscriptionInput = Parameters<
|
|
15
|
+
InferClient<typeof NotificationApi>["notifyForSubscription"]
|
|
16
|
+
>[0];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Inputs to {@link buildHealthTransitionNotification}. Pure data only - the
|
|
20
|
+
* catalog client is unused here (parents are resolved server-side) and thus
|
|
21
|
+
* omitted; every field is derived before the call site in the queue executor.
|
|
22
|
+
*/
|
|
23
|
+
export interface HealthTransitionNotificationInput {
|
|
24
|
+
transition: Exclude<TransitionKind, "none">;
|
|
25
|
+
systemId: string;
|
|
26
|
+
systemName: string;
|
|
27
|
+
configurationId: string;
|
|
28
|
+
/** Resolved display name of the check that drove the transition. */
|
|
29
|
+
checkName: string;
|
|
30
|
+
newStatus: HealthCheckStatus;
|
|
31
|
+
/** Concrete env id for a per-env slice, null/undefined for the system rollup. */
|
|
32
|
+
environmentId?: string | null;
|
|
33
|
+
/** Human-readable env name for the body/title. */
|
|
34
|
+
environmentName?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build the notification payload for a health-state transition. Pure and
|
|
39
|
+
* side-effect free so it can be unit-tested directly. Extracted from
|
|
40
|
+
* `notifyStateChange` so the body/title/subject wording (which now NAMES the
|
|
41
|
+
* failing check and pushes a `healthcheck.healthcheck` subject) is verifiable
|
|
42
|
+
* without booting the whole queue executor.
|
|
43
|
+
*
|
|
44
|
+
* Recovery bodies stay system-level (the whole system is green again; naming
|
|
45
|
+
* one check would mislead) and omit the check subject. Failing transitions
|
|
46
|
+
* (escalation / de-escalation) name the check in the body and add it as a
|
|
47
|
+
* subject deep-linked to its run history.
|
|
48
|
+
*/
|
|
49
|
+
export function buildHealthTransitionNotification(
|
|
50
|
+
input: HealthTransitionNotificationInput,
|
|
51
|
+
): NotifyForSubscriptionInput {
|
|
52
|
+
const {
|
|
53
|
+
transition,
|
|
54
|
+
systemId,
|
|
55
|
+
systemName,
|
|
56
|
+
configurationId,
|
|
57
|
+
checkName,
|
|
58
|
+
newStatus,
|
|
59
|
+
environmentId,
|
|
60
|
+
environmentName,
|
|
61
|
+
} = input;
|
|
62
|
+
|
|
63
|
+
const envScoped = typeof environmentId === "string";
|
|
64
|
+
const envSuffix = envScoped && environmentName ? ` (${environmentName})` : "";
|
|
65
|
+
const envQualifier = envScoped
|
|
66
|
+
? ` in environment **${environmentName ?? environmentId}**`
|
|
67
|
+
: "";
|
|
68
|
+
|
|
69
|
+
let title: string;
|
|
70
|
+
let body: string;
|
|
71
|
+
let importance: "info" | "warning" | "critical";
|
|
72
|
+
|
|
73
|
+
if (transition === "recovery") {
|
|
74
|
+
title = `System health restored${envSuffix}: ${systemName}`;
|
|
75
|
+
body = envScoped
|
|
76
|
+
? `Health checks for **${systemName}** in environment **${environmentName ?? environmentId}** are now passing. The system has returned to normal operation in that environment.`
|
|
77
|
+
: `All health checks for **${systemName}** are now passing. The system has returned to normal operation.`;
|
|
78
|
+
importance = "info";
|
|
79
|
+
} else if (newStatus === "unhealthy") {
|
|
80
|
+
title = `System health critical${envSuffix}: ${systemName}`;
|
|
81
|
+
body = `Health check **"${checkName}"** on **${systemName}**${envQualifier} is failing. The system is unhealthy and may be down${envScoped ? " in that environment" : ""}.`;
|
|
82
|
+
importance = "critical";
|
|
83
|
+
} else {
|
|
84
|
+
// degraded - either an escalation from healthy or a partial recovery
|
|
85
|
+
title = `System health degraded${envSuffix}: ${systemName}`;
|
|
86
|
+
body = `Health check **"${checkName}"** on **${systemName}**${envQualifier} is failing. The system may be experiencing issues${envScoped ? " in that environment" : ""}.`;
|
|
87
|
+
importance = "warning";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const systemDetailPath = resolveRoute(catalogRoutes.routes.systemDetail, {
|
|
91
|
+
systemId,
|
|
92
|
+
});
|
|
93
|
+
// Recovery lands on the default (all) view; failing transitions deep-link
|
|
94
|
+
// operators into the failing-checks filter so they can debug immediately.
|
|
95
|
+
const actionUrl =
|
|
96
|
+
transition === "recovery"
|
|
97
|
+
? systemDetailPath
|
|
98
|
+
: `${systemDetailPath}?filter=failing`;
|
|
99
|
+
const actionLabel =
|
|
100
|
+
transition === "recovery" ? "View System" : "View failing checks";
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
specId: healthcheckSystemSubscription.specId,
|
|
104
|
+
resourceKeys: [systemId],
|
|
105
|
+
title,
|
|
106
|
+
body,
|
|
107
|
+
importance,
|
|
108
|
+
action: { label: actionLabel, url: actionUrl },
|
|
109
|
+
// Carry the failing ENVIRONMENT so the status-page fan-out can drop this
|
|
110
|
+
// change for a page that does not publish that environment (e.g. a
|
|
111
|
+
// `development` failure never reaches a prod-only page's subscribers). Only
|
|
112
|
+
// set for an env-scoped slice; the system-rollup path stays env-less.
|
|
113
|
+
...(typeof environmentId === "string"
|
|
114
|
+
? { originEnvironmentId: environmentId }
|
|
115
|
+
: {}),
|
|
116
|
+
// Env-qualified collapse key so two failing envs of one system generate
|
|
117
|
+
// two independent notification cards (one per env) instead of merging.
|
|
118
|
+
collapseKey: envScoped
|
|
119
|
+
? systemHealthCollapseKey(systemId, environmentId)
|
|
120
|
+
: systemHealthCollapseKey(systemId),
|
|
121
|
+
subjects: [
|
|
122
|
+
createSystemSubject({
|
|
123
|
+
id: systemId,
|
|
124
|
+
name: systemName,
|
|
125
|
+
url: systemDetailPath,
|
|
126
|
+
status: newStatus,
|
|
127
|
+
}),
|
|
128
|
+
// Name the failing check as its own subject for every non-recovery
|
|
129
|
+
// transition, deep-linked to its run history. Omitted on recovery.
|
|
130
|
+
...(transition === "recovery"
|
|
131
|
+
? []
|
|
132
|
+
: [
|
|
133
|
+
createHealthcheckSubject({
|
|
134
|
+
id: configurationId,
|
|
135
|
+
name: checkName,
|
|
136
|
+
url: resolveRoute(healthcheckRoutes.routes.historyDetail, {
|
|
137
|
+
systemId,
|
|
138
|
+
configurationId,
|
|
139
|
+
}),
|
|
140
|
+
status: newStatus,
|
|
141
|
+
}),
|
|
142
|
+
]),
|
|
143
|
+
],
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
buildHealthcheckKind,
|
|
6
6
|
buildSystemHealthcheckExtension,
|
|
7
7
|
} from "./healthcheck-gitops-kinds";
|
|
8
|
+
import { createStubHealthCheckCache } from "./cache-test-stub";
|
|
8
9
|
import type {
|
|
9
10
|
HealthCheckConfiguration,
|
|
10
11
|
CreateHealthCheckConfiguration,
|
|
@@ -248,10 +249,13 @@ describe("Healthcheck GitOps Kind: Healthcheck", () => {
|
|
|
248
249
|
let mockHCRegistry: ReturnType<typeof createMockHealthCheckRegistry>;
|
|
249
250
|
let mockCollectorRegistry: ReturnType<typeof createMockCollectorRegistry>;
|
|
250
251
|
|
|
252
|
+
let mockCache: ReturnType<typeof createStubHealthCheckCache>;
|
|
253
|
+
|
|
251
254
|
beforeEach(() => {
|
|
252
255
|
mockService = createMockService();
|
|
253
256
|
mockHCRegistry = createMockHealthCheckRegistry();
|
|
254
257
|
mockCollectorRegistry = createMockCollectorRegistry();
|
|
258
|
+
mockCache = createStubHealthCheckCache();
|
|
255
259
|
});
|
|
256
260
|
|
|
257
261
|
function buildKind() {
|
|
@@ -262,6 +266,7 @@ describe("Healthcheck GitOps Kind: Healthcheck", () => {
|
|
|
262
266
|
getQueueManager: () => emptyReconcileQueueManager(),
|
|
263
267
|
getDb: () => emptyReconcileDbStub(),
|
|
264
268
|
getCatalogClient: () => emptyCatalogClient(),
|
|
269
|
+
getCache: () => mockCache,
|
|
265
270
|
};
|
|
266
271
|
return buildHealthcheckKind(mockDeps);
|
|
267
272
|
}
|
|
@@ -601,6 +606,9 @@ describe("Healthcheck GitOps Kind: Healthcheck", () => {
|
|
|
601
606
|
|
|
602
607
|
expect(result.entityId).toBe("hc-1");
|
|
603
608
|
expect(mockService.createConfiguration).toHaveBeenCalledTimes(1);
|
|
609
|
+
// Regression guard: GitOps must invalidate the status cache (a create could
|
|
610
|
+
// affect any system's rollup), otherwise every pod serves stale health.
|
|
611
|
+
expect(mockCache.invalidateAllSystems).toHaveBeenCalledTimes(1);
|
|
604
612
|
});
|
|
605
613
|
});
|
|
606
614
|
|
|
@@ -608,9 +616,11 @@ describe("Healthcheck GitOps Kind: Healthcheck", () => {
|
|
|
608
616
|
|
|
609
617
|
describe("Healthcheck GitOps Kind: System Extension", () => {
|
|
610
618
|
let mockService: ReturnType<typeof createMockService>;
|
|
619
|
+
let mockCache: ReturnType<typeof createStubHealthCheckCache>;
|
|
611
620
|
|
|
612
621
|
beforeEach(() => {
|
|
613
622
|
mockService = createMockService();
|
|
623
|
+
mockCache = createStubHealthCheckCache();
|
|
614
624
|
});
|
|
615
625
|
|
|
616
626
|
function buildExtension() {
|
|
@@ -627,6 +637,7 @@ describe("Healthcheck GitOps Kind: System Extension", () => {
|
|
|
627
637
|
getQueueManager: () => emptyReconcileQueueManager(),
|
|
628
638
|
getDb: () => emptyReconcileDbStub(),
|
|
629
639
|
getCatalogClient: () => emptyCatalogClient(),
|
|
640
|
+
getCache: () => mockCache,
|
|
630
641
|
});
|
|
631
642
|
}
|
|
632
643
|
|
|
@@ -667,6 +678,9 @@ describe("Healthcheck GitOps Kind: System Extension", () => {
|
|
|
667
678
|
expect(mockService.associations[0].systemId).toBe("sys-123");
|
|
668
679
|
expect(mockService.associations[0].configurationId).toBe("hc-1");
|
|
669
680
|
expect(mockService.associations[1].configurationId).toBe("hc-2");
|
|
681
|
+
// Regression guard: an association change must invalidate THIS system's
|
|
682
|
+
// cached status (rollup + env keys), else pods serve stale health.
|
|
683
|
+
expect(mockCache.invalidateSystem).toHaveBeenCalledWith("sys-123");
|
|
670
684
|
});
|
|
671
685
|
|
|
672
686
|
it("removes stale associations not in spec", async () => {
|
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
} from "@checkstack/backend-api";
|
|
16
16
|
import { NotificationPolicySchema } from "@checkstack/healthcheck-common";
|
|
17
17
|
import { HealthCheckService } from "./service";
|
|
18
|
+
import type { HealthCheckCache } from "./cache";
|
|
18
19
|
import { validateVersionedConfigStrict } from "./validate-configuration";
|
|
19
20
|
import {
|
|
20
21
|
DynamicOperators,
|
|
@@ -43,6 +44,15 @@ interface HealthcheckGitOpsKindsDeps {
|
|
|
43
44
|
getQueueManager: () => QueueManager;
|
|
44
45
|
getDb: () => SafeDatabase<typeof schema>;
|
|
45
46
|
getCatalogClient: () => InferClient<typeof CatalogApi>;
|
|
47
|
+
/**
|
|
48
|
+
* Lazy accessor for the system-health status cache. GitOps writes configs /
|
|
49
|
+
* associations directly on the service (not through the router), so they MUST
|
|
50
|
+
* invalidate the cache themselves — otherwise a `git push` that changes a
|
|
51
|
+
* system's derived status leaves every pod serving the stale cached value
|
|
52
|
+
* until its 15s TTL. Mirrors the router's config-mutation invalidation. May be
|
|
53
|
+
* `undefined` before init completes (reconcile only runs post-init).
|
|
54
|
+
*/
|
|
55
|
+
getCache: () => HealthCheckCache | undefined;
|
|
46
56
|
}
|
|
47
57
|
|
|
48
58
|
// ─── Healthcheck Spec Schema ───────────────────────────────────────────────
|
|
@@ -264,6 +274,11 @@ export function buildHealthcheckKind(
|
|
|
264
274
|
// silently restored from the stored row (keep-existing is UI-only).
|
|
265
275
|
{ mergeSecrets: false },
|
|
266
276
|
);
|
|
277
|
+
// A config change (thresholds, assertions, interval) can move the
|
|
278
|
+
// derived status of every system this check is assigned to. Match the
|
|
279
|
+
// router's `updateConfiguration` handler: drop every system's cached
|
|
280
|
+
// status (+ broadcast) so no pod serves stale health.
|
|
281
|
+
await deps.getCache()?.invalidateAllSystems();
|
|
267
282
|
context.logger.info(
|
|
268
283
|
`GitOps: updated Healthcheck "${displayName}" (id: ${existingEntityId})`,
|
|
269
284
|
);
|
|
@@ -282,6 +297,9 @@ export function buildHealthcheckKind(
|
|
|
282
297
|
assertions: c.assertions,
|
|
283
298
|
})),
|
|
284
299
|
});
|
|
300
|
+
// A new configuration could be associated with any system; match the
|
|
301
|
+
// router's `createConfiguration` handler and drop every cached status.
|
|
302
|
+
await deps.getCache()?.invalidateAllSystems();
|
|
285
303
|
context.logger.info(
|
|
286
304
|
`GitOps: created Healthcheck "${displayName}" (id: ${config.id})`,
|
|
287
305
|
);
|
|
@@ -299,6 +317,9 @@ export function buildHealthcheckKind(
|
|
|
299
317
|
if (!entityId) return;
|
|
300
318
|
const service = deps.createService();
|
|
301
319
|
await service.deleteConfiguration(entityId);
|
|
320
|
+
// Match the router's `deleteConfiguration` handler: drop every cached
|
|
321
|
+
// status since the removed check could have affected any system's rollup.
|
|
322
|
+
await deps.getCache()?.invalidateAllSystems();
|
|
302
323
|
context.logger.info(`GitOps: deleted Healthcheck (id: ${entityId})`);
|
|
303
324
|
},
|
|
304
325
|
};
|
|
@@ -415,6 +436,12 @@ export function buildSystemHealthcheckExtension(
|
|
|
415
436
|
);
|
|
416
437
|
}
|
|
417
438
|
}
|
|
439
|
+
|
|
440
|
+
// Every association add/remove above changes THIS system's derived
|
|
441
|
+
// rollup (a check enters/leaves worst-wins), so drop its cached status
|
|
442
|
+
// (rollup + all env keys) and broadcast — mirroring the router's
|
|
443
|
+
// associate/disassociate handlers.
|
|
444
|
+
await deps.getCache()?.invalidateSystem(systemEntityId);
|
|
418
445
|
},
|
|
419
446
|
};
|
|
420
447
|
}
|