@checkstack/healthcheck-backend 1.16.0 → 1.18.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 (39) hide show
  1. package/CHANGELOG.md +399 -0
  2. package/package.json +30 -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/healthcheck-gitops-kinds.test.ts +34 -2
  9. package/src/healthcheck-gitops-kinds.ts +17 -13
  10. package/src/index.ts +87 -6
  11. package/src/migration-chain-contract.test.ts +7 -1
  12. package/src/notification-policy.test.ts +19 -0
  13. package/src/notification-policy.ts +26 -0
  14. package/src/queue-executor.test.ts +391 -338
  15. package/src/queue-executor.ts +395 -294
  16. package/src/realtime-aggregation.ts +9 -2
  17. package/src/rollup-consumer.test.ts +191 -0
  18. package/src/rollup-consumer.ts +160 -0
  19. package/src/router.ts +103 -19
  20. package/src/schedule-jitter.test.ts +69 -0
  21. package/src/schedule-jitter.ts +50 -0
  22. package/src/schedule-reconciler.it.test.ts +453 -0
  23. package/src/schedule-reconciler.test.ts +418 -0
  24. package/src/schedule-reconciler.ts +304 -0
  25. package/src/service-batching.test.ts +98 -0
  26. package/src/service-ordering.test.ts +4 -0
  27. package/src/service-paused-filter.test.ts +14 -7
  28. package/src/service-rollup-worst-wins.test.ts +37 -4
  29. package/src/service.ts +348 -145
  30. package/src/slow-check-admission.test.ts +184 -0
  31. package/src/slow-check-admission.ts +101 -0
  32. package/src/slow-check-classifier.test.ts +155 -0
  33. package/src/slow-check-classifier.ts +137 -0
  34. package/src/slow-check-config.ts +102 -0
  35. package/src/status-page/widgets.ts +11 -1
  36. package/src/suspect-lane.test.ts +50 -0
  37. package/src/suspect-lane.ts +61 -0
  38. package/src/system-health-override.test.ts +94 -0
  39. package/src/system-health-override.ts +93 -0
package/src/service.ts CHANGED
@@ -41,7 +41,9 @@ import {
41
41
  gte,
42
42
  lte,
43
43
  isNull,
44
+ isNotNull,
44
45
  inArray,
46
+ max,
45
47
  } from "drizzle-orm";
46
48
  import { ORPCError } from "@orpc/server";
47
49
  import { evaluateHealthStatus } from "./state-evaluator";
@@ -50,7 +52,9 @@ import { parseHealthEntityId } from "./health-entity-id";
50
52
  import { stateThresholds } from "./state-thresholds-migrations";
51
53
  import type { MaintenanceApi } from "@checkstack/maintenance-common";
52
54
  import type { Logger } from "@checkstack/backend-api";
55
+ import { resolveEffectiveEnvironments } from "./effective-environments";
53
56
  import { incrementHourlyAggregate } from "./realtime-aggregation";
57
+ import { withScopedTransaction } from "@checkstack/backend-api";
54
58
  import type {
55
59
  HealthCheckRegistry,
56
60
  SafeDatabase,
@@ -106,6 +110,10 @@ interface SystemCheckStatus {
106
110
  status: HealthCheckStatus;
107
111
  runsConsidered: number;
108
112
  lastRunAt?: Date;
113
+ /** Environment slices this check currently fans out to (>= 1). */
114
+ sliceCount: number;
115
+ /** How many of {@link sliceCount} slices are currently non-healthy. */
116
+ failingSliceCount: number;
109
117
  }
110
118
 
111
119
  interface SystemHealthStatusResponse {
@@ -259,6 +267,53 @@ export class HealthCheckService {
259
267
  return config ? this.mapConfig(config) : undefined;
260
268
  }
261
269
 
270
+ /**
271
+ * Resolve the per-environment slices a (system, config) assignment should
272
+ * enqueue for a ONE-OFF run (the `run_now` automation). Returns the list of
273
+ * environment ids to run, or `[null]` (a single env-less run) when the
274
+ * assignment has no effective environments. Mirrors the executor's fan-out
275
+ * resolution so a manual run covers exactly the same slices the recurring
276
+ * schedule does. Fail-open: a catalog resolution failure collapses to a
277
+ * single env-less run rather than enqueuing nothing.
278
+ */
279
+ async resolveEnqueueEnvironmentIds(props: {
280
+ systemId: string;
281
+ configurationId: string;
282
+ catalogClient: CatalogClient;
283
+ logger: Logger;
284
+ }): Promise<(string | null)[]> {
285
+ const { systemId, configurationId, catalogClient, logger } = props;
286
+ const [assignment] = await this.db
287
+ .select({ environmentIds: systemHealthChecks.environmentIds })
288
+ .from(systemHealthChecks)
289
+ .where(
290
+ and(
291
+ eq(systemHealthChecks.systemId, systemId),
292
+ eq(systemHealthChecks.configurationId, configurationId),
293
+ ),
294
+ );
295
+
296
+ let membership: Awaited<
297
+ ReturnType<CatalogClient["resolveSystemEnvironments"]>
298
+ > = [];
299
+ try {
300
+ membership = await catalogClient.resolveSystemEnvironments({ systemId });
301
+ } catch (error) {
302
+ logger.warn(
303
+ `run_now: could not resolve environments for system ${systemId}`,
304
+ error,
305
+ );
306
+ }
307
+
308
+ const effectiveEnvs = resolveEffectiveEnvironments({
309
+ environmentIds: assignment?.environmentIds,
310
+ membership,
311
+ });
312
+ return effectiveEnvs.length > 0
313
+ ? effectiveEnvs.map((env) => env.id)
314
+ : [null];
315
+ }
316
+
262
317
  /**
263
318
  * Redact a configuration for a UI/AI read: every `x-secret` field is
264
319
  * removed from the strategy config and each collector config. Stored
@@ -1029,159 +1084,179 @@ export class HealthCheckService {
1029
1084
  systemId: string,
1030
1085
  environmentId?: string | null,
1031
1086
  ): Promise<SystemHealthStatusResponse> {
1032
- // Get all associations for this system with their thresholds and config names
1033
- const associations = await this.db
1034
- .select({
1035
- configurationId: systemHealthChecks.configurationId,
1036
- stateThresholds: systemHealthChecks.stateThresholds,
1037
- configName: healthCheckConfigurations.name,
1038
- enabled: systemHealthChecks.enabled,
1039
- })
1040
- .from(systemHealthChecks)
1041
- .innerJoin(
1042
- healthCheckConfigurations,
1043
- eq(systemHealthChecks.configurationId, healthCheckConfigurations.id),
1044
- )
1045
- .where(
1046
- and(
1047
- eq(systemHealthChecks.systemId, systemId),
1048
- eq(systemHealthChecks.enabled, true),
1049
- // A paused configuration contributes no signal to the system's
1050
- // health: its execution is skipped (see queue-executor pause gate)
1051
- // and its historical runs MUST NOT keep the aggregate degraded
1052
- // while it is paused. Excluding it here makes the rollup reflect
1053
- // only the actively-running checks, so pausing the sole failing
1054
- // check clears the system's status and downstream SLO downtime.
1055
- eq(healthCheckConfigurations.paused, false),
1056
- ),
1057
- );
1058
-
1059
- if (associations.length === 0) {
1060
- // No health checks configured - default healthy
1061
- return {
1062
- status: "healthy",
1063
- evaluatedAt: new Date(),
1064
- checkStatuses: [],
1065
- };
1066
- }
1067
-
1068
- // For each association, get recent runs and evaluate status
1069
- const checkStatuses: SystemCheckStatus[] = [];
1070
- const maxWindowSize = 100; // Max configurable window size
1071
-
1072
- // Environment filter for the per-check run window. `undefined` (rollup)
1073
- // adds no predicate; `null` filters to the env-less slice; a string
1074
- // filters to that environment. The lookup index leads with
1075
- // (system_id, environment_id, …) so the env-scoped query is index-efficient.
1076
- //
1077
- // For the rollup, we deliberately do NOT apply a single envFilter to one
1078
- // flat run list — see the per-association branch below for why.
1079
- const envFilter =
1080
- environmentId === undefined
1081
- ? undefined
1082
- : environmentId === null
1083
- ? isNull(healthCheckRuns.environmentId)
1084
- : eq(healthCheckRuns.environmentId, environmentId);
1085
-
1086
- for (const assoc of associations) {
1087
- // Extract and migrate thresholds from versioned config
1088
- let thresholds: StateThresholds | undefined;
1089
- if (assoc.stateThresholds) {
1090
- thresholds = await stateThresholds.parse(assoc.stateThresholds);
1091
- }
1087
+ // §perf: batch the 1 (associations) + N (per-check run window) reads
1088
+ // into ONE scoped transaction so the whole read fan-out pays a single
1089
+ // BEGIN/SET LOCAL/COMMIT and holds one connection, instead of 1+N
1090
+ // standalone scoped queries each checking a connection out. Pure
1091
+ // evaluation runs inside too (it issues no DB). See withScopedTransaction.
1092
+ const checkStatuses = await withScopedTransaction(this.db, async (tx) => {
1093
+ // Get all associations for this system with their thresholds and config names
1094
+ const associations = await tx
1095
+ .select({
1096
+ configurationId: systemHealthChecks.configurationId,
1097
+ stateThresholds: systemHealthChecks.stateThresholds,
1098
+ configName: healthCheckConfigurations.name,
1099
+ enabled: systemHealthChecks.enabled,
1100
+ })
1101
+ .from(systemHealthChecks)
1102
+ .innerJoin(
1103
+ healthCheckConfigurations,
1104
+ eq(systemHealthChecks.configurationId, healthCheckConfigurations.id),
1105
+ )
1106
+ .where(
1107
+ and(
1108
+ eq(systemHealthChecks.systemId, systemId),
1109
+ eq(systemHealthChecks.enabled, true),
1110
+ // A paused configuration contributes no signal to the system's
1111
+ // health: its execution is skipped (see queue-executor pause gate)
1112
+ // and its historical runs MUST NOT keep the aggregate degraded
1113
+ // while it is paused. Excluding it here makes the rollup reflect
1114
+ // only the actively-running checks, so pausing the sole failing
1115
+ // check clears the system's status and downstream SLO downtime.
1116
+ eq(healthCheckConfigurations.paused, false),
1117
+ ),
1118
+ );
1092
1119
 
1093
- let status: HealthCheckStatus;
1094
- let runsConsidered: number;
1095
- let lastRunAt: Date | undefined;
1096
-
1097
- if (environmentId === undefined) {
1098
- // System rollup: evaluate the threshold window PER ENVIRONMENT within
1099
- // the association, then take worst-wins ACROSS envs. Flattening every
1100
- // env's runs into one list feeds interleaved statuses to
1101
- // `evaluateConsecutive` (the default mode): the streak breaks on the
1102
- // first interleaving env, so the evaluator collapses to whatever
1103
- // single env's status the most recent run landed on. That masks any
1104
- // permanently-failing sibling env in the default mode ("the healthy
1105
- // env wins"), and flaps healthy↔degraded whenever env insertion
1106
- // order drifts across ticks (see the regression test
1107
- // `rollup — worst-wins across environments within an association`).
1108
- // Per-env evaluation makes the rollup worst-wins stable regardless of
1109
- // insertion order or multi-pod racing.
1110
- const runs = await this.db
1111
- .select({
1112
- status: healthCheckRuns.status,
1113
- timestamp: healthCheckRuns.timestamp,
1114
- environmentId: healthCheckRuns.environmentId,
1115
- })
1116
- .from(healthCheckRuns)
1117
- .where(
1118
- and(
1119
- eq(healthCheckRuns.systemId, systemId),
1120
- eq(healthCheckRuns.configurationId, assoc.configurationId),
1121
- ),
1122
- )
1123
- .orderBy(desc(healthCheckRuns.timestamp))
1124
- .limit(maxWindowSize);
1125
-
1126
- // Group by environmentId. `null` is its own group (the env-less slice
1127
- // of an assignment that has opted out, plus any pre-3b env-less runs).
1128
- const byEnv = new Map<string | null, { status: HealthCheckStatus; timestamp: Date }[]>();
1129
- for (const r of runs) {
1130
- const key = r.environmentId ?? null;
1131
- const bucket = byEnv.get(key);
1132
- if (bucket) {
1133
- bucket.push(r);
1134
- } else {
1135
- byEnv.set(key, [r]);
1136
- }
1120
+ if (associations.length === 0) return [];
1121
+
1122
+ // For each association, get recent runs and evaluate status
1123
+ const out: SystemCheckStatus[] = [];
1124
+ const maxWindowSize = 100; // Max configurable window size
1125
+
1126
+ // Environment filter for the per-check run window. `undefined` (rollup)
1127
+ // adds no predicate; `null` filters to the env-less slice; a string
1128
+ // filters to that environment. The lookup index leads with
1129
+ // (system_id, environment_id, …) so the env-scoped query is index-efficient.
1130
+ //
1131
+ // For the rollup, we deliberately do NOT apply a single envFilter to one
1132
+ // flat run list — see the per-association branch below for why.
1133
+ const envFilter =
1134
+ environmentId === undefined
1135
+ ? undefined
1136
+ : environmentId === null
1137
+ ? isNull(healthCheckRuns.environmentId)
1138
+ : eq(healthCheckRuns.environmentId, environmentId);
1139
+
1140
+ for (const assoc of associations) {
1141
+ // Extract and migrate thresholds from versioned config
1142
+ let thresholds: StateThresholds | undefined;
1143
+ if (assoc.stateThresholds) {
1144
+ thresholds = await stateThresholds.parse(assoc.stateThresholds);
1137
1145
  }
1138
1146
 
1139
- status = "healthy";
1140
- runsConsidered = runs.length;
1141
- lastRunAt = runs[0]?.timestamp;
1142
- for (const envRuns of byEnv.values()) {
1143
- const envStatus = evaluateHealthStatus({ runs: envRuns, thresholds });
1144
- if (envStatus === "unhealthy") {
1145
- status = "unhealthy";
1146
- break; // worst: stop
1147
+ let status: HealthCheckStatus;
1148
+ let runsConsidered: number;
1149
+ let lastRunAt: Date | undefined;
1150
+ // Fan-out accounting for the honest "X of Y checks failing" denominator:
1151
+ // how many environment slices this check currently spans, and how many
1152
+ // are non-healthy. A non-fanned (single-env / env-less) check is one
1153
+ // slice. Populated in both branches so the DTO field is always present.
1154
+ let sliceCount = 1;
1155
+ let failingSliceCount = 0;
1156
+
1157
+ if (environmentId === undefined) {
1158
+ // System rollup: evaluate the threshold window PER ENVIRONMENT within
1159
+ // the association, then take worst-wins ACROSS envs. Flattening every
1160
+ // env's runs into one list feeds interleaved statuses to
1161
+ // `evaluateConsecutive` (the default mode): the streak breaks on the
1162
+ // first interleaving env, so the evaluator collapses to whatever
1163
+ // single env's status the most recent run landed on. That masks any
1164
+ // permanently-failing sibling env in the default mode ("the healthy
1165
+ // env wins"), and flaps healthy↔degraded whenever env insertion
1166
+ // order drifts across ticks (see the regression test
1167
+ // `rollup — worst-wins across environments within an association`).
1168
+ // Per-env evaluation makes the rollup worst-wins stable regardless of
1169
+ // insertion order or multi-pod racing.
1170
+ const runs = await tx
1171
+ .select({
1172
+ status: healthCheckRuns.status,
1173
+ timestamp: healthCheckRuns.timestamp,
1174
+ environmentId: healthCheckRuns.environmentId,
1175
+ })
1176
+ .from(healthCheckRuns)
1177
+ .where(
1178
+ and(
1179
+ eq(healthCheckRuns.systemId, systemId),
1180
+ eq(healthCheckRuns.configurationId, assoc.configurationId),
1181
+ ),
1182
+ )
1183
+ .orderBy(desc(healthCheckRuns.timestamp))
1184
+ .limit(maxWindowSize);
1185
+
1186
+ // Group by environmentId. `null` is its own group (the env-less slice
1187
+ // of an assignment that has opted out, plus any pre-3b env-less runs).
1188
+ const byEnv = new Map<string | null, { status: HealthCheckStatus; timestamp: Date }[]>();
1189
+ for (const r of runs) {
1190
+ const key = r.environmentId ?? null;
1191
+ const bucket = byEnv.get(key);
1192
+ if (bucket) {
1193
+ bucket.push(r);
1194
+ } else {
1195
+ byEnv.set(key, [r]);
1196
+ }
1147
1197
  }
1148
- if (envStatus === "degraded" && status === "healthy") {
1149
- status = "degraded";
1198
+
1199
+ status = "healthy";
1200
+ runsConsidered = runs.length;
1201
+ lastRunAt = runs[0]?.timestamp;
1202
+ // Each env group is a slice. A check that has runs against N envs
1203
+ // currently fans out to N; before it has ever run it is still one
1204
+ // logical slice (byEnv empty => keep the default 1).
1205
+ sliceCount = Math.max(byEnv.size, 1);
1206
+ failingSliceCount = 0;
1207
+ for (const envRuns of byEnv.values()) {
1208
+ const envStatus = evaluateHealthStatus({ runs: envRuns, thresholds });
1209
+ // Count EVERY failing slice (don't break early): the failing count
1210
+ // feeds the dashboard numerator, so all non-healthy envs must tally.
1211
+ if (envStatus !== "healthy") {
1212
+ failingSliceCount++;
1213
+ }
1214
+ if (envStatus === "unhealthy") {
1215
+ status = "unhealthy";
1216
+ } else if (envStatus === "degraded" && status === "healthy") {
1217
+ status = "degraded";
1218
+ }
1150
1219
  }
1220
+ } else {
1221
+ // Per-env (string) or env-less (null) slice: that slice's flat run
1222
+ // window is monotonic per-env, so the threshold evaluator sees no
1223
+ // interleaving — the consecutive streak is well-defined.
1224
+ const runs = await tx
1225
+ .select({
1226
+ status: healthCheckRuns.status,
1227
+ timestamp: healthCheckRuns.timestamp,
1228
+ })
1229
+ .from(healthCheckRuns)
1230
+ .where(
1231
+ and(
1232
+ eq(healthCheckRuns.systemId, systemId),
1233
+ eq(healthCheckRuns.configurationId, assoc.configurationId),
1234
+ ...(envFilter ? [envFilter] : []),
1235
+ ),
1236
+ )
1237
+ .orderBy(desc(healthCheckRuns.timestamp))
1238
+ .limit(maxWindowSize);
1239
+
1240
+ status = evaluateHealthStatus({ runs, thresholds });
1241
+ runsConsidered = runs.length;
1242
+ lastRunAt = runs[0]?.timestamp;
1243
+ // Single-slice evaluation: this env either counts as failing or not.
1244
+ sliceCount = 1;
1245
+ failingSliceCount = status === "healthy" ? 0 : 1;
1151
1246
  }
1152
- } else {
1153
- // Per-env (string) or env-less (null) slice: that slice's flat run
1154
- // window is monotonic per-env, so the threshold evaluator sees no
1155
- // interleaving — the consecutive streak is well-defined.
1156
- const runs = await this.db
1157
- .select({
1158
- status: healthCheckRuns.status,
1159
- timestamp: healthCheckRuns.timestamp,
1160
- })
1161
- .from(healthCheckRuns)
1162
- .where(
1163
- and(
1164
- eq(healthCheckRuns.systemId, systemId),
1165
- eq(healthCheckRuns.configurationId, assoc.configurationId),
1166
- ...(envFilter ? [envFilter] : []),
1167
- ),
1168
- )
1169
- .orderBy(desc(healthCheckRuns.timestamp))
1170
- .limit(maxWindowSize);
1171
1247
 
1172
- status = evaluateHealthStatus({ runs, thresholds });
1173
- runsConsidered = runs.length;
1174
- lastRunAt = runs[0]?.timestamp;
1248
+ out.push({
1249
+ configurationId: assoc.configurationId,
1250
+ configurationName: assoc.configName,
1251
+ status,
1252
+ runsConsidered,
1253
+ lastRunAt,
1254
+ sliceCount,
1255
+ failingSliceCount,
1256
+ });
1175
1257
  }
1176
-
1177
- checkStatuses.push({
1178
- configurationId: assoc.configurationId,
1179
- configurationName: assoc.configName,
1180
- status,
1181
- runsConsidered,
1182
- lastRunAt,
1183
- });
1184
- }
1258
+ return out;
1259
+ });
1185
1260
 
1186
1261
  // Aggregate status: worst status wins (unhealthy > degraded > healthy)
1187
1262
  let aggregateStatus: HealthCheckStatus = "healthy";
@@ -1341,6 +1416,98 @@ export class HealthCheckService {
1341
1416
  return Object.fromEntries(entries);
1342
1417
  }
1343
1418
 
1419
+ /**
1420
+ * Bulk per-(system, check, environment) health for the given systems.
1421
+ *
1422
+ * For each system returns the cross-environment rollup (status +
1423
+ * checkStatuses, same as {@link getSystemHealthStatus}) PLUS a slice per
1424
+ * environment the system has runs for. Consumers that scope by environment
1425
+ * (the dependency map) must read the per-environment slice, because the
1426
+ * rollup deliberately hides a single failing environment.
1427
+ *
1428
+ * Cost scales with the number of environments each system actually fans out
1429
+ * to (`1 + #envs` status evaluations per system); systems with only env-less
1430
+ * runs cost the same as a plain rollup read. Not on any per-run hot path.
1431
+ */
1432
+ async getBulkSystemHealthMatrix(systemIds: string[]): Promise<
1433
+ Record<
1434
+ string,
1435
+ {
1436
+ status: HealthCheckStatus;
1437
+ checkStatuses: SystemHealthStatusResponse["checkStatuses"];
1438
+ environments: Record<
1439
+ string,
1440
+ {
1441
+ status: HealthCheckStatus;
1442
+ checkStatuses: SystemHealthStatusResponse["checkStatuses"];
1443
+ }
1444
+ >;
1445
+ }
1446
+ >
1447
+ > {
1448
+ const result: Record<
1449
+ string,
1450
+ {
1451
+ status: HealthCheckStatus;
1452
+ checkStatuses: SystemHealthStatusResponse["checkStatuses"];
1453
+ environments: Record<
1454
+ string,
1455
+ {
1456
+ status: HealthCheckStatus;
1457
+ checkStatuses: SystemHealthStatusResponse["checkStatuses"];
1458
+ }
1459
+ >;
1460
+ }
1461
+ > = {};
1462
+
1463
+ await Promise.all(
1464
+ systemIds.map(async (systemId) => {
1465
+ const overall = await this.getSystemHealthStatus(systemId);
1466
+
1467
+ // Environments this system actually has runs for (env-less excluded -
1468
+ // it is folded into the rollup and never a real environment id).
1469
+ const envRows = await this.db
1470
+ .selectDistinct({ environmentId: healthCheckRuns.environmentId })
1471
+ .from(healthCheckRuns)
1472
+ .where(
1473
+ and(
1474
+ eq(healthCheckRuns.systemId, systemId),
1475
+ isNotNull(healthCheckRuns.environmentId),
1476
+ ),
1477
+ );
1478
+
1479
+ const environments: Record<
1480
+ string,
1481
+ {
1482
+ status: HealthCheckStatus;
1483
+ checkStatuses: SystemHealthStatusResponse["checkStatuses"];
1484
+ }
1485
+ > = {};
1486
+ await Promise.all(
1487
+ envRows.map(async ({ environmentId }) => {
1488
+ if (!environmentId) return;
1489
+ const envStatus = await this.getSystemHealthStatus(
1490
+ systemId,
1491
+ environmentId,
1492
+ );
1493
+ environments[environmentId] = {
1494
+ status: envStatus.status,
1495
+ checkStatuses: envStatus.checkStatuses,
1496
+ };
1497
+ }),
1498
+ );
1499
+
1500
+ result[systemId] = {
1501
+ status: overall.status,
1502
+ checkStatuses: overall.checkStatuses,
1503
+ environments,
1504
+ };
1505
+ }),
1506
+ );
1507
+
1508
+ return result;
1509
+ }
1510
+
1344
1511
  /**
1345
1512
  * Get comprehensive health overview for a system.
1346
1513
  * Returns all health checks with their last 25 runs for sparkline visualization.
@@ -1395,6 +1562,39 @@ export class HealthCheckService {
1395
1562
  thresholds = await stateThresholds.parse(assoc.stateThresholds);
1396
1563
  }
1397
1564
 
1565
+ // Most recent HEALTHY run per environment, computed OUTSIDE the bounded
1566
+ // sparkline window so "last successful run" stays correct even when a
1567
+ // check has been failing for far longer than the last 25 runs. One
1568
+ // grouped aggregate query per check (env-less = the `null` group). The
1569
+ // (system_id, configuration_id, environment_id, timestamp) index makes
1570
+ // this a cheap max-per-group scan.
1571
+ const lastHealthyRows = await this.db
1572
+ .select({
1573
+ environmentId: healthCheckRuns.environmentId,
1574
+ lastSuccessAt: max(healthCheckRuns.timestamp),
1575
+ })
1576
+ .from(healthCheckRuns)
1577
+ .where(
1578
+ and(
1579
+ eq(healthCheckRuns.systemId, systemId),
1580
+ eq(healthCheckRuns.configurationId, assoc.configurationId),
1581
+ eq(healthCheckRuns.status, "healthy"),
1582
+ ),
1583
+ )
1584
+ .groupBy(healthCheckRuns.environmentId);
1585
+ const lastHealthyByEnv = new Map<string | null, Date>();
1586
+ let checkLastSuccessfulRunAt: Date | undefined;
1587
+ for (const row of lastHealthyRows) {
1588
+ if (!row.lastSuccessAt) continue;
1589
+ lastHealthyByEnv.set(row.environmentId ?? null, row.lastSuccessAt);
1590
+ if (
1591
+ !checkLastSuccessfulRunAt ||
1592
+ row.lastSuccessAt > checkLastSuccessfulRunAt
1593
+ ) {
1594
+ checkLastSuccessfulRunAt = row.lastSuccessAt;
1595
+ }
1596
+ }
1597
+
1398
1598
  // Group the fetched runs by environmentId (null = env-less slice). We
1399
1599
  // query each env's slice separately below to evaluate it on its own
1400
1600
  // monotonic run window and worst-wins across envs — this is the same
@@ -1404,6 +1604,7 @@ export class HealthCheckService {
1404
1604
  const perEnvironment: {
1405
1605
  environmentId: string | null;
1406
1606
  status: HealthCheckStatus;
1607
+ lastSuccessfulRunAt?: Date;
1407
1608
  recentRuns: { id: string; status: HealthCheckStatus; timestamp: Date }[];
1408
1609
  }[] = [];
1409
1610
 
@@ -1459,6 +1660,7 @@ export class HealthCheckService {
1459
1660
  perEnvironment.push({
1460
1661
  environmentId: envId,
1461
1662
  status: envStatus,
1663
+ lastSuccessfulRunAt: lastHealthyByEnv.get(envId),
1462
1664
  recentRuns: envRuns.toReversed().map((r) => ({
1463
1665
  id: r.id,
1464
1666
  status: r.status,
@@ -1488,6 +1690,7 @@ export class HealthCheckService {
1488
1690
  paused: assoc.paused,
1489
1691
  status,
1490
1692
  stateThresholds: thresholds,
1693
+ lastSuccessfulRunAt: checkLastSuccessfulRunAt,
1491
1694
  recentRuns: chronologicalRuns.map((r) => ({
1492
1695
  id: r.id,
1493
1696
  status: r.status,