@checkstack/healthcheck-backend 1.17.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +559 -0
  2. package/package.json +32 -29
  3. package/src/adaptive-timeout.test.ts +91 -0
  4. package/src/adaptive-timeout.ts +75 -0
  5. package/src/ai/system-signals-contributor.test.ts +2 -0
  6. package/src/automations.test.ts +47 -0
  7. package/src/automations.ts +19 -3
  8. package/src/health-notification-content.test.ts +89 -0
  9. package/src/health-notification-content.ts +138 -0
  10. package/src/healthcheck-gitops-kinds.test.ts +34 -2
  11. package/src/healthcheck-gitops-kinds.ts +17 -13
  12. package/src/index.ts +58 -6
  13. package/src/migration-chain-contract.test.ts +7 -1
  14. package/src/notification-policy.test.ts +19 -0
  15. package/src/notification-policy.ts +26 -0
  16. package/src/queue-executor.test.ts +391 -338
  17. package/src/queue-executor.ts +426 -362
  18. package/src/realtime-aggregation.ts +9 -2
  19. package/src/rollup-consumer.test.ts +191 -0
  20. package/src/rollup-consumer.ts +160 -0
  21. package/src/router.ts +46 -13
  22. package/src/schedule-jitter.test.ts +69 -0
  23. package/src/schedule-jitter.ts +50 -0
  24. package/src/schedule-reconciler.it.test.ts +453 -0
  25. package/src/schedule-reconciler.test.ts +418 -0
  26. package/src/schedule-reconciler.ts +304 -0
  27. package/src/service-batching.test.ts +106 -0
  28. package/src/service-bulk-counts.it.test.ts +144 -0
  29. package/src/service-bulk-run-stats.it.test.ts +197 -0
  30. package/src/service-ordering.test.ts +10 -2
  31. package/src/service-paused-filter.test.ts +27 -7
  32. package/src/service-rollup-worst-wins.test.ts +221 -124
  33. package/src/service.ts +557 -266
  34. package/src/slow-check-admission.test.ts +184 -0
  35. package/src/slow-check-admission.ts +101 -0
  36. package/src/slow-check-classifier.test.ts +155 -0
  37. package/src/slow-check-classifier.ts +137 -0
  38. package/src/slow-check-config.ts +102 -0
  39. package/src/status-page/rollup.test.ts +40 -0
  40. package/src/status-page/rollup.ts +27 -0
  41. package/src/status-page/widgets.test.ts +303 -0
  42. package/src/status-page/widgets.ts +155 -39
  43. package/src/suspect-lane.test.ts +50 -0
  44. package/src/suspect-lane.ts +61 -0
package/src/service.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  type HealthcheckSignalStatuses,
14
14
  type RunStats,
15
15
  stripEphemeralFields,
16
+ selectEffectiveEnvKeys,
16
17
  } from "@checkstack/healthcheck-common";
17
18
  import { evaluateCollectorAssertionOutcomes } from "./collector-assertions";
18
19
  import { summarizeRuns, type StatRun } from "./run-stats.logic";
@@ -43,6 +44,8 @@ import {
43
44
  isNull,
44
45
  isNotNull,
45
46
  inArray,
47
+ max,
48
+ count,
46
49
  } from "drizzle-orm";
47
50
  import { ORPCError } from "@orpc/server";
48
51
  import { evaluateHealthStatus } from "./state-evaluator";
@@ -51,7 +54,9 @@ import { parseHealthEntityId } from "./health-entity-id";
51
54
  import { stateThresholds } from "./state-thresholds-migrations";
52
55
  import type { MaintenanceApi } from "@checkstack/maintenance-common";
53
56
  import type { Logger } from "@checkstack/backend-api";
57
+ import { resolveEffectiveEnvironments } from "./effective-environments";
54
58
  import { incrementHourlyAggregate } from "./realtime-aggregation";
59
+ import { withScopedTransaction } from "@checkstack/backend-api";
55
60
  import type {
56
61
  HealthCheckRegistry,
57
62
  SafeDatabase,
@@ -107,6 +112,10 @@ interface SystemCheckStatus {
107
112
  status: HealthCheckStatus;
108
113
  runsConsidered: number;
109
114
  lastRunAt?: Date;
115
+ /** Environment slices this check currently fans out to (>= 1). */
116
+ sliceCount: number;
117
+ /** How many of {@link sliceCount} slices are currently non-healthy. */
118
+ failingSliceCount: number;
110
119
  }
111
120
 
112
121
  interface SystemHealthStatusResponse {
@@ -260,6 +269,53 @@ export class HealthCheckService {
260
269
  return config ? this.mapConfig(config) : undefined;
261
270
  }
262
271
 
272
+ /**
273
+ * Resolve the per-environment slices a (system, config) assignment should
274
+ * enqueue for a ONE-OFF run (the `run_now` automation). Returns the list of
275
+ * environment ids to run, or `[null]` (a single env-less run) when the
276
+ * assignment has no effective environments. Mirrors the executor's fan-out
277
+ * resolution so a manual run covers exactly the same slices the recurring
278
+ * schedule does. Fail-open: a catalog resolution failure collapses to a
279
+ * single env-less run rather than enqueuing nothing.
280
+ */
281
+ async resolveEnqueueEnvironmentIds(props: {
282
+ systemId: string;
283
+ configurationId: string;
284
+ catalogClient: CatalogClient;
285
+ logger: Logger;
286
+ }): Promise<(string | null)[]> {
287
+ const { systemId, configurationId, catalogClient, logger } = props;
288
+ const [assignment] = await this.db
289
+ .select({ environmentIds: systemHealthChecks.environmentIds })
290
+ .from(systemHealthChecks)
291
+ .where(
292
+ and(
293
+ eq(systemHealthChecks.systemId, systemId),
294
+ eq(systemHealthChecks.configurationId, configurationId),
295
+ ),
296
+ );
297
+
298
+ let membership: Awaited<
299
+ ReturnType<CatalogClient["resolveSystemEnvironments"]>
300
+ > = [];
301
+ try {
302
+ membership = await catalogClient.resolveSystemEnvironments({ systemId });
303
+ } catch (error) {
304
+ logger.warn(
305
+ `run_now: could not resolve environments for system ${systemId}`,
306
+ error,
307
+ );
308
+ }
309
+
310
+ const effectiveEnvs = resolveEffectiveEnvironments({
311
+ environmentIds: assignment?.environmentIds,
312
+ membership,
313
+ });
314
+ return effectiveEnvs.length > 0
315
+ ? effectiveEnvs.map((env) => env.id)
316
+ : [null];
317
+ }
318
+
263
319
  /**
264
320
  * Redact a configuration for a UI/AI read: every `x-secret` field is
265
321
  * removed from the strategy config and each collector config. Stored
@@ -933,6 +989,39 @@ export class HealthCheckService {
933
989
  return results;
934
990
  }
935
991
 
992
+ /**
993
+ * Count of health-check assignments for each of `systemIds`, keyed by
994
+ * systemId. ONE grouped query (no per-system fan-out) so the catalog manager
995
+ * can badge the whole visible list without an N+1 of {@link
996
+ * getSystemAssociations} - each of those held a pooled connection and
997
+ * contended with the run executor. Every requested system appears in the
998
+ * result (`0` when it has no `system_health_checks` rows, since the grouped
999
+ * query only returns systems that have at least one); the RPC's `recordKey`
1000
+ * gating then drops any keys the caller may not read.
1001
+ */
1002
+ async getBulkAssignedHealthCheckCounts(
1003
+ systemIds: string[],
1004
+ ): Promise<Record<string, number>> {
1005
+ // Seed 0 for every requested system so those with no assignments still
1006
+ // report a count (GROUP BY omits systems with zero rows).
1007
+ const counts: Record<string, number> = {};
1008
+ for (const id of systemIds) counts[id] = 0;
1009
+
1010
+ if (systemIds.length === 0) return counts;
1011
+
1012
+ const rows = await this.db
1013
+ .select({
1014
+ systemId: systemHealthChecks.systemId,
1015
+ assignmentCount: count(),
1016
+ })
1017
+ .from(systemHealthChecks)
1018
+ .where(inArray(systemHealthChecks.systemId, systemIds))
1019
+ .groupBy(systemHealthChecks.systemId);
1020
+
1021
+ for (const row of rows) counts[row.systemId] = row.assignmentCount;
1022
+ return counts;
1023
+ }
1024
+
936
1025
  /**
937
1026
  * List the IDs of every system an ENABLED assignment of `configurationId`
938
1027
  * targets. Used by the pause/resume RPC handlers to know which systems'
@@ -1030,159 +1119,212 @@ export class HealthCheckService {
1030
1119
  systemId: string,
1031
1120
  environmentId?: string | null,
1032
1121
  ): Promise<SystemHealthStatusResponse> {
1033
- // Get all associations for this system with their thresholds and config names
1034
- const associations = await this.db
1035
- .select({
1036
- configurationId: systemHealthChecks.configurationId,
1037
- stateThresholds: systemHealthChecks.stateThresholds,
1038
- configName: healthCheckConfigurations.name,
1039
- enabled: systemHealthChecks.enabled,
1040
- })
1041
- .from(systemHealthChecks)
1042
- .innerJoin(
1043
- healthCheckConfigurations,
1044
- eq(systemHealthChecks.configurationId, healthCheckConfigurations.id),
1045
- )
1046
- .where(
1047
- and(
1048
- eq(systemHealthChecks.systemId, systemId),
1049
- eq(systemHealthChecks.enabled, true),
1050
- // A paused configuration contributes no signal to the system's
1051
- // health: its execution is skipped (see queue-executor pause gate)
1052
- // and its historical runs MUST NOT keep the aggregate degraded
1053
- // while it is paused. Excluding it here makes the rollup reflect
1054
- // only the actively-running checks, so pausing the sole failing
1055
- // check clears the system's status and downstream SLO downtime.
1056
- eq(healthCheckConfigurations.paused, false),
1057
- ),
1058
- );
1059
-
1060
- if (associations.length === 0) {
1061
- // No health checks configured - default healthy
1062
- return {
1063
- status: "healthy",
1064
- evaluatedAt: new Date(),
1065
- checkStatuses: [],
1066
- };
1067
- }
1068
-
1069
- // For each association, get recent runs and evaluate status
1070
- const checkStatuses: SystemCheckStatus[] = [];
1071
- const maxWindowSize = 100; // Max configurable window size
1072
-
1073
- // Environment filter for the per-check run window. `undefined` (rollup)
1074
- // adds no predicate; `null` filters to the env-less slice; a string
1075
- // filters to that environment. The lookup index leads with
1076
- // (system_id, environment_id, …) so the env-scoped query is index-efficient.
1077
- //
1078
- // For the rollup, we deliberately do NOT apply a single envFilter to one
1079
- // flat run list — see the per-association branch below for why.
1080
- const envFilter =
1081
- environmentId === undefined
1082
- ? undefined
1083
- : environmentId === null
1084
- ? isNull(healthCheckRuns.environmentId)
1085
- : eq(healthCheckRuns.environmentId, environmentId);
1122
+ // §perf: batch the 1 (associations) + N (per-check run window) reads
1123
+ // into ONE scoped transaction so the whole read fan-out pays a single
1124
+ // BEGIN/SET LOCAL/COMMIT and holds one connection, instead of 1+N
1125
+ // standalone scoped queries each checking a connection out. Pure
1126
+ // evaluation runs inside too (it issues no DB). See withScopedTransaction.
1127
+ const checkStatuses = await withScopedTransaction(this.db, async (tx) => {
1128
+ // Get all associations for this system with their thresholds and config names
1129
+ const associations = await tx
1130
+ .select({
1131
+ configurationId: systemHealthChecks.configurationId,
1132
+ stateThresholds: systemHealthChecks.stateThresholds,
1133
+ configName: healthCheckConfigurations.name,
1134
+ enabled: systemHealthChecks.enabled,
1135
+ // The per-assignment environment selector. Drives the rollup's
1136
+ // effective-slice filter below so a per-env slice whose environment
1137
+ // was DISABLED for this assignment (removed from `environmentIds`)
1138
+ // stops dragging the aggregate the instant it is disabled - instead
1139
+ // of lingering until its stale runs age out of the bounded window.
1140
+ environmentIds: systemHealthChecks.environmentIds,
1141
+ })
1142
+ .from(systemHealthChecks)
1143
+ .innerJoin(
1144
+ healthCheckConfigurations,
1145
+ eq(systemHealthChecks.configurationId, healthCheckConfigurations.id),
1146
+ )
1147
+ .where(
1148
+ and(
1149
+ eq(systemHealthChecks.systemId, systemId),
1150
+ eq(systemHealthChecks.enabled, true),
1151
+ // A paused configuration contributes no signal to the system's
1152
+ // health: its execution is skipped (see queue-executor pause gate)
1153
+ // and its historical runs MUST NOT keep the aggregate degraded
1154
+ // while it is paused. Excluding it here makes the rollup reflect
1155
+ // only the actively-running checks, so pausing the sole failing
1156
+ // check clears the system's status and downstream SLO downtime.
1157
+ eq(healthCheckConfigurations.paused, false),
1158
+ ),
1159
+ );
1086
1160
 
1087
- for (const assoc of associations) {
1088
- // Extract and migrate thresholds from versioned config
1089
- let thresholds: StateThresholds | undefined;
1090
- if (assoc.stateThresholds) {
1091
- thresholds = await stateThresholds.parse(assoc.stateThresholds);
1092
- }
1161
+ if (associations.length === 0) return [];
1162
+
1163
+ // For each association, get recent runs and evaluate status
1164
+ const out: SystemCheckStatus[] = [];
1165
+ const maxWindowSize = 100; // Max configurable window size
1166
+
1167
+ // Environment filter for the per-check run window. `undefined` (rollup)
1168
+ // adds no predicate; `null` filters to the env-less slice; a string
1169
+ // filters to that environment. The lookup index leads with
1170
+ // (system_id, environment_id, …) so the env-scoped query is index-efficient.
1171
+ //
1172
+ // For the rollup, we deliberately do NOT apply a single envFilter to one
1173
+ // flat run list — see the per-association branch below for why.
1174
+ const envFilter =
1175
+ environmentId === undefined
1176
+ ? undefined
1177
+ : environmentId === null
1178
+ ? isNull(healthCheckRuns.environmentId)
1179
+ : eq(healthCheckRuns.environmentId, environmentId);
1180
+
1181
+ for (const assoc of associations) {
1182
+ // Extract and migrate thresholds from versioned config
1183
+ let thresholds: StateThresholds | undefined;
1184
+ if (assoc.stateThresholds) {
1185
+ thresholds = await stateThresholds.parse(assoc.stateThresholds);
1186
+ }
1093
1187
 
1094
- let status: HealthCheckStatus;
1095
- let runsConsidered: number;
1096
- let lastRunAt: Date | undefined;
1097
-
1098
- if (environmentId === undefined) {
1099
- // System rollup: evaluate the threshold window PER ENVIRONMENT within
1100
- // the association, then take worst-wins ACROSS envs. Flattening every
1101
- // env's runs into one list feeds interleaved statuses to
1102
- // `evaluateConsecutive` (the default mode): the streak breaks on the
1103
- // first interleaving env, so the evaluator collapses to whatever
1104
- // single env's status the most recent run landed on. That masks any
1105
- // permanently-failing sibling env in the default mode ("the healthy
1106
- // env wins"), and flaps healthy↔degraded whenever env insertion
1107
- // order drifts across ticks (see the regression test
1108
- // `rollup — worst-wins across environments within an association`).
1109
- // Per-env evaluation makes the rollup worst-wins stable regardless of
1110
- // insertion order or multi-pod racing.
1111
- const runs = await this.db
1112
- .select({
1113
- status: healthCheckRuns.status,
1114
- timestamp: healthCheckRuns.timestamp,
1115
- environmentId: healthCheckRuns.environmentId,
1116
- })
1117
- .from(healthCheckRuns)
1118
- .where(
1119
- and(
1120
- eq(healthCheckRuns.systemId, systemId),
1121
- eq(healthCheckRuns.configurationId, assoc.configurationId),
1122
- ),
1123
- )
1124
- .orderBy(desc(healthCheckRuns.timestamp))
1125
- .limit(maxWindowSize);
1188
+ let status: HealthCheckStatus;
1189
+ let runsConsidered: number;
1190
+ let lastRunAt: Date | undefined;
1191
+ // Fan-out accounting for the honest "X of Y checks failing" denominator:
1192
+ // how many environment slices this check currently spans, and how many
1193
+ // are non-healthy. A non-fanned (single-env / env-less) check is one
1194
+ // slice. Populated in both branches so the DTO field is always present.
1195
+ let sliceCount = 1;
1196
+ let failingSliceCount = 0;
1197
+
1198
+ if (environmentId === undefined) {
1199
+ // System rollup: evaluate the threshold window PER ENVIRONMENT within
1200
+ // the association, then take worst-wins ACROSS envs. Flattening every
1201
+ // env's runs into one list feeds interleaved statuses to
1202
+ // `evaluateConsecutive` (the default mode): the streak breaks on the
1203
+ // first interleaving env, so the evaluator collapses to whatever
1204
+ // single env's status the most recent run landed on. That masks any
1205
+ // permanently-failing sibling env in the default mode ("the healthy
1206
+ // env wins"), and flaps healthy↔degraded whenever env insertion
1207
+ // order drifts across ticks (see the regression test
1208
+ // `rollup — worst-wins across environments within an association`).
1209
+ // Per-env evaluation makes the rollup worst-wins stable regardless of
1210
+ // insertion order or multi-pod racing.
1211
+ //
1212
+ // Each env is windowed SEPARATELY (`maxWindowSize` runs PER env), not
1213
+ // via one shared `LIMIT maxWindowSize` across the mixed pool. A shared
1214
+ // window silently truncates a check that fans out to many envs: with
1215
+ // E envs each env sees only ~maxWindowSize/E of its own runs, so a
1216
+ // small consecutive threshold can miss a genuine per-env streak once
1217
+ // E grows. Per-env windows give every environment its full evaluation
1218
+ // depth regardless of how many siblings it has.
1219
+ const distinctEnvRows = await tx
1220
+ .selectDistinct({ environmentId: healthCheckRuns.environmentId })
1221
+ .from(healthCheckRuns)
1222
+ .where(
1223
+ and(
1224
+ eq(healthCheckRuns.systemId, systemId),
1225
+ eq(healthCheckRuns.configurationId, assoc.configurationId),
1226
+ ),
1227
+ );
1228
+ const presentEnvKeys = distinctEnvRows.map(
1229
+ (r) => r.environmentId ?? null,
1230
+ );
1126
1231
 
1127
- // Group by environmentId. `null` is its own group (the env-less slice
1128
- // of an assignment that has opted out, plus any pre-3b env-less runs).
1129
- const byEnv = new Map<string | null, { status: HealthCheckStatus; timestamp: Date }[]>();
1130
- for (const r of runs) {
1131
- const key = r.environmentId ?? null;
1132
- const bucket = byEnv.get(key);
1133
- if (bucket) {
1134
- bucket.push(r);
1135
- } else {
1136
- byEnv.set(key, [r]);
1137
- }
1138
- }
1232
+ // Keep only slices that are still EFFECTIVE for this assignment: a
1233
+ // concrete environment removed from `environmentIds` (the reported
1234
+ // bug - "disable env for assignment"), plus the stale env-less slice
1235
+ // of a check that now fans out, are dropped. Without this a disabled
1236
+ // env's last unhealthy runs keep dragging the rollup via worst-wins,
1237
+ // because no health-change event fires for a slice that stopped
1238
+ // producing runs, so the event-driven rollup consumer never recomputes
1239
+ // it away. The selector is durable Postgres state (`environmentIds`),
1240
+ // so this is catalog-free and returns the same answer on every pod.
1241
+ const effectiveKeys = selectEffectiveEnvKeys({
1242
+ environmentIds: assoc.environmentIds,
1243
+ presentEnvKeys,
1244
+ });
1139
1245
 
1140
- status = "healthy";
1141
- runsConsidered = runs.length;
1142
- lastRunAt = runs[0]?.timestamp;
1143
- for (const envRuns of byEnv.values()) {
1144
- const envStatus = evaluateHealthStatus({ runs: envRuns, thresholds });
1145
- if (envStatus === "unhealthy") {
1146
- status = "unhealthy";
1147
- break; // worst: stop
1148
- }
1149
- if (envStatus === "degraded" && status === "healthy") {
1150
- status = "degraded";
1246
+ status = "healthy";
1247
+ runsConsidered = 0;
1248
+ lastRunAt = undefined;
1249
+ // Each EFFECTIVE env group is a slice. A check that has runs against N
1250
+ // effective envs currently fans out to N; before it has ever run (no
1251
+ // effective group) it is still one logical slice.
1252
+ sliceCount = Math.max(effectiveKeys.size, 1);
1253
+ failingSliceCount = 0;
1254
+ for (const key of effectiveKeys) {
1255
+ const envRuns = await tx
1256
+ .select({
1257
+ status: healthCheckRuns.status,
1258
+ timestamp: healthCheckRuns.timestamp,
1259
+ })
1260
+ .from(healthCheckRuns)
1261
+ .where(
1262
+ and(
1263
+ eq(healthCheckRuns.systemId, systemId),
1264
+ eq(healthCheckRuns.configurationId, assoc.configurationId),
1265
+ key === null
1266
+ ? isNull(healthCheckRuns.environmentId)
1267
+ : eq(healthCheckRuns.environmentId, key),
1268
+ ),
1269
+ )
1270
+ .orderBy(desc(healthCheckRuns.timestamp))
1271
+ .limit(maxWindowSize);
1272
+
1273
+ runsConsidered += envRuns.length;
1274
+ const newest = envRuns[0]?.timestamp;
1275
+ if (newest && (!lastRunAt || newest > lastRunAt)) lastRunAt = newest;
1276
+ const envStatus = evaluateHealthStatus({ runs: envRuns, thresholds });
1277
+ // Count EVERY failing slice (don't break early): the failing count
1278
+ // feeds the dashboard numerator, so all non-healthy envs must tally.
1279
+ if (envStatus !== "healthy") {
1280
+ failingSliceCount++;
1281
+ }
1282
+ if (envStatus === "unhealthy") {
1283
+ status = "unhealthy";
1284
+ } else if (envStatus === "degraded" && status === "healthy") {
1285
+ status = "degraded";
1286
+ }
1151
1287
  }
1288
+ } else {
1289
+ // Per-env (string) or env-less (null) slice: that slice's flat run
1290
+ // window is monotonic per-env, so the threshold evaluator sees no
1291
+ // interleaving — the consecutive streak is well-defined.
1292
+ const runs = await tx
1293
+ .select({
1294
+ status: healthCheckRuns.status,
1295
+ timestamp: healthCheckRuns.timestamp,
1296
+ })
1297
+ .from(healthCheckRuns)
1298
+ .where(
1299
+ and(
1300
+ eq(healthCheckRuns.systemId, systemId),
1301
+ eq(healthCheckRuns.configurationId, assoc.configurationId),
1302
+ ...(envFilter ? [envFilter] : []),
1303
+ ),
1304
+ )
1305
+ .orderBy(desc(healthCheckRuns.timestamp))
1306
+ .limit(maxWindowSize);
1307
+
1308
+ status = evaluateHealthStatus({ runs, thresholds });
1309
+ runsConsidered = runs.length;
1310
+ lastRunAt = runs[0]?.timestamp;
1311
+ // Single-slice evaluation: this env either counts as failing or not.
1312
+ sliceCount = 1;
1313
+ failingSliceCount = status === "healthy" ? 0 : 1;
1152
1314
  }
1153
- } else {
1154
- // Per-env (string) or env-less (null) slice: that slice's flat run
1155
- // window is monotonic per-env, so the threshold evaluator sees no
1156
- // interleaving — the consecutive streak is well-defined.
1157
- const runs = await this.db
1158
- .select({
1159
- status: healthCheckRuns.status,
1160
- timestamp: healthCheckRuns.timestamp,
1161
- })
1162
- .from(healthCheckRuns)
1163
- .where(
1164
- and(
1165
- eq(healthCheckRuns.systemId, systemId),
1166
- eq(healthCheckRuns.configurationId, assoc.configurationId),
1167
- ...(envFilter ? [envFilter] : []),
1168
- ),
1169
- )
1170
- .orderBy(desc(healthCheckRuns.timestamp))
1171
- .limit(maxWindowSize);
1172
1315
 
1173
- status = evaluateHealthStatus({ runs, thresholds });
1174
- runsConsidered = runs.length;
1175
- lastRunAt = runs[0]?.timestamp;
1316
+ out.push({
1317
+ configurationId: assoc.configurationId,
1318
+ configurationName: assoc.configName,
1319
+ status,
1320
+ runsConsidered,
1321
+ lastRunAt,
1322
+ sliceCount,
1323
+ failingSliceCount,
1324
+ });
1176
1325
  }
1177
-
1178
- checkStatuses.push({
1179
- configurationId: assoc.configurationId,
1180
- configurationName: assoc.configName,
1181
- status,
1182
- runsConsidered,
1183
- lastRunAt,
1184
- });
1185
- }
1326
+ return out;
1327
+ });
1186
1328
 
1187
1329
  // Aggregate status: worst status wins (unhealthy > degraded > healthy)
1188
1330
  let aggregateStatus: HealthCheckStatus = "healthy";
@@ -1439,157 +1581,237 @@ export class HealthCheckService {
1439
1581
  * Returns all health checks with their last 25 runs for sparkline visualization.
1440
1582
  */
1441
1583
  async getSystemHealthOverview(systemId: string) {
1442
- // Get all associations with config details
1443
- const associations = await this.db
1444
- .select({
1445
- configurationId: systemHealthChecks.configurationId,
1446
- configName: healthCheckConfigurations.name,
1447
- strategyId: healthCheckConfigurations.strategyId,
1448
- intervalSeconds: healthCheckConfigurations.intervalSeconds,
1449
- enabled: systemHealthChecks.enabled,
1450
- paused: healthCheckConfigurations.paused,
1451
- stateThresholds: systemHealthChecks.stateThresholds,
1452
- })
1453
- .from(systemHealthChecks)
1454
- .innerJoin(
1455
- healthCheckConfigurations,
1456
- eq(systemHealthChecks.configurationId, healthCheckConfigurations.id),
1457
- )
1458
- .where(eq(systemHealthChecks.systemId, systemId));
1459
-
1460
- const checks = [];
1461
- const sparklineLimit = 25;
1462
-
1463
- for (const assoc of associations) {
1464
- // Get last 25 runs for sparkline (newest first, then reverse for chronological display)
1465
- const runs = await this.db
1584
+ // §perf: batch the 1 (associations) + N·(2+E) (per-check recentRuns +
1585
+ // grouped last-healthy + per-env slice) reads into ONE scoped transaction
1586
+ // so the whole read fan-out pays a single BEGIN/SET LOCAL/COMMIT and holds
1587
+ // one connection, instead of 1+N·(2+E) standalone scoped queries each
1588
+ // checking a connection out. Only pure CPU (stateThresholds.parse,
1589
+ // evaluateHealthStatus, selectEffectiveEnvKeys) sits between the queries —
1590
+ // no DB-external await — so wrapping is safe. This mirrors the sibling
1591
+ // getSystemHealthStatus above. See withScopedTransaction.
1592
+ const checks = await withScopedTransaction(this.db, async (tx) => {
1593
+ // Get all associations with config details
1594
+ const associations = await tx
1466
1595
  .select({
1467
- id: healthCheckRuns.id,
1468
- status: healthCheckRuns.status,
1469
- timestamp: healthCheckRuns.timestamp,
1470
- environmentId: healthCheckRuns.environmentId,
1596
+ configurationId: systemHealthChecks.configurationId,
1597
+ configName: healthCheckConfigurations.name,
1598
+ strategyId: healthCheckConfigurations.strategyId,
1599
+ intervalSeconds: healthCheckConfigurations.intervalSeconds,
1600
+ enabled: systemHealthChecks.enabled,
1601
+ paused: healthCheckConfigurations.paused,
1602
+ stateThresholds: systemHealthChecks.stateThresholds,
1603
+ // The per-assignment environment selector, surfaced so the check-level
1604
+ // rollup status here excludes slices whose env was disabled for this
1605
+ // assignment, and so the response can carry it to the frontend orphan
1606
+ // detection (a disabled-for-assignment env is tucked under "Old checks"
1607
+ // even though it is still part of the system's membership).
1608
+ environmentIds: systemHealthChecks.environmentIds,
1471
1609
  })
1472
- .from(healthCheckRuns)
1473
- .where(
1474
- and(
1475
- eq(healthCheckRuns.systemId, systemId),
1476
- eq(healthCheckRuns.configurationId, assoc.configurationId),
1477
- ),
1610
+ .from(systemHealthChecks)
1611
+ .innerJoin(
1612
+ healthCheckConfigurations,
1613
+ eq(systemHealthChecks.configurationId, healthCheckConfigurations.id),
1478
1614
  )
1479
- .orderBy(desc(healthCheckRuns.timestamp))
1480
- .limit(sparklineLimit);
1615
+ .where(eq(systemHealthChecks.systemId, systemId));
1481
1616
 
1482
- // Reverse to chronological order (oldest first) for sparkline display
1483
- const chronologicalRuns = runs.toReversed();
1617
+ const checks = [];
1618
+ const sparklineLimit = 25;
1484
1619
 
1485
- // Migrate and extract thresholds
1486
- let thresholds: StateThresholds | undefined;
1487
- if (assoc.stateThresholds) {
1488
- thresholds = await stateThresholds.parse(assoc.stateThresholds);
1489
- }
1490
-
1491
- // Group the fetched runs by environmentId (null = env-less slice). We
1492
- // query each env's slice separately below to evaluate it on its own
1493
- // monotonic run window and worst-wins across envs — this is the same
1494
- // derivation `getSystemHealthStatus(systemId)` uses for the rollup; see
1495
- // that method for the rationale (flattening envs feeds interleaved
1496
- // statuses to the consecutive evaluator and masks sibling outages).
1497
- const perEnvironment: {
1498
- environmentId: string | null;
1499
- status: HealthCheckStatus;
1500
- recentRuns: { id: string; status: HealthCheckStatus; timestamp: Date }[];
1501
- }[] = [];
1502
-
1503
- // Stable ordering of env keys: env-less (`null`) first, then env ids in
1504
- // the order they were first encountered in the mixed pool (membership
1505
- // order is otherwise unobservable here without a catalog read; recent
1506
- // runs surface stable, recent order).
1507
- const envKeys: (string | null)[] = [];
1508
- const seenEnv = new Set<string | null>();
1509
- for (const r of runs) {
1510
- const key = r.environmentId ?? null;
1511
- if (!seenEnv.has(key)) {
1512
- seenEnv.add(key);
1513
- envKeys.push(key);
1514
- }
1515
- }
1516
- // If no runs at all, surface a single env-less entry so UI can render
1517
- // an empty row rather than nothing.
1518
- if (envKeys.length === 0) envKeys.push(null);
1519
-
1520
- let aggregateStatus: HealthCheckStatus = "healthy";
1521
- for (const envId of envKeys) {
1522
- const envRuns = await this.db
1620
+ for (const assoc of associations) {
1621
+ // Get last 25 runs for sparkline (newest first, then reverse for chronological display)
1622
+ const runs = await tx
1523
1623
  .select({
1524
1624
  id: healthCheckRuns.id,
1525
1625
  status: healthCheckRuns.status,
1526
1626
  timestamp: healthCheckRuns.timestamp,
1627
+ environmentId: healthCheckRuns.environmentId,
1527
1628
  })
1528
1629
  .from(healthCheckRuns)
1529
1630
  .where(
1530
1631
  and(
1531
1632
  eq(healthCheckRuns.systemId, systemId),
1532
1633
  eq(healthCheckRuns.configurationId, assoc.configurationId),
1533
- envId === null
1534
- ? isNull(healthCheckRuns.environmentId)
1535
- : eq(healthCheckRuns.environmentId, envId),
1536
1634
  ),
1537
1635
  )
1538
1636
  .orderBy(desc(healthCheckRuns.timestamp))
1539
1637
  .limit(sparklineLimit);
1540
1638
 
1541
- const envStatus = evaluateHealthStatus({
1542
- runs: envRuns,
1543
- thresholds,
1639
+ // Reverse to chronological order (oldest first) for sparkline display
1640
+ const chronologicalRuns = runs.toReversed();
1641
+
1642
+ // Migrate and extract thresholds
1643
+ let thresholds: StateThresholds | undefined;
1644
+ if (assoc.stateThresholds) {
1645
+ thresholds = await stateThresholds.parse(assoc.stateThresholds);
1646
+ }
1647
+
1648
+ // Most recent HEALTHY run per environment, computed OUTSIDE the bounded
1649
+ // sparkline window so "last successful run" stays correct even when a
1650
+ // check has been failing for far longer than the last 25 runs. One
1651
+ // grouped aggregate query per check (env-less = the `null` group). The
1652
+ // (system_id, configuration_id, environment_id, timestamp) index makes
1653
+ // this a cheap max-per-group scan.
1654
+ const lastHealthyRows = await tx
1655
+ .select({
1656
+ environmentId: healthCheckRuns.environmentId,
1657
+ lastSuccessAt: max(healthCheckRuns.timestamp),
1658
+ })
1659
+ .from(healthCheckRuns)
1660
+ .where(
1661
+ and(
1662
+ eq(healthCheckRuns.systemId, systemId),
1663
+ eq(healthCheckRuns.configurationId, assoc.configurationId),
1664
+ eq(healthCheckRuns.status, "healthy"),
1665
+ ),
1666
+ )
1667
+ .groupBy(healthCheckRuns.environmentId);
1668
+ const lastHealthyByEnv = new Map<string | null, Date>();
1669
+ let checkLastSuccessfulRunAt: Date | undefined;
1670
+ for (const row of lastHealthyRows) {
1671
+ if (!row.lastSuccessAt) continue;
1672
+ lastHealthyByEnv.set(row.environmentId ?? null, row.lastSuccessAt);
1673
+ if (
1674
+ !checkLastSuccessfulRunAt ||
1675
+ row.lastSuccessAt > checkLastSuccessfulRunAt
1676
+ ) {
1677
+ checkLastSuccessfulRunAt = row.lastSuccessAt;
1678
+ }
1679
+ }
1680
+
1681
+ // Group the fetched runs by environmentId (null = env-less slice). We
1682
+ // query each env's slice separately below to evaluate it on its own
1683
+ // monotonic run window and worst-wins across envs — this is the same
1684
+ // derivation `getSystemHealthStatus(systemId)` uses for the rollup; see
1685
+ // that method for the rationale (flattening envs feeds interleaved
1686
+ // statuses to the consecutive evaluator and masks sibling outages).
1687
+ const perEnvironment: {
1688
+ environmentId: string | null;
1689
+ status: HealthCheckStatus;
1690
+ lastSuccessfulRunAt?: Date;
1691
+ recentRuns: {
1692
+ id: string;
1693
+ status: HealthCheckStatus;
1694
+ timestamp: Date;
1695
+ }[];
1696
+ }[] = [];
1697
+
1698
+ // Stable ordering of env keys: env-less (`null`) first, then env ids in
1699
+ // the order they were first encountered in the mixed pool (membership
1700
+ // order is otherwise unobservable here without a catalog read; recent
1701
+ // runs surface stable, recent order).
1702
+ const envKeys: (string | null)[] = [];
1703
+ const seenEnv = new Set<string | null>();
1704
+ for (const r of runs) {
1705
+ const key = r.environmentId ?? null;
1706
+ if (!seenEnv.has(key)) {
1707
+ seenEnv.add(key);
1708
+ envKeys.push(key);
1709
+ }
1710
+ }
1711
+ // If no runs at all, surface a single env-less entry so UI can render
1712
+ // an empty row rather than nothing.
1713
+ if (envKeys.length === 0) envKeys.push(null);
1714
+
1715
+ // The slices that currently CONTRIBUTE to this check's rollup status: a
1716
+ // concrete env removed from `environmentIds` (disabled for the assignment)
1717
+ // and the stale env-less slice of a check that now fans out are excluded,
1718
+ // mirroring `getSystemHealthStatus`. The orphaned slices are still emitted
1719
+ // in `perEnvironment` (the frontend tucks them under "Old checks"); they
1720
+ // just no longer drag the check-level worst-wins `status`.
1721
+ const effectiveKeys = selectEffectiveEnvKeys({
1722
+ environmentIds: assoc.environmentIds,
1723
+ presentEnvKeys: envKeys,
1544
1724
  });
1545
- // Worst-wins across envs (unhealthy > degraded > healthy).
1546
- if (envStatus === "unhealthy") {
1547
- aggregateStatus = "unhealthy";
1548
- } else if (envStatus === "degraded" && aggregateStatus === "healthy") {
1549
- aggregateStatus = "degraded";
1725
+
1726
+ let aggregateStatus: HealthCheckStatus = "healthy";
1727
+ for (const envId of envKeys) {
1728
+ const envRuns = await tx
1729
+ .select({
1730
+ id: healthCheckRuns.id,
1731
+ status: healthCheckRuns.status,
1732
+ timestamp: healthCheckRuns.timestamp,
1733
+ })
1734
+ .from(healthCheckRuns)
1735
+ .where(
1736
+ and(
1737
+ eq(healthCheckRuns.systemId, systemId),
1738
+ eq(healthCheckRuns.configurationId, assoc.configurationId),
1739
+ envId === null
1740
+ ? isNull(healthCheckRuns.environmentId)
1741
+ : eq(healthCheckRuns.environmentId, envId),
1742
+ ),
1743
+ )
1744
+ .orderBy(desc(healthCheckRuns.timestamp))
1745
+ .limit(sparklineLimit);
1746
+
1747
+ const envStatus = evaluateHealthStatus({
1748
+ runs: envRuns,
1749
+ thresholds,
1750
+ });
1751
+ // Worst-wins across EFFECTIVE envs (unhealthy > degraded > healthy).
1752
+ // An orphaned slice's status is still reported per-env but must not
1753
+ // move the aggregate.
1754
+ if (effectiveKeys.has(envId)) {
1755
+ if (envStatus === "unhealthy") {
1756
+ aggregateStatus = "unhealthy";
1757
+ } else if (
1758
+ envStatus === "degraded" &&
1759
+ aggregateStatus === "healthy"
1760
+ ) {
1761
+ aggregateStatus = "degraded";
1762
+ }
1763
+ }
1764
+
1765
+ perEnvironment.push({
1766
+ environmentId: envId,
1767
+ status: envStatus,
1768
+ lastSuccessfulRunAt: lastHealthyByEnv.get(envId),
1769
+ recentRuns: envRuns.toReversed().map((r) => ({
1770
+ id: r.id,
1771
+ status: r.status,
1772
+ timestamp: r.timestamp,
1773
+ })),
1774
+ });
1550
1775
  }
1551
1776
 
1552
- perEnvironment.push({
1553
- environmentId: envId,
1554
- status: envStatus,
1555
- recentRuns: envRuns.toReversed().map((r) => ({
1777
+ // Evaluate current status (runs are in DESC order - newest first - as evaluateHealthStatus expects).
1778
+ // For a paused configuration the runs are stale (execution is skipped),
1779
+ // so the evaluated `status` is NOT a meaningful current verdict — the
1780
+ // frontend renders a "Paused" pill from the `paused` flag instead.
1781
+ // We still compute it so the historical/sparkline path stays uniform,
1782
+ // and so a non-paused consumer that ignores `paused` sees a best-
1783
+ // effort status rather than a hard null. `aggregateStatus` is the
1784
+ // worst-wins-across-envs rollup derived above (it equals what
1785
+ // evaluateHealthStatus would return on the flat pool if only ONE env is
1786
+ // present, preserving per-check single-env behavior).
1787
+ const status = aggregateStatus;
1788
+
1789
+ checks.push({
1790
+ configurationId: assoc.configurationId,
1791
+ configurationName: assoc.configName,
1792
+ strategyId: assoc.strategyId,
1793
+ intervalSeconds: assoc.intervalSeconds,
1794
+ enabled: assoc.enabled,
1795
+ paused: assoc.paused,
1796
+ status,
1797
+ stateThresholds: thresholds,
1798
+ // Surface the per-assignment environment selector so the frontend can
1799
+ // treat a slice whose env was disabled for THIS assignment as orphaned
1800
+ // (system membership alone can't tell - the env is still in the system).
1801
+ environmentIds: assoc.environmentIds,
1802
+ lastSuccessfulRunAt: checkLastSuccessfulRunAt,
1803
+ recentRuns: chronologicalRuns.map((r) => ({
1556
1804
  id: r.id,
1557
1805
  status: r.status,
1558
1806
  timestamp: r.timestamp,
1807
+ environmentId: r.environmentId,
1559
1808
  })),
1809
+ perEnvironment,
1560
1810
  });
1561
1811
  }
1562
1812
 
1563
- // Evaluate current status (runs are in DESC order - newest first - as evaluateHealthStatus expects).
1564
- // For a paused configuration the runs are stale (execution is skipped),
1565
- // so the evaluated `status` is NOT a meaningful current verdict — the
1566
- // frontend renders a "Paused" pill from the `paused` flag instead.
1567
- // We still compute it so the historical/sparkline path stays uniform,
1568
- // and so a non-paused consumer that ignores `paused` sees a best-
1569
- // effort status rather than a hard null. `aggregateStatus` is the
1570
- // worst-wins-across-envs rollup derived above (it equals what
1571
- // evaluateHealthStatus would return on the flat pool if only ONE env is
1572
- // present, preserving per-check single-env behavior).
1573
- const status = aggregateStatus;
1574
-
1575
- checks.push({
1576
- configurationId: assoc.configurationId,
1577
- configurationName: assoc.configName,
1578
- strategyId: assoc.strategyId,
1579
- intervalSeconds: assoc.intervalSeconds,
1580
- enabled: assoc.enabled,
1581
- paused: assoc.paused,
1582
- status,
1583
- stateThresholds: thresholds,
1584
- recentRuns: chronologicalRuns.map((r) => ({
1585
- id: r.id,
1586
- status: r.status,
1587
- timestamp: r.timestamp,
1588
- environmentId: r.environmentId,
1589
- })),
1590
- perEnvironment,
1591
- });
1592
- }
1813
+ return checks;
1814
+ });
1593
1815
 
1594
1816
  return { systemId, checks };
1595
1817
  }
@@ -1704,6 +1926,7 @@ export class HealthCheckService {
1704
1926
  sourceFilter?: string;
1705
1927
  statusFilter?: HealthCheckStatus[];
1706
1928
  environmentId?: string | null;
1929
+ environmentIds?: string[];
1707
1930
  maxBuckets?: number;
1708
1931
  }): Promise<RunStats> {
1709
1932
  const {
@@ -1714,6 +1937,7 @@ export class HealthCheckService {
1714
1937
  sourceFilter,
1715
1938
  statusFilter,
1716
1939
  environmentId,
1940
+ environmentIds,
1717
1941
  maxBuckets = 24,
1718
1942
  } = props;
1719
1943
 
@@ -1738,6 +1962,12 @@ export class HealthCheckService {
1738
1962
  } else if (environmentId !== undefined) {
1739
1963
  conditions.push(eq(healthCheckRuns.environmentId, environmentId));
1740
1964
  }
1965
+ // Set-of-environments filter (the status page's per-page env scope). Only
1966
+ // counts runs tagged with one of the selected envs; env-less runs are
1967
+ // excluded (they belong to no published environment).
1968
+ if (environmentIds && environmentIds.length > 0) {
1969
+ conditions.push(inArray(healthCheckRuns.environmentId, environmentIds));
1970
+ }
1741
1971
 
1742
1972
  const rows = await this.db
1743
1973
  .select({
@@ -1757,6 +1987,67 @@ export class HealthCheckService {
1757
1987
  return summarizeRuns({ runs, startDate, endDate, maxBuckets });
1758
1988
  }
1759
1989
 
1990
+ /**
1991
+ * Bulk variant of {@link getRunStats}: compute the compact stats summary for
1992
+ * MANY systems over one shared window in a SINGLE grouped read, instead of an
1993
+ * N+1 fan-out of per-system `getRunStats` calls. Fetches every matching run
1994
+ * for all `systemIds` with one `inArray` query, groups by systemId in JS, and
1995
+ * runs the same pure `summarizeRuns` per system so each entry is identical to
1996
+ * what `getRunStats({ systemId })` would return. Systems with no runs in the
1997
+ * window are OMITTED from the record (they simply have no rows to group),
1998
+ * matching the single endpoint's "no runs => nothing to report" semantics.
1999
+ */
2000
+ async getBulkRunStats(props: {
2001
+ systemIds: string[];
2002
+ startDate: Date;
2003
+ endDate: Date;
2004
+ environmentIds?: string[];
2005
+ maxBuckets?: number;
2006
+ }): Promise<Record<string, RunStats>> {
2007
+ const { systemIds, startDate, endDate, environmentIds, maxBuckets = 24 } =
2008
+ props;
2009
+ if (systemIds.length === 0) return {};
2010
+
2011
+ const conditions = [
2012
+ inArray(healthCheckRuns.systemId, systemIds),
2013
+ gte(healthCheckRuns.timestamp, startDate),
2014
+ lte(healthCheckRuns.timestamp, endDate),
2015
+ ];
2016
+ // Set-of-environments filter (the status page's per-page env scope): count
2017
+ // only runs tagged with one of the selected envs; env-less runs excluded.
2018
+ if (environmentIds && environmentIds.length > 0) {
2019
+ conditions.push(inArray(healthCheckRuns.environmentId, environmentIds));
2020
+ }
2021
+
2022
+ const rows = await this.db
2023
+ .select({
2024
+ systemId: healthCheckRuns.systemId,
2025
+ timestamp: healthCheckRuns.timestamp,
2026
+ status: healthCheckRuns.status,
2027
+ latencyMs: healthCheckRuns.latencyMs,
2028
+ })
2029
+ .from(healthCheckRuns)
2030
+ .where(and(...conditions));
2031
+
2032
+ const bySystem = new Map<string, StatRun[]>();
2033
+ for (const r of rows) {
2034
+ const list = bySystem.get(r.systemId);
2035
+ const run: StatRun = {
2036
+ timestamp: r.timestamp,
2037
+ status: r.status,
2038
+ latencyMs: r.latencyMs ?? undefined,
2039
+ };
2040
+ if (list) list.push(run);
2041
+ else bySystem.set(r.systemId, [run]);
2042
+ }
2043
+
2044
+ const out: Record<string, RunStats> = {};
2045
+ for (const [systemId, runs] of bySystem) {
2046
+ out[systemId] = summarizeRuns({ runs, startDate, endDate, maxBuckets });
2047
+ }
2048
+ return out;
2049
+ }
2050
+
1760
2051
  /**
1761
2052
  * Get detailed health check run history with full result data.
1762
2053
  * Restricted to users with manage access.