@checkstack/healthcheck-backend 1.21.3 → 1.23.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/src/service.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  UpdateHealthCheckConfiguration,
5
5
  StateThresholds,
6
6
  HealthCheckStatus,
7
+ SystemHealthStatus,
7
8
  RetentionConfig,
8
9
  type HealthCheckRunResult,
9
10
  type NotificationPolicy,
@@ -13,13 +14,16 @@ import {
13
14
  type HealthcheckSignalStatuses,
14
15
  type RunStats,
15
16
  stripEphemeralFields,
16
- selectEffectiveEnvKeys,
17
+ selectEffectiveRunSlices,
18
+ isSourceSliceEffective,
19
+ runSliceKeyOf,
20
+ type RunSliceKey,
17
21
  } from "@checkstack/healthcheck-common";
18
22
  import { evaluateCollectorAssertionOutcomes } from "./collector-assertions";
19
23
  import { summarizeRuns, type StatRun } from "./run-stats.logic";
20
24
  import type { ConfigService } from "@checkstack/backend-api";
21
25
  import type { InferClient } from "@checkstack/common";
22
- import type { CatalogApi } from "@checkstack/catalog-common";
26
+ import type { CatalogApi, Environment } from "@checkstack/catalog-common";
23
27
  import {
24
28
  notificationDefaultsConfigV1,
25
29
  NOTIFICATION_DEFAULTS_CONFIG_ID,
@@ -54,7 +58,10 @@ import { parseHealthEntityId } from "./health-entity-id";
54
58
  import { stateThresholds } from "./state-thresholds-migrations";
55
59
  import type { MaintenanceApi } from "@checkstack/maintenance-common";
56
60
  import type { Logger } from "@checkstack/backend-api";
57
- import { resolveEffectiveEnvironments } from "./effective-environments";
61
+ import {
62
+ resolveEffectiveEnvironments,
63
+ resolveSatelliteEnvironments,
64
+ } from "./effective-environments";
58
65
  import { incrementHourlyAggregate } from "./realtime-aggregation";
59
66
  import { withScopedTransaction } from "@checkstack/backend-api";
60
67
  import type {
@@ -106,20 +113,45 @@ type CatalogClient = InferClient<typeof CatalogApi>;
106
113
  // state into the health-state snapshot. Optional on the read path.
107
114
  type MaintenanceClient = InferClient<typeof MaintenanceApi>;
108
115
 
116
+ /**
117
+ * One independently-evaluated run stream of a check: an (environment, source)
118
+ * pair. Surfaced so the system page can show WHICH location is failing instead
119
+ * of one combined verdict - a check that is green locally and red from a
120
+ * satellite is a genuinely different situation from one that is red everywhere.
121
+ */
122
+ interface SystemCheckSliceStatus {
123
+ /** `null` for the env-less slice. */
124
+ environmentId: string | null;
125
+ /** `null` for the local core; a satellite id otherwise. */
126
+ sourceId: string | null;
127
+ /** The source's display name as recorded on its runs, when it had one. */
128
+ sourceLabel?: string;
129
+ status: SystemHealthStatus;
130
+ runsConsidered: number;
131
+ lastRunAt?: Date;
132
+ }
133
+
109
134
  interface SystemCheckStatus {
110
135
  configurationId: string;
111
136
  configurationName: string;
112
- status: HealthCheckStatus;
137
+ /** `unknown` when this check has produced no runs to evaluate. */
138
+ status: SystemHealthStatus;
113
139
  runsConsidered: number;
114
140
  lastRunAt?: Date;
115
- /** Environment slices this check currently fans out to (>= 1). */
141
+ /** (environment, source) slices this check currently fans out to (>= 1). */
116
142
  sliceCount: number;
117
143
  /** How many of {@link sliceCount} slices are currently non-healthy. */
118
144
  failingSliceCount: number;
145
+ /**
146
+ * The per-slice breakdown behind {@link status}. Empty when the check has
147
+ * produced no runs at all (there is nothing to break down).
148
+ */
149
+ slices: SystemCheckSliceStatus[];
119
150
  }
120
151
 
121
152
  interface SystemHealthStatusResponse {
122
- status: HealthCheckStatus;
153
+ /** `unknown` when no check contributed a signal. */
154
+ status: SystemHealthStatus;
123
155
  evaluatedAt: Date;
124
156
  checkStatuses: SystemCheckStatus[];
125
157
  }
@@ -640,6 +672,13 @@ export class HealthCheckService {
640
672
  enabled?: boolean;
641
673
  stateThresholds?: StateThresholds;
642
674
  satelliteIds?: string[];
675
+ /**
676
+ * Per-SATELLITE environment scoping. Absent key = that satellite runs every
677
+ * environment the assignment resolves to; `[]` = one env-less run on it;
678
+ * non-empty = those ids, intersected with `environmentIds` (a satellite can
679
+ * narrow the assignment's scope, never widen it).
680
+ */
681
+ satelliteEnvironmentIds?: Record<string, string[] | null>;
643
682
  /**
644
683
  * Per-assignment environment selector. `null` (or `undefined`) = all
645
684
  * current environments; `[]` = opt out (env-less); non-empty = those
@@ -656,6 +695,7 @@ export class HealthCheckService {
656
695
  enabled = true,
657
696
  stateThresholds: stateThresholds_,
658
697
  satelliteIds,
698
+ satelliteEnvironmentIds,
659
699
  environmentIds,
660
700
  includeLocal = true,
661
701
  notificationPolicy,
@@ -678,6 +718,7 @@ export class HealthCheckService {
678
718
  enabled,
679
719
  stateThresholds: versionedThresholds,
680
720
  satelliteIds: satelliteIds ?? undefined,
721
+ satelliteEnvironmentIds: satelliteEnvironmentIds ?? undefined,
681
722
  environmentIds: environmentIdsValue,
682
723
  includeLocal,
683
724
  notificationPolicy: notificationPolicy ?? undefined,
@@ -691,6 +732,7 @@ export class HealthCheckService {
691
732
  enabled,
692
733
  stateThresholds: versionedThresholds,
693
734
  satelliteIds: satelliteIds ?? undefined,
735
+ satelliteEnvironmentIds: satelliteEnvironmentIds ?? undefined,
694
736
  environmentIds: environmentIdsValue,
695
737
  includeLocal,
696
738
  notificationPolicy: notificationPolicy ?? undefined,
@@ -713,6 +755,13 @@ export class HealthCheckService {
713
755
  enabled?: boolean;
714
756
  stateThresholds?: StateThresholds;
715
757
  satelliteIds?: string[];
758
+ /**
759
+ * Per-SATELLITE environment scoping. Absent key = that satellite runs every
760
+ * environment the assignment resolves to; `[]` = one env-less run on it;
761
+ * non-empty = those ids, intersected with `environmentIds` (a satellite can
762
+ * narrow the assignment's scope, never widen it).
763
+ */
764
+ satelliteEnvironmentIds?: Record<string, string[] | null>;
716
765
  /**
717
766
  * Per-assignment environment selector. `null` (or `undefined`) = all
718
767
  * current environments; `[]` = opt out (env-less); non-empty = those ids.
@@ -727,6 +776,7 @@ export class HealthCheckService {
727
776
  enabled = true,
728
777
  stateThresholds: stateThresholds_,
729
778
  satelliteIds,
779
+ satelliteEnvironmentIds,
730
780
  environmentIds,
731
781
  includeLocal = true,
732
782
  notificationPolicy,
@@ -784,6 +834,7 @@ export class HealthCheckService {
784
834
  enabled,
785
835
  stateThresholds: versionedThresholds,
786
836
  satelliteIds: satelliteIds ?? undefined,
837
+ satelliteEnvironmentIds: satelliteEnvironmentIds ?? undefined,
787
838
  environmentIds: environmentIdsValue,
788
839
  includeLocal,
789
840
  notificationPolicy: notificationPolicy ?? undefined,
@@ -955,6 +1006,7 @@ export class HealthCheckService {
955
1006
  enabled: systemHealthChecks.enabled,
956
1007
  stateThresholds: systemHealthChecks.stateThresholds,
957
1008
  satelliteIds: systemHealthChecks.satelliteIds,
1009
+ satelliteEnvironmentIds: systemHealthChecks.satelliteEnvironmentIds,
958
1010
  environmentIds: systemHealthChecks.environmentIds,
959
1011
  includeLocal: systemHealthChecks.includeLocal,
960
1012
  notificationPolicy: systemHealthChecks.notificationPolicy,
@@ -1002,6 +1054,7 @@ export class HealthCheckService {
1002
1054
  enabled: systemHealthChecks.enabled,
1003
1055
  stateThresholds: systemHealthChecks.stateThresholds,
1004
1056
  satelliteIds: systemHealthChecks.satelliteIds,
1057
+ satelliteEnvironmentIds: systemHealthChecks.satelliteEnvironmentIds,
1005
1058
  environmentIds: systemHealthChecks.environmentIds,
1006
1059
  includeLocal: systemHealthChecks.includeLocal,
1007
1060
  notificationPolicy: systemHealthChecks.notificationPolicy,
@@ -1195,6 +1248,13 @@ export class HealthCheckService {
1195
1248
  // stops dragging the aggregate the instant it is disabled - instead
1196
1249
  // of lingering until its stale runs age out of the bounded window.
1197
1250
  environmentIds: systemHealthChecks.environmentIds,
1251
+ // The SOURCE half of the fan-out. Runs are sliced by (environment,
1252
+ // source), so these decide which satellites' (and the core's) run
1253
+ // streams still contribute - exactly as `environmentIds` does for
1254
+ // environments.
1255
+ satelliteIds: systemHealthChecks.satelliteIds,
1256
+ satelliteEnvironmentIds: systemHealthChecks.satelliteEnvironmentIds,
1257
+ includeLocal: systemHealthChecks.includeLocal,
1198
1258
  })
1199
1259
  .from(systemHealthChecks)
1200
1260
  .innerJoin(
@@ -1243,132 +1303,155 @@ export class HealthCheckService {
1243
1303
  thresholds = await stateThresholds.parse(assoc.stateThresholds);
1244
1304
  }
1245
1305
 
1246
- let status: HealthCheckStatus;
1306
+ // Can be `unknown`: a check whose slices produced no runs.
1307
+ let status: SystemHealthStatus;
1247
1308
  let runsConsidered: number;
1248
1309
  let lastRunAt: Date | undefined;
1249
1310
  // Fan-out accounting for the honest "X of Y checks failing" denominator:
1250
- // how many environment slices this check currently spans, and how many
1251
- // are non-healthy. A non-fanned (single-env / env-less) check is one
1252
- // slice. Populated in both branches so the DTO field is always present.
1311
+ // how many (environment, source) slices this check currently spans, and
1312
+ // how many are non-healthy. A check that neither fans out nor uses
1313
+ // satellites is one slice.
1253
1314
  let sliceCount = 1;
1254
1315
  let failingSliceCount = 0;
1255
-
1256
- if (environmentId === undefined) {
1257
- // System rollup: evaluate the threshold window PER ENVIRONMENT within
1258
- // the association, then take worst-wins ACROSS envs. Flattening every
1259
- // env's runs into one list feeds interleaved statuses to
1316
+ // The per-slice breakdown, so the system page can name the failing
1317
+ // location rather than showing one combined verdict.
1318
+ const slices: SystemCheckSliceStatus[] = [];
1319
+
1320
+ {
1321
+ // Evaluate the threshold window PER SLICE - one (environment, source)
1322
+ // pair - then take worst-wins ACROSS slices. Flattening a check's runs
1323
+ // into one list feeds interleaved statuses to
1260
1324
  // `evaluateConsecutive` (the default mode): the streak breaks on the
1261
- // first interleaving env, so the evaluator collapses to whatever
1262
- // single env's status the most recent run landed on. That masks any
1263
- // permanently-failing sibling env in the default mode ("the healthy
1264
- // env wins"), and flaps healthy↔degraded whenever env insertion
1265
- // order drifts across ticks (see the regression test
1266
- // `rollup — worst-wins across environments within an association`).
1267
- // Per-env evaluation makes the rollup worst-wins stable regardless of
1268
- // insertion order or multi-pod racing.
1325
+ // first interleaving slice, so the evaluator collapses to whatever
1326
+ // single slice's status the most recent run landed on - and when no
1327
+ // streak survives long enough to meet a threshold, evaluation falls
1328
+ // through to its healthy default. That masks any permanently-failing
1329
+ // sibling slice ("the healthy one wins"), and flaps
1330
+ // healthy<->degraded whenever insertion order drifts across ticks.
1331
+ //
1332
+ // BOTH dimensions must key the slice. Environments were handled here
1333
+ // first (see the regression test `rollup — worst-wins across
1334
+ // environments within an association`); the SOURCE dimension was not,
1335
+ // and produced the same masking: a system whose local check passed
1336
+ // while its satellite check failed every time read HEALTHY, because
1337
+ // both sources' runs interleaved inside one environment slice. The
1338
+ // rule is one stream per (environment, source).
1269
1339
  //
1270
- // Each env is windowed SEPARATELY (`maxWindowSize` runs PER env), not
1271
- // via one shared `LIMIT maxWindowSize` across the mixed pool. A shared
1272
- // window silently truncates a check that fans out to many envs: with
1273
- // E envs each env sees only ~maxWindowSize/E of its own runs, so a
1274
- // small consecutive threshold can miss a genuine per-env streak once
1275
- // E grows. Per-env windows give every environment its full evaluation
1276
- // depth regardless of how many siblings it has.
1277
- const distinctEnvRows = await tx
1278
- .selectDistinct({ environmentId: healthCheckRuns.environmentId })
1340
+ // Each slice is windowed SEPARATELY (`maxWindowSize` runs PER slice),
1341
+ // not via one shared `LIMIT maxWindowSize` across the mixed pool. A
1342
+ // shared window silently truncates a check that fans out widely: with
1343
+ // S slices each sees only ~maxWindowSize/S of its own runs, so a small
1344
+ // consecutive threshold can miss a genuine per-slice streak once S
1345
+ // grows. Per-slice windows give every stream its full evaluation depth
1346
+ // regardless of how many siblings it has.
1347
+ const distinctSliceRows = await tx
1348
+ .selectDistinct({
1349
+ environmentId: healthCheckRuns.environmentId,
1350
+ sourceId: healthCheckRuns.sourceId,
1351
+ })
1279
1352
  .from(healthCheckRuns)
1280
1353
  .where(
1281
1354
  and(
1282
1355
  eq(healthCheckRuns.systemId, systemId),
1283
1356
  eq(healthCheckRuns.configurationId, assoc.configurationId),
1357
+ ...(envFilter ? [envFilter] : []),
1284
1358
  ),
1285
1359
  );
1286
- const presentEnvKeys = distinctEnvRows.map(
1287
- (r) => r.environmentId ?? null,
1288
- );
1289
1360
 
1290
1361
  // Keep only slices that are still EFFECTIVE for this assignment: a
1291
1362
  // concrete environment removed from `environmentIds` (the reported
1292
- // bug - "disable env for assignment"), plus the stale env-less slice
1293
- // of a check that now fans out, are dropped. Without this a disabled
1294
- // env's last unhealthy runs keep dragging the rollup via worst-wins,
1295
- // because no health-change event fires for a slice that stopped
1296
- // producing runs, so the event-driven rollup consumer never recomputes
1297
- // it away. The selector is durable Postgres state (`environmentIds`),
1298
- // so this is catalog-free and returns the same answer on every pod.
1299
- const effectiveKeys = selectEffectiveEnvKeys({
1363
+ // "disable env for assignment" bug), the stale env-less slice of a
1364
+ // check that now fans out, and the runs of a DE-ASSIGNED satellite,
1365
+ // are all dropped. Without this their last unhealthy runs keep
1366
+ // dragging the rollup via worst-wins, because no health-change event
1367
+ // fires for a slice that stopped producing runs, so the event-driven
1368
+ // rollup consumer never recomputes it away. Every input is durable
1369
+ // Postgres state (the assignment's selectors), so this is catalog-free
1370
+ // and returns the same answer on every pod.
1371
+ const effectiveSlices = selectEffectiveRunSlices({
1300
1372
  environmentIds: assoc.environmentIds,
1301
- presentEnvKeys,
1373
+ includeLocal: assoc.includeLocal,
1374
+ satelliteIds: assoc.satelliteIds,
1375
+ satelliteEnvironmentIds: assoc.satelliteEnvironmentIds,
1376
+ presentSlices: distinctSliceRows.map((r) => ({
1377
+ environmentId: r.environmentId ?? null,
1378
+ sourceId: r.sourceId ?? null,
1379
+ })),
1380
+ // A pinned environment (the per-environment view) is the user's
1381
+ // explicit choice, so it is not re-derived - but its sources are
1382
+ // still split apart, or the masking bug simply reappears inside
1383
+ // that one environment's view.
1384
+ ...(environmentId === undefined ? {} : { environmentScope: environmentId }),
1302
1385
  });
1303
1386
 
1304
- status = "healthy";
1387
+ // Start from "no signal", NOT from healthy: a check whose slices
1388
+ // have produced no runs has measured nothing, and inventing health
1389
+ // for it is what made a misconfigured check read green everywhere.
1390
+ status = "unknown";
1305
1391
  runsConsidered = 0;
1306
1392
  lastRunAt = undefined;
1307
- // Each EFFECTIVE env group is a slice. A check that has runs against N
1308
- // effective envs currently fans out to N; before it has ever run (no
1309
- // effective group) it is still one logical slice.
1310
- sliceCount = Math.max(effectiveKeys.size, 1);
1393
+ // A check with runs across N effective slices fans out to N; before it
1394
+ // has ever run (no slice at all) it is still one logical slice.
1395
+ sliceCount = Math.max(effectiveSlices.length, 1);
1311
1396
  failingSliceCount = 0;
1312
- for (const key of effectiveKeys) {
1313
- const envRuns = await tx
1397
+ for (const slice of effectiveSlices) {
1398
+ const sliceRuns = await tx
1314
1399
  .select({
1315
1400
  status: healthCheckRuns.status,
1316
1401
  timestamp: healthCheckRuns.timestamp,
1402
+ sourceLabel: healthCheckRuns.sourceLabel,
1317
1403
  })
1318
1404
  .from(healthCheckRuns)
1319
1405
  .where(
1320
1406
  and(
1321
1407
  eq(healthCheckRuns.systemId, systemId),
1322
1408
  eq(healthCheckRuns.configurationId, assoc.configurationId),
1323
- key === null
1409
+ slice.environmentId === null
1324
1410
  ? isNull(healthCheckRuns.environmentId)
1325
- : eq(healthCheckRuns.environmentId, key),
1411
+ : eq(healthCheckRuns.environmentId, slice.environmentId),
1412
+ slice.sourceId === null
1413
+ ? isNull(healthCheckRuns.sourceId)
1414
+ : eq(healthCheckRuns.sourceId, slice.sourceId),
1326
1415
  ),
1327
1416
  )
1328
1417
  .orderBy(desc(healthCheckRuns.timestamp))
1329
1418
  .limit(maxWindowSize);
1330
1419
 
1331
- runsConsidered += envRuns.length;
1332
- const newest = envRuns[0]?.timestamp;
1420
+ runsConsidered += sliceRuns.length;
1421
+ const newest = sliceRuns[0]?.timestamp;
1333
1422
  if (newest && (!lastRunAt || newest > lastRunAt)) lastRunAt = newest;
1334
- const envStatus = evaluateHealthStatus({ runs: envRuns, thresholds });
1423
+ // A slice always has runs (it was derived from them), but an empty
1424
+ // one must contribute nothing rather than invent health.
1425
+ const sliceStatus: SystemHealthStatus =
1426
+ sliceRuns.length === 0
1427
+ ? "unknown"
1428
+ : evaluateHealthStatus({ runs: sliceRuns, thresholds });
1429
+
1430
+ const sourceLabel = sliceRuns[0]?.sourceLabel;
1431
+ slices.push({
1432
+ environmentId: slice.environmentId,
1433
+ sourceId: slice.sourceId,
1434
+ ...(sourceLabel ? { sourceLabel } : {}),
1435
+ status: sliceStatus,
1436
+ runsConsidered: sliceRuns.length,
1437
+ ...(newest ? { lastRunAt: newest } : {}),
1438
+ });
1439
+
1335
1440
  // Count EVERY failing slice (don't break early): the failing count
1336
- // feeds the dashboard numerator, so all non-healthy envs must tally.
1337
- if (envStatus !== "healthy") {
1441
+ // feeds the dashboard numerator, so all of them must tally.
1442
+ // `unknown` is not a failure - it is the absence of a measurement.
1443
+ if (sliceStatus !== "healthy" && sliceStatus !== "unknown") {
1338
1444
  failingSliceCount++;
1339
1445
  }
1340
- if (envStatus === "unhealthy") {
1446
+ if (sliceRuns.length === 0) continue;
1447
+ if (sliceStatus === "unhealthy") {
1341
1448
  status = "unhealthy";
1342
- } else if (envStatus === "degraded" && status === "healthy") {
1449
+ } else if (sliceStatus === "degraded" && status !== "unhealthy") {
1343
1450
  status = "degraded";
1451
+ } else if (status === "unknown") {
1452
+ status = "healthy";
1344
1453
  }
1345
1454
  }
1346
- } else {
1347
- // Per-env (string) or env-less (null) slice: that slice's flat run
1348
- // window is monotonic per-env, so the threshold evaluator sees no
1349
- // interleaving — the consecutive streak is well-defined.
1350
- const runs = await tx
1351
- .select({
1352
- status: healthCheckRuns.status,
1353
- timestamp: healthCheckRuns.timestamp,
1354
- })
1355
- .from(healthCheckRuns)
1356
- .where(
1357
- and(
1358
- eq(healthCheckRuns.systemId, systemId),
1359
- eq(healthCheckRuns.configurationId, assoc.configurationId),
1360
- ...(envFilter ? [envFilter] : []),
1361
- ),
1362
- )
1363
- .orderBy(desc(healthCheckRuns.timestamp))
1364
- .limit(maxWindowSize);
1365
-
1366
- status = evaluateHealthStatus({ runs, thresholds });
1367
- runsConsidered = runs.length;
1368
- lastRunAt = runs[0]?.timestamp;
1369
- // Single-slice evaluation: this env either counts as failing or not.
1370
- sliceCount = 1;
1371
- failingSliceCount = status === "healthy" ? 0 : 1;
1372
1455
  }
1373
1456
 
1374
1457
  out.push({
@@ -1379,13 +1462,20 @@ export class HealthCheckService {
1379
1462
  lastRunAt,
1380
1463
  sliceCount,
1381
1464
  failingSliceCount,
1465
+ slices,
1382
1466
  });
1383
1467
  }
1384
1468
  return out;
1385
1469
  });
1386
1470
 
1387
- // Aggregate status: worst status wins (unhealthy > degraded > healthy)
1388
- let aggregateStatus: HealthCheckStatus = "healthy";
1471
+ // Aggregate status: worst status wins (unhealthy > degraded > healthy),
1472
+ // starting from `unknown` so a system with NO checks - or whose checks have
1473
+ // never run - reports "not measured" instead of claiming health it has no
1474
+ // evidence for. A check that DID report keeps deciding the outcome, so a
1475
+ // system with one healthy check and one never-run check still reads healthy:
1476
+ // it has positive evidence, and the unmeasured check is visible on the
1477
+ // system's own page rather than dragging the whole system to unknown.
1478
+ let aggregateStatus: SystemHealthStatus = "unknown";
1389
1479
  for (const cs of checkStatuses) {
1390
1480
  if (cs.status === "unhealthy") {
1391
1481
  aggregateStatus = "unhealthy";
@@ -1394,6 +1484,8 @@ export class HealthCheckService {
1394
1484
  if (cs.status === "degraded") {
1395
1485
  aggregateStatus = "degraded";
1396
1486
  // Don't break - keep looking for unhealthy
1487
+ } else if (cs.status === "healthy" && aggregateStatus === "unknown") {
1488
+ aggregateStatus = "healthy";
1397
1489
  }
1398
1490
  }
1399
1491
 
@@ -1606,6 +1698,12 @@ export class HealthCheckService {
1606
1698
  // detection (a disabled-for-assignment env is tucked under "Old checks"
1607
1699
  // even though it is still part of the system's membership).
1608
1700
  environmentIds: systemHealthChecks.environmentIds,
1701
+ // The SOURCE half of the fan-out: which satellites (and whether the
1702
+ // core) still run this check, so a de-assigned satellite's slice can
1703
+ // be marked orphaned rather than left dragging the rollup.
1704
+ satelliteIds: systemHealthChecks.satelliteIds,
1705
+ satelliteEnvironmentIds: systemHealthChecks.satelliteEnvironmentIds,
1706
+ includeLocal: systemHealthChecks.includeLocal,
1609
1707
  })
1610
1708
  .from(systemHealthChecks)
1611
1709
  .innerJoin(
@@ -1625,6 +1723,7 @@ export class HealthCheckService {
1625
1723
  status: healthCheckRuns.status,
1626
1724
  timestamp: healthCheckRuns.timestamp,
1627
1725
  environmentId: healthCheckRuns.environmentId,
1726
+ sourceId: healthCheckRuns.sourceId,
1628
1727
  })
1629
1728
  .from(healthCheckRuns)
1630
1729
  .where(
@@ -1678,14 +1777,19 @@ export class HealthCheckService {
1678
1777
  }
1679
1778
  }
1680
1779
 
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
1780
+ // Group the fetched runs into SLICES - one (environment, source) pair.
1781
+ // Each slice is queried and evaluated separately below on its own
1782
+ // monotonic run window, then worst-wins across slices. This is the same
1684
1783
  // 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).
1784
+ // that method for the rationale (flattening slices feeds interleaved
1785
+ // statuses to the consecutive evaluator, which masks a sibling outage -
1786
+ // originally observed across environments, then reported again across
1787
+ // sources when a satellite failed behind a passing local check).
1687
1788
  const perEnvironment: {
1688
1789
  environmentId: string | null;
1790
+ sourceId: string | null;
1791
+ sourceLabel?: string;
1792
+ sourceOrphaned?: boolean;
1689
1793
  status: HealthCheckStatus;
1690
1794
  lastSuccessfulRunAt?: Date;
1691
1795
  recentRuns: {
@@ -1695,50 +1799,65 @@ export class HealthCheckService {
1695
1799
  }[];
1696
1800
  }[] = [];
1697
1801
 
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>();
1802
+ // Stable ordering of slice keys: in the order they were first
1803
+ // encountered in the mixed pool (membership order is otherwise
1804
+ // unobservable here without a catalog read; recent runs surface stable,
1805
+ // recent order).
1806
+ const sliceKeys: RunSliceKey[] = [];
1807
+ const seenSlice = new Set<string>();
1704
1808
  for (const r of runs) {
1705
- const key = r.environmentId ?? null;
1706
- if (!seenEnv.has(key)) {
1707
- seenEnv.add(key);
1708
- envKeys.push(key);
1809
+ const key: RunSliceKey = {
1810
+ environmentId: r.environmentId ?? null,
1811
+ sourceId: r.sourceId ?? null,
1812
+ };
1813
+ const id = runSliceKeyOf(key);
1814
+ if (!seenSlice.has(id)) {
1815
+ seenSlice.add(id);
1816
+ sliceKeys.push(key);
1709
1817
  }
1710
1818
  }
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);
1819
+ // If no runs at all, surface a single env-less local entry so UI can
1820
+ // render an empty row rather than nothing.
1821
+ if (sliceKeys.length === 0)
1822
+ sliceKeys.push({ environmentId: null, sourceId: null });
1714
1823
 
1715
1824
  // 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
1825
+ // concrete env removed from `environmentIds` (disabled for the
1826
+ // assignment), the stale env-less slice of a check that now fans out,
1827
+ // and a de-assigned satellite's slice are all excluded, mirroring
1828
+ // `getSystemHealthStatus`. The orphaned slices are still emitted in
1829
+ // `perEnvironment` (the frontend tucks them under "Old checks"); they
1720
1830
  // just no longer drag the check-level worst-wins `status`.
1721
- const effectiveKeys = selectEffectiveEnvKeys({
1722
- environmentIds: assoc.environmentIds,
1723
- presentEnvKeys: envKeys,
1724
- });
1831
+ const effectiveKeys = new Set(
1832
+ selectEffectiveRunSlices({
1833
+ environmentIds: assoc.environmentIds,
1834
+ includeLocal: assoc.includeLocal,
1835
+ satelliteIds: assoc.satelliteIds,
1836
+ satelliteEnvironmentIds: assoc.satelliteEnvironmentIds,
1837
+ presentSlices: sliceKeys,
1838
+ }).map((slice) => runSliceKeyOf(slice)),
1839
+ );
1725
1840
 
1726
1841
  let aggregateStatus: HealthCheckStatus = "healthy";
1727
- for (const envId of envKeys) {
1842
+ for (const slice of sliceKeys) {
1728
1843
  const envRuns = await tx
1729
1844
  .select({
1730
1845
  id: healthCheckRuns.id,
1731
1846
  status: healthCheckRuns.status,
1732
1847
  timestamp: healthCheckRuns.timestamp,
1848
+ sourceLabel: healthCheckRuns.sourceLabel,
1733
1849
  })
1734
1850
  .from(healthCheckRuns)
1735
1851
  .where(
1736
1852
  and(
1737
1853
  eq(healthCheckRuns.systemId, systemId),
1738
1854
  eq(healthCheckRuns.configurationId, assoc.configurationId),
1739
- envId === null
1855
+ slice.environmentId === null
1740
1856
  ? isNull(healthCheckRuns.environmentId)
1741
- : eq(healthCheckRuns.environmentId, envId),
1857
+ : eq(healthCheckRuns.environmentId, slice.environmentId),
1858
+ slice.sourceId === null
1859
+ ? isNull(healthCheckRuns.sourceId)
1860
+ : eq(healthCheckRuns.sourceId, slice.sourceId),
1742
1861
  ),
1743
1862
  )
1744
1863
  .orderBy(desc(healthCheckRuns.timestamp))
@@ -1748,10 +1867,10 @@ export class HealthCheckService {
1748
1867
  runs: envRuns,
1749
1868
  thresholds,
1750
1869
  });
1751
- // Worst-wins across EFFECTIVE envs (unhealthy > degraded > healthy).
1752
- // An orphaned slice's status is still reported per-env but must not
1870
+ // Worst-wins across EFFECTIVE slices (unhealthy > degraded > healthy).
1871
+ // An orphaned slice's status is still reported per-slice but must not
1753
1872
  // move the aggregate.
1754
- if (effectiveKeys.has(envId)) {
1873
+ if (effectiveKeys.has(runSliceKeyOf(slice))) {
1755
1874
  if (envStatus === "unhealthy") {
1756
1875
  aggregateStatus = "unhealthy";
1757
1876
  } else if (
@@ -1762,10 +1881,23 @@ export class HealthCheckService {
1762
1881
  }
1763
1882
  }
1764
1883
 
1884
+ const sourceLabel = envRuns[0]?.sourceLabel;
1765
1885
  perEnvironment.push({
1766
- environmentId: envId,
1886
+ environmentId: slice.environmentId,
1887
+ sourceId: slice.sourceId,
1888
+ ...(sourceLabel ? { sourceLabel } : {}),
1889
+ // The frontend classifies orphaned ENVIRONMENTS itself (it knows
1890
+ // system membership and the env selector) but has no view of the
1891
+ // satellite selectors, so the source verdict is resolved here.
1892
+ ...(isSourceSliceEffective({
1893
+ sourceId: slice.sourceId,
1894
+ includeLocal: assoc.includeLocal,
1895
+ satelliteIds: assoc.satelliteIds,
1896
+ })
1897
+ ? {}
1898
+ : { sourceOrphaned: true }),
1767
1899
  status: envStatus,
1768
- lastSuccessfulRunAt: lastHealthyByEnv.get(envId),
1900
+ lastSuccessfulRunAt: lastHealthyByEnv.get(slice.environmentId),
1769
1901
  recentRuns: envRuns.toReversed().map((r) => ({
1770
1902
  id: r.id,
1771
1903
  status: r.status,
@@ -2671,6 +2803,7 @@ export class HealthCheckService {
2671
2803
  systemId: systemHealthChecks.systemId,
2672
2804
  configurationId: systemHealthChecks.configurationId,
2673
2805
  satelliteIds: systemHealthChecks.satelliteIds,
2806
+ satelliteEnvironmentIds: systemHealthChecks.satelliteEnvironmentIds,
2674
2807
  })
2675
2808
  .from(systemHealthChecks);
2676
2809
 
@@ -2705,7 +2838,9 @@ export class HealthCheckService {
2705
2838
  systemId: systemHealthChecks.systemId,
2706
2839
  configurationId: systemHealthChecks.configurationId,
2707
2840
  satelliteIds: systemHealthChecks.satelliteIds,
2841
+ satelliteEnvironmentIds: systemHealthChecks.satelliteEnvironmentIds,
2708
2842
  enabled: systemHealthChecks.enabled,
2843
+ environmentIds: systemHealthChecks.environmentIds,
2709
2844
  })
2710
2845
  .from(systemHealthChecks);
2711
2846
 
@@ -2749,6 +2884,31 @@ export class HealthCheckService {
2749
2884
  return resolved;
2750
2885
  };
2751
2886
 
2887
+ // The system's current environment membership, resolved once per distinct
2888
+ // system. A satellite must fan out exactly as the local executor does, or
2889
+ // its results land env-less and every per-environment surface loses them.
2890
+ // A catalog failure degrades to "no environments" - a single env-less run,
2891
+ // which is the pre-fan-out behaviour - rather than dropping the check.
2892
+ const membershipCache = new Map<string, Environment[]>();
2893
+ const resolveMembership = async (
2894
+ systemId: string,
2895
+ ): Promise<Environment[]> => {
2896
+ const cached = membershipCache.get(systemId);
2897
+ if (cached !== undefined) return cached;
2898
+ let membership: Environment[] = [];
2899
+ if (this.catalogClient) {
2900
+ try {
2901
+ membership = await this.catalogClient.resolveSystemEnvironments({
2902
+ systemId,
2903
+ });
2904
+ } catch {
2905
+ // Degrade to env-less rather than withholding the assignment.
2906
+ }
2907
+ }
2908
+ membershipCache.set(systemId, membership);
2909
+ return membership;
2910
+ };
2911
+
2752
2912
  // Get configurations for each matching association
2753
2913
  const assignments = [];
2754
2914
  for (const assoc of matchingAssociations) {
@@ -2760,6 +2920,17 @@ export class HealthCheckService {
2760
2920
  if (!config || config.paused) continue;
2761
2921
 
2762
2922
  const system = await resolveSystem(assoc.systemId);
2923
+ // The assignment decides which environments exist for this check; the
2924
+ // satellite decides which of those IT is responsible for. Narrowing in
2925
+ // that order is what lets a prod satellite run only prod, and stops any
2926
+ // satellite widening its own scope.
2927
+ const environments = resolveSatelliteEnvironments({
2928
+ effective: resolveEffectiveEnvironments({
2929
+ environmentIds: assoc.environmentIds,
2930
+ membership: await resolveMembership(assoc.systemId),
2931
+ }),
2932
+ satelliteEnvironmentIds: assoc.satelliteEnvironmentIds?.[satelliteId],
2933
+ });
2763
2934
  assignments.push({
2764
2935
  configId: config.id,
2765
2936
  systemId: assoc.systemId,
@@ -2771,6 +2942,10 @@ export class HealthCheckService {
2771
2942
  configName: config.name,
2772
2943
  systemName: system.name,
2773
2944
  systemMetadata: system.metadata,
2945
+ // One run per environment, mirroring the local fan-out. Empty means a
2946
+ // single env-less run (the system has no environments, or this
2947
+ // assignment opted out with `[]`).
2948
+ environments,
2774
2949
  });
2775
2950
  }
2776
2951
 
@@ -2840,24 +3015,35 @@ export class HealthCheckService {
2840
3015
  * HTTP bodies) are needed for JSONPath assertions and are stripped right
2841
3016
  * after evaluation, matching what the local executor stores.
2842
3017
  */
2843
- async ingestSatelliteResult(props: {
3018
+ async processSatelliteResult(props: {
2844
3019
  configId: string;
2845
- systemId: string;
2846
3020
  status: HealthCheckStatus;
2847
- latencyMs?: number;
2848
3021
  result?: HealthCheckRunResult;
2849
- executedAt: string;
2850
- sourceId: string;
2851
- sourceLabel: string;
2852
- }) {
2853
- const { configId, systemId, latencyMs, result, sourceId, sourceLabel } =
2854
- props;
3022
+ }): Promise<{
3023
+ status: HealthCheckStatus;
3024
+ resultRecord: Record<string, unknown>;
3025
+ /** The check's display name, resolved for the subscriber notification. */
3026
+ configName?: string;
3027
+ }> {
3028
+ const { configId, result } = props;
2855
3029
 
2856
3030
  const resultRecord = result
2857
3031
  ? ({ ...result } as Record<string, unknown>)
2858
3032
  : {};
2859
3033
 
2860
3034
  let status = props.status;
3035
+
3036
+ // Resolve the config once: its NAME (for the notification the shared
3037
+ // post-run path sends) and its collector entries (for assertion eval).
3038
+ const [configRow] = await this.db
3039
+ .select({
3040
+ name: healthCheckConfigurations.name,
3041
+ collectors: healthCheckConfigurations.collectors,
3042
+ })
3043
+ .from(healthCheckConfigurations)
3044
+ .where(eq(healthCheckConfigurations.id, configId));
3045
+ const configName = configRow?.name;
3046
+
2861
3047
  const metadata = resultRecord.metadata as
2862
3048
  | Record<string, unknown>
2863
3049
  | undefined;
@@ -2865,10 +3051,6 @@ export class HealthCheckService {
2865
3051
  | Record<string, Record<string, unknown>>
2866
3052
  | undefined;
2867
3053
  if (collectorsMeta && Object.keys(collectorsMeta).length > 0) {
2868
- const [configRow] = await this.db
2869
- .select({ collectors: healthCheckConfigurations.collectors })
2870
- .from(healthCheckConfigurations)
2871
- .where(eq(healthCheckConfigurations.id, configId));
2872
3054
  const entries: CollectorConfigEntry[] = configRow?.collectors ?? [];
2873
3055
 
2874
3056
  let firstFailure: string | undefined;
@@ -2921,36 +3103,61 @@ export class HealthCheckService {
2921
3103
  }
2922
3104
  }
2923
3105
 
2924
- // Atomic: the run row and the hourly-aggregate increment it feeds must
2925
- // commit together. Without the transaction a failure on the (non-idempotent
2926
- // `runCount + 1`) aggregate left a committed run that the aggregate never
2927
- // counted - or, on the reverse ordering, an aggregate with no backing run.
2928
- // NOTE: this guarantees run/aggregate consistency, but does NOT make a
2929
- // *duplicate satellite delivery* (a re-POST after a committed write)
2930
- // idempotent - that requires a dedupe key on the high-volume runs table and
2931
- // is tracked as a separate follow-up.
3106
+ return { status, resultRecord, configName };
3107
+ }
3108
+
3109
+ /**
3110
+ * Insert a processed satellite run + its hourly aggregate WITHOUT the
3111
+ * reactive/notify path. This is the fallback ONLY for a host that did not
3112
+ * wire the shared post-run reactor (e.g. a test harness); the real host
3113
+ * routes satellite results through `persistRunAndReact` so they react exactly
3114
+ * like a local run. Kept insert-only (a strict subset of the shared path) so
3115
+ * it cannot drift into a second, divergent notify path.
3116
+ */
3117
+ async insertSatelliteRun(props: {
3118
+ configId: string;
3119
+ systemId: string;
3120
+ environmentId: string | null;
3121
+ status: HealthCheckStatus;
3122
+ latencyMs?: number;
3123
+ result: Record<string, unknown>;
3124
+ sourceId: string;
3125
+ sourceLabel: string;
3126
+ executedAt: string;
3127
+ }): Promise<void> {
3128
+ const {
3129
+ configId,
3130
+ systemId,
3131
+ environmentId,
3132
+ status,
3133
+ latencyMs,
3134
+ result,
3135
+ sourceId,
3136
+ sourceLabel,
3137
+ executedAt,
3138
+ } = props;
2932
3139
  await this.db.transaction(async (tx) => {
2933
3140
  await tx.insert(healthCheckRuns).values({
2934
3141
  configurationId: configId,
2935
3142
  systemId,
2936
3143
  status,
2937
3144
  latencyMs,
2938
- result: resultRecord,
3145
+ result,
2939
3146
  sourceId,
2940
3147
  sourceLabel,
3148
+ environmentId,
2941
3149
  });
2942
-
2943
- // Trigger incremental hourly aggregation — same as local executor
2944
3150
  await incrementHourlyAggregate({
2945
3151
  db: tx,
2946
3152
  systemId,
2947
3153
  configurationId: configId,
2948
3154
  status,
2949
3155
  latencyMs,
2950
- runTimestamp: new Date(props.executedAt),
2951
- result: resultRecord,
3156
+ runTimestamp: new Date(executedAt),
3157
+ result,
2952
3158
  collectorRegistry: this.collectorRegistry,
2953
3159
  sourceLabel,
3160
+ environmentId,
2954
3161
  });
2955
3162
  });
2956
3163
  }