@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.
- package/CHANGELOG.md +399 -0
- package/package.json +30 -29
- package/src/adaptive-timeout.test.ts +91 -0
- package/src/adaptive-timeout.ts +75 -0
- package/src/ai/system-signals-contributor.test.ts +2 -0
- package/src/automations.test.ts +47 -0
- package/src/automations.ts +19 -3
- package/src/healthcheck-gitops-kinds.test.ts +34 -2
- package/src/healthcheck-gitops-kinds.ts +17 -13
- package/src/index.ts +87 -6
- package/src/migration-chain-contract.test.ts +7 -1
- package/src/notification-policy.test.ts +19 -0
- package/src/notification-policy.ts +26 -0
- package/src/queue-executor.test.ts +391 -338
- package/src/queue-executor.ts +395 -294
- package/src/realtime-aggregation.ts +9 -2
- package/src/rollup-consumer.test.ts +191 -0
- package/src/rollup-consumer.ts +160 -0
- package/src/router.ts +103 -19
- package/src/schedule-jitter.test.ts +69 -0
- package/src/schedule-jitter.ts +50 -0
- package/src/schedule-reconciler.it.test.ts +453 -0
- package/src/schedule-reconciler.test.ts +418 -0
- package/src/schedule-reconciler.ts +304 -0
- package/src/service-batching.test.ts +98 -0
- package/src/service-ordering.test.ts +4 -0
- package/src/service-paused-filter.test.ts +14 -7
- package/src/service-rollup-worst-wins.test.ts +37 -4
- package/src/service.ts +348 -145
- package/src/slow-check-admission.test.ts +184 -0
- package/src/slow-check-admission.ts +101 -0
- package/src/slow-check-classifier.test.ts +155 -0
- package/src/slow-check-classifier.ts +137 -0
- package/src/slow-check-config.ts +102 -0
- package/src/status-page/widgets.ts +11 -1
- package/src/suspect-lane.test.ts +50 -0
- package/src/suspect-lane.ts +61 -0
- package/src/system-health-override.test.ts +94 -0
- 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
|
-
//
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
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
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
.
|
|
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
|
|
1140
|
-
runsConsidered
|
|
1141
|
-
lastRunAt
|
|
1142
|
-
for
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
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
|
-
|
|
1149
|
-
|
|
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
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
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
|
-
|
|
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,
|