@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +484 -0
  2. package/drizzle/0019_chemical_frightful_four.sql +8 -0
  3. package/drizzle/0020_certain_mordo.sql +2 -0
  4. package/drizzle/meta/0019_snapshot.json +661 -0
  5. package/drizzle/meta/0020_snapshot.json +711 -0
  6. package/drizzle/meta/_journal.json +14 -0
  7. package/package.json +23 -21
  8. package/src/ai/system-signals-contributor.test.ts +33 -9
  9. package/src/ai/system-signals-contributor.ts +38 -16
  10. package/src/cache-test-stub.ts +26 -0
  11. package/src/cache.test.ts +291 -0
  12. package/src/cache.ts +204 -34
  13. package/src/health-notification-content.test.ts +111 -0
  14. package/src/health-notification-content.ts +145 -0
  15. package/src/healthcheck-gitops-kinds.test.ts +14 -0
  16. package/src/healthcheck-gitops-kinds.ts +27 -0
  17. package/src/index.ts +31 -12
  18. package/src/queue-executor.test.ts +13 -26
  19. package/src/queue-executor.ts +125 -112
  20. package/src/retention-job.ts +8 -0
  21. package/src/rollup-consumer.test.ts +19 -8
  22. package/src/router-config-secrets.test.ts +2 -7
  23. package/src/router-create-and-assign.test.ts +2 -7
  24. package/src/router-pause-recompute.test.ts +2 -7
  25. package/src/router.test.ts +3 -8
  26. package/src/router.ts +43 -15
  27. package/src/schema.ts +74 -31
  28. package/src/service-batching.test.ts +8 -0
  29. package/src/service-bulk-counts.it.test.ts +144 -0
  30. package/src/service-bulk-run-stats.it.test.ts +197 -0
  31. package/src/service-ordering.test.ts +6 -2
  32. package/src/service-paused-filter.test.ts +13 -0
  33. package/src/service-rollup-worst-wins.test.ts +209 -145
  34. package/src/service.ts +408 -284
  35. package/src/status-fingerprint.test.ts +92 -0
  36. package/src/status-fingerprint.ts +66 -0
  37. package/src/status-page/rollup.test.ts +40 -0
  38. package/src/status-page/rollup.ts +27 -0
  39. package/src/status-page/widgets.test.ts +387 -0
  40. package/src/status-page/widgets.ts +236 -39
@@ -23,12 +23,93 @@ import {
23
23
  mapHealthStatus,
24
24
  rollupStatus,
25
25
  overallBannerStatus,
26
+ rollupSelectedEnvironments,
26
27
  statusBannerTitle,
27
28
  } from "./rollup";
28
29
 
29
30
  const SYSTEM_TYPE = "catalog.system";
30
31
  const GROUP_TYPE = "catalog.group";
31
32
 
33
+ /**
34
+ * The catalog system ids visible under the page's published-environment scope,
35
+ * or null when the page publishes all environments (no filter). Resolved once
36
+ * per page resolve (cache-keyed by the env set) from the catalog's own
37
+ * env->systems mapping, so a system in NONE of the selected environments is
38
+ * omitted from every health widget.
39
+ */
40
+ async function envVisibleSystems(
41
+ ctx: WidgetResolveContext,
42
+ ): Promise<Set<string> | null> {
43
+ const envIds = ctx.publishedEnvironmentIds;
44
+ if (!envIds || envIds.length === 0) return null;
45
+ const key = `catalog.systemsInEnv:${[...envIds].toSorted().join(",")}`;
46
+ const ids = await ctx.cache(key, async () => {
47
+ const envs = await ctx.rpcClient
48
+ .forPlugin(CatalogApi)
49
+ .resolveEnvironments({ environmentIds: envIds });
50
+ const set = new Set<string>();
51
+ for (const env of envs) for (const s of env.systemIds) set.add(s);
52
+ return [...set];
53
+ });
54
+ return new Set(ids);
55
+ }
56
+
57
+ /**
58
+ * Restrict a widget's configured system ids to those visible under the page's
59
+ * published-environment scope. Returns the ids unchanged when the page publishes
60
+ * all environments.
61
+ */
62
+ async function scopeToEnv(
63
+ ctx: WidgetResolveContext,
64
+ ids: string[],
65
+ ): Promise<string[]> {
66
+ const visible = await envVisibleSystems(ctx);
67
+ if (!visible) return ids;
68
+ return ids.filter((id) => visible.has(id));
69
+ }
70
+
71
+ /*
72
+ * The CURRENT set of catalog system ids each health widget surfaces, resolved
73
+ * from the SAME config the DTO resolve reads and intersected with the page's
74
+ * published-environment scope. Shared by `resolvePublic` (what the widget shows)
75
+ * and `resolveScopedSystems` / `resolveScopedSystemsDetailed` (what the
76
+ * subscriber fan-out may email about), so the shown set and the emailed-about set
77
+ * can never drift.
78
+ */
79
+
80
+ async function bannerScopedIds(
81
+ config: unknown,
82
+ ctx: WidgetResolveContext,
83
+ ): Promise<string[]> {
84
+ return scopeToEnv(ctx, BannerConfigSchema.parse(config).systemIds);
85
+ }
86
+
87
+ async function systemHealthScopedItems(
88
+ config: unknown,
89
+ ctx: WidgetResolveContext,
90
+ ) {
91
+ const c = SystemHealthConfigSchema.parse(config);
92
+ const visible = await envVisibleSystems(ctx);
93
+ return visible ? c.items.filter((i) => visible.has(i.systemId)) : c.items;
94
+ }
95
+
96
+ async function groupStatusScopedIds(
97
+ config: unknown,
98
+ ctx: WidgetResolveContext,
99
+ ): Promise<string[]> {
100
+ const c = GroupStatusConfigSchema.parse(config);
101
+ const groups = await allGroups(ctx);
102
+ const group = groups.find((g) => g.id === c.groupId);
103
+ return scopeToEnv(ctx, group?.systemIds ?? []);
104
+ }
105
+
106
+ async function uptimeScopedIds(
107
+ config: unknown,
108
+ ctx: WidgetResolveContext,
109
+ ): Promise<string[]> {
110
+ return scopeToEnv(ctx, [UptimeConfigSchema.parse(config).systemId]);
111
+ }
112
+
32
113
  function uptimeToStatus(pct: number): PublicStatus {
33
114
  if (pct >= 99.5) return "operational";
34
115
  if (pct >= 95) return "degraded";
@@ -83,25 +164,69 @@ async function inMaintenance(
83
164
  return out;
84
165
  }
85
166
 
86
- async function healthStatuses(
167
+ /**
168
+ * Per-system HEALTH-derived PUBLIC status (before the maintenance override).
169
+ *
170
+ * - Page publishes ALL environments: the cross-environment rollup via
171
+ * `getBulkSystemHealthStatus`, which folds active incident overrides into the
172
+ * status (so the public page shows the forced status).
173
+ * - Page publishes a SPECIFIC environment set: the per-environment matrix rolled
174
+ * up over ONLY the selected environments (`rollupSelectedEnvironments`), then
175
+ * the whole-system incident override folded IN via worst-wins. Incident
176
+ * overrides are whole-system (not env-scoped), so they must apply regardless of
177
+ * which environments the page publishes - otherwise a system whose checks are
178
+ * green in the selected env but which is under an active incident-forced outage
179
+ * would wrongly read healthy. The override status comes from the SAME source as
180
+ * the all-env path (`getBulkSystemHealthStatus`, which folds it and surfaces it
181
+ * on `override`), so both modes show the identical forced status.
182
+ *
183
+ * In BOTH modes only the derived status enum is read - never `override.reason`
184
+ * (the incident TITLE) or per-check detail - so no internal name reaches a
185
+ * public widget DTO.
186
+ */
187
+ async function healthPublicStatuses(
87
188
  ctx: WidgetResolveContext,
88
189
  ids: string[],
89
- ): Promise<Record<string, { status: string } | undefined>> {
90
- if (ids.length === 0) return {};
190
+ ): Promise<Map<string, PublicStatus>> {
191
+ const out = new Map<string, PublicStatus>();
192
+ if (ids.length === 0) return out;
193
+ const envIds = ctx.publishedEnvironmentIds;
194
+ if (envIds && envIds.length > 0) {
195
+ const client = ctx.rpcClient.forPlugin(HealthCheckApi);
196
+ // Matrix = per-environment CHECKS status; bulk status carries the
197
+ // whole-system incident override (on `override`). Fetch both, roll up only
198
+ // the selected envs' checks, then fold the override in (worst-wins).
199
+ const [matrixRes, bulkRes] = await Promise.all([
200
+ client.getBulkSystemHealthMatrix({ systemIds: ids }),
201
+ client.getBulkSystemHealthStatus({ systemIds: ids }),
202
+ ]);
203
+ for (const id of ids) {
204
+ const matrix = matrixRes.statuses[id];
205
+ const envChecks: PublicStatus = matrix
206
+ ? rollupSelectedEnvironments({
207
+ environments: matrix.environments,
208
+ selectedEnvironmentIds: envIds,
209
+ })
210
+ : "unknown";
211
+ const overrideStatus = bulkRes.statuses[id]?.override?.status;
212
+ // rollupStatus is worst-wins over the public vocabulary, so the override
213
+ // lifts the status exactly as `applySystemHealthOverrides` does upstream.
214
+ out.set(
215
+ id,
216
+ overrideStatus
217
+ ? rollupStatus([envChecks, mapHealthStatus(overrideStatus)])
218
+ : envChecks,
219
+ );
220
+ }
221
+ return out;
222
+ }
91
223
  const { statuses } = await ctx.rpcClient
92
224
  .forPlugin(HealthCheckApi)
93
225
  .getBulkSystemHealthStatus({ systemIds: ids });
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
226
  for (const [systemId, value] of Object.entries(statuses)) {
102
- projected[systemId] = value ? { status: value.status } : undefined;
227
+ if (value) out.set(systemId, mapHealthStatus(value.status));
103
228
  }
104
- return projected;
229
+ return out;
105
230
  }
106
231
 
107
232
  function publicStatus({
@@ -110,12 +235,11 @@ function publicStatus({
110
235
  maint,
111
236
  }: {
112
237
  systemId: string;
113
- health: Record<string, { status: string } | undefined>;
238
+ health: Map<string, PublicStatus>;
114
239
  maint: Set<string>;
115
240
  }): PublicStatus {
116
241
  if (maint.has(systemId)) return "maintenance";
117
- const internal = health[systemId]?.status;
118
- return internal ? mapHealthStatus(internal) : "unknown";
242
+ return health.get(systemId) ?? "unknown";
119
243
  }
120
244
 
121
245
  /** assertBindingsReadable for system-bound widgets. */
@@ -146,12 +270,22 @@ const banner: WidgetTypeDefinition = {
146
270
  assertBindingsReadable: assertSystems((c) => ({
147
271
  systemIds: BannerConfigSchema.parse(c).systemIds,
148
272
  })),
273
+ subscriptionCategory: "health",
274
+ resolveScopedSystems: async ({ config, ctx }) =>
275
+ new Set(await bannerScopedIds(config, ctx)),
276
+ async resolveScopedSystemsDetailed({ config, ctx }) {
277
+ const ids = await bannerScopedIds(config, ctx);
278
+ const names = await labelsFor(ctx, ids);
279
+ return ids.map((id) => ({ id, name: names.get(id) ?? id }));
280
+ },
149
281
  async resolvePublic({ config, ctx }) {
150
282
  const c = BannerConfigSchema.parse(config);
151
- const health = await healthStatuses(ctx, c.systemIds);
152
- const maint = await inMaintenance(ctx, c.systemIds);
283
+ // Omit systems outside the page's published environments before rolling up.
284
+ const ids = await bannerScopedIds(config, ctx);
285
+ const health = await healthPublicStatuses(ctx, ids);
286
+ const maint = await inMaintenance(ctx, ids);
153
287
  const status = overallBannerStatus(
154
- c.systemIds.map((systemId) => publicStatus({ systemId, health, maint })),
288
+ ids.map((systemId) => publicStatus({ systemId, health, maint })),
155
289
  );
156
290
  return BannerDtoSchema.parse({
157
291
  status,
@@ -176,14 +310,32 @@ const systemHealth: WidgetTypeDefinition = {
176
310
  assertBindingsReadable: assertSystems((c) => ({
177
311
  systemIds: SystemHealthConfigSchema.parse(c).items.map((i) => i.systemId),
178
312
  })),
313
+ subscriptionCategory: "health",
314
+ async resolveScopedSystems({ config, ctx }) {
315
+ const items = await systemHealthScopedItems(config, ctx);
316
+ return new Set(items.map((i) => i.systemId));
317
+ },
318
+ async resolveScopedSystemsDetailed({ config, ctx }) {
319
+ const items = await systemHealthScopedItems(config, ctx);
320
+ const names = await labelsFor(
321
+ ctx,
322
+ items.map((i) => i.systemId),
323
+ );
324
+ return items.map((i) => ({
325
+ id: i.systemId,
326
+ name: i.label ?? names.get(i.systemId) ?? i.systemId,
327
+ }));
328
+ },
179
329
  async resolvePublic({ config, ctx }) {
180
330
  const c = SystemHealthConfigSchema.parse(config);
181
- const ids = c.items.map((i) => i.systemId);
182
- const health = await healthStatuses(ctx, ids);
331
+ // Drop rows for systems outside the page's published environments.
332
+ const items = await systemHealthScopedItems(config, ctx);
333
+ const ids = items.map((i) => i.systemId);
334
+ const health = await healthPublicStatuses(ctx, ids);
183
335
  const maint = await inMaintenance(ctx, ids);
184
336
  const names = await labelsFor(ctx, ids);
185
337
  const uptime = c.showUptime ? await uptimeMap(ctx, ids) : undefined;
186
- const systems = c.items.map((item) => {
338
+ const systems = items.map((item) => {
187
339
  const pct = uptime?.get(item.systemId);
188
340
  return {
189
341
  label: item.label ?? names.get(item.systemId) ?? item.systemId,
@@ -199,22 +351,30 @@ async function uptimeMap(
199
351
  ctx: WidgetResolveContext,
200
352
  ids: string[],
201
353
  ): Promise<Map<string, number>> {
354
+ const out = new Map<string, number>();
355
+ if (ids.length === 0) return out;
202
356
  const end = new Date();
203
357
  const start = new Date(end.getTime() - 30 * 86_400_000);
204
- const out = new Map<string, number>();
205
- const results = await Promise.allSettled(
206
- ids.map(async (systemId) => {
207
- const stats = await ctx.rpcClient
208
- .forPlugin(HealthCheckApi)
209
- .getRunStats({ systemId, startDate: start, endDate: end, maxBuckets: 1 });
210
- // No runs in the window => no uptime to report (don't surface a
211
- // misleading 0.00% for a system with no history).
212
- if (stats.total.runCount === 0) return null;
213
- return [systemId, stats.total.uptimePct] as const;
214
- }),
215
- );
216
- for (const r of results) {
217
- if (r.status === "fulfilled" && r.value) out.set(r.value[0], r.value[1]);
358
+ // ONE bulk call for every system's uptime, instead of an N+1 fan-out of
359
+ // per-system `getRunStats` (each holding a pooled connection). Systems with
360
+ // no runs are omitted from `stats`, so they never enter the map - preserving
361
+ // the previous "no runs => no misleading 0.00%" behavior exactly. When the
362
+ // page publishes a specific environment set, uptime counts only runs in those
363
+ // environments (env-less runs excluded).
364
+ const { stats } = await ctx.rpcClient
365
+ .forPlugin(HealthCheckApi)
366
+ .getBulkRunStats({
367
+ systemIds: ids,
368
+ startDate: start,
369
+ endDate: end,
370
+ ...(ctx.publishedEnvironmentIds
371
+ ? { environmentIds: ctx.publishedEnvironmentIds }
372
+ : {}),
373
+ maxBuckets: 1,
374
+ });
375
+ for (const systemId of ids) {
376
+ const s = stats[systemId];
377
+ if (s && s.total.runCount > 0) out.set(systemId, s.total.uptimePct);
218
378
  }
219
379
  return out;
220
380
  }
@@ -233,12 +393,21 @@ const groupStatus: WidgetTypeDefinition = {
233
393
  assertBindingsReadable: assertSystems((c) => ({
234
394
  groupIds: [GroupStatusConfigSchema.parse(c).groupId],
235
395
  })),
396
+ subscriptionCategory: "health",
397
+ resolveScopedSystems: async ({ config, ctx }) =>
398
+ new Set(await groupStatusScopedIds(config, ctx)),
399
+ async resolveScopedSystemsDetailed({ config, ctx }) {
400
+ const ids = await groupStatusScopedIds(config, ctx);
401
+ const names = await labelsFor(ctx, ids);
402
+ return ids.map((id) => ({ id, name: names.get(id) ?? id }));
403
+ },
236
404
  async resolvePublic({ config, ctx }) {
237
405
  const c = GroupStatusConfigSchema.parse(config);
238
406
  const groups = await allGroups(ctx);
239
407
  const group = groups.find((g) => g.id === c.groupId);
240
- const ids = group?.systemIds ?? [];
241
- const health = await healthStatuses(ctx, ids);
408
+ // Omit group members outside the page's published environments.
409
+ const ids = await groupStatusScopedIds(config, ctx);
410
+ const health = await healthPublicStatuses(ctx, ids);
242
411
  const maint = await inMaintenance(ctx, ids);
243
412
  const names = await labelsFor(ctx, ids);
244
413
  const systems = ids.map((systemId) => ({
@@ -249,6 +418,7 @@ const groupStatus: WidgetTypeDefinition = {
249
418
  label: c.label ?? group?.name ?? "Group",
250
419
  status: rollupStatus(systems.map((s) => s.status)),
251
420
  systems,
421
+ collapseWhenHealthy: c.collapseWhenHealthy,
252
422
  });
253
423
  },
254
424
  };
@@ -267,19 +437,46 @@ const uptime: WidgetTypeDefinition = {
267
437
  assertBindingsReadable: assertSystems((c) => ({
268
438
  systemIds: [UptimeConfigSchema.parse(c).systemId],
269
439
  })),
440
+ subscriptionCategory: "health",
441
+ resolveScopedSystems: async ({ config, ctx }) =>
442
+ new Set(await uptimeScopedIds(config, ctx)),
443
+ async resolveScopedSystemsDetailed({ config, ctx }) {
444
+ const c = UptimeConfigSchema.parse(config);
445
+ const ids = await uptimeScopedIds(config, ctx);
446
+ if (ids.length === 0) return [];
447
+ const names = await labelsFor(ctx, ids);
448
+ return ids.map((id) => ({ id, name: c.label ?? names.get(id) ?? id }));
449
+ },
270
450
  async resolvePublic({ config, ctx }) {
271
451
  const c = UptimeConfigSchema.parse(config);
452
+ const names = await labelsFor(ctx, [c.systemId]);
453
+ const label = c.label ?? names.get(c.systemId) ?? c.systemId;
454
+ // Omit this system's uptime when it is outside the page's published
455
+ // environments: emit the blank empty-state DTO (empty bars) the sibling
456
+ // health widgets use for out-of-scope systems, so the single-system uptime
457
+ // widget never shows misleading or stale-environment uptime. Gated on the
458
+ // CURRENT catalog env membership (`envVisibleSystems`), NOT on "getRunStats
459
+ // returned nothing" - so a system removed from the published env but still
460
+ // carrying old env-tagged runs is correctly blanked. No env filter (visible
461
+ // is null) leaves behavior unchanged.
462
+ const visible = await envVisibleSystems(ctx);
463
+ if (visible && !visible.has(c.systemId)) {
464
+ return UptimeDtoSchema.parse({ label, uptimePct: 0, bars: [] });
465
+ }
272
466
  const end = new Date();
273
467
  const start = new Date(end.getTime() - c.days * 86_400_000);
274
468
  const stats = await ctx.rpcClient.forPlugin(HealthCheckApi).getRunStats({
275
469
  systemId: c.systemId,
276
470
  startDate: start,
277
471
  endDate: end,
472
+ // Scope uptime to the page's published environments when set.
473
+ ...(ctx.publishedEnvironmentIds
474
+ ? { environmentIds: ctx.publishedEnvironmentIds }
475
+ : {}),
278
476
  maxBuckets: c.days,
279
477
  });
280
- const names = await labelsFor(ctx, [c.systemId]);
281
478
  return UptimeDtoSchema.parse({
282
- label: c.label ?? names.get(c.systemId) ?? c.systemId,
479
+ label,
283
480
  uptimePct: stats.total.uptimePct,
284
481
  bars: stats.buckets.map((b) => ({
285
482
  date: b.start,