@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.
@@ -5,18 +5,14 @@ import {
5
5
  type CollectorRegistry,
6
6
  type SafeDatabase,
7
7
  type BaseStrategyConfig,
8
- type ConnectedClient,
9
- type TransportClient,
10
- type TransportTimings,
11
8
  type CollectorRunContext,
12
9
  type AdvisoryLockService,
13
- renderTemplatableConfig,
14
10
  withScopedTransaction,
15
11
  healthcheckExecutionHistogram,
16
12
  healthcheckPhaseHistogram,
17
13
  healthcheckDeferredCounter,
18
14
  } from "@checkstack/backend-api";
19
- import type { RunTimings } from "@checkstack/healthcheck-common";
15
+ import { runHealthCheckCollection } from "@checkstack/healthcheck-execution";
20
16
  import { QueueManager } from "@checkstack/queue-api";
21
17
  import {
22
18
  healthCheckConfigurations,
@@ -31,6 +27,7 @@ import {
31
27
  SYSTEM_STATUS_CHANGED,
32
28
  ENVIRONMENT_RESOLUTION_FAILED,
33
29
  type HealthCheckStatus,
30
+ type SystemHealthStatus,
34
31
  stripEphemeralFields,
35
32
  HEALTH_CHECK_QUEUE,
36
33
  type HealthCheckJobPayload,
@@ -42,10 +39,7 @@ export {
42
39
  HEALTH_CHECK_QUEUE,
43
40
  type HealthCheckJobPayload,
44
41
  } from "@checkstack/healthcheck-common";
45
- import {
46
- CatalogApi,
47
- type Environment,
48
- } from "@checkstack/catalog-common";
42
+ import { CatalogApi, type Environment } from "@checkstack/catalog-common";
49
43
  import {
50
44
  resolveEffectiveEnvironments,
51
45
  type EffectiveEnvironment,
@@ -54,7 +48,7 @@ import { buildHealthTransitionNotification } from "./health-notification-content
54
48
  import { MaintenanceApi } from "@checkstack/maintenance-common";
55
49
  import { IncidentApi } from "@checkstack/incident-common";
56
50
  import { NotificationApi } from "@checkstack/notification-common";
57
- import { type InferClient, extractErrorMessage} from "@checkstack/common";
51
+ import { type InferClient, extractErrorMessage } from "@checkstack/common";
58
52
  import { secretEnvMappingSchema } from "@checkstack/secrets-common";
59
53
  import type {
60
54
  SecretResolverService,
@@ -84,6 +78,10 @@ import {
84
78
  type HealthEntityState,
85
79
  } from "./health-entity";
86
80
  import { encodeHealthEntityId } from "./health-entity-id";
81
+ import {
82
+ buildUnobservableRun,
83
+ resolveSatelliteOnlyOutcome,
84
+ } from "./satellite-liveness";
87
85
  import type { EntityHandle } from "@checkstack/automation-backend";
88
86
 
89
87
  type Db = SafeDatabase<typeof schema>;
@@ -156,39 +154,6 @@ async function fetchRecentRunsForSlice(props: {
156
154
  }));
157
155
  }
158
156
 
159
- /** The known transport timing phase keys, in transport order. */
160
- const RUN_TIMING_KEYS = [
161
- "dnsMs",
162
- "connectMs",
163
- "tlsMs",
164
- "waitMs",
165
- "transferMs",
166
- "processingMs",
167
- ] as const;
168
-
169
- /**
170
- * Build the run's `metadata.timings` from a connected client's surfaced
171
- * transport timings, keeping only present, finite, non-negative phases. Returns
172
- * `undefined` when no usable phase was measured so the field is omitted (old
173
- * runs and single-phase strategies stay on the coarse fallback in the UI).
174
- */
175
- function extractRunTimings(
176
- connectedClient: ConnectedClient<TransportClient<never, unknown>> | undefined,
177
- ): RunTimings | undefined {
178
- const raw: TransportTimings | undefined = connectedClient?.timings;
179
- if (!raw) return undefined;
180
- const result: RunTimings = {};
181
- let any = false;
182
- for (const key of RUN_TIMING_KEYS) {
183
- const value = raw[key];
184
- if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
185
- result[key] = value;
186
- any = true;
187
- }
188
- }
189
- return any ? result : undefined;
190
- }
191
-
192
157
  /**
193
158
  * Emit the checkCompleted hook if available, plus the narrower
194
159
  * `checkFailed` hook when the result wasn't `healthy` (so operators
@@ -357,7 +322,10 @@ export async function recomputeSystemRollupHealth(args: {
357
322
  */
358
323
  signalService?: SignalService;
359
324
  cache?: HealthCheckCache;
360
- }): Promise<{ previousStatus: HealthCheckStatus; newStatus: HealthCheckStatus } | undefined> {
325
+ }): Promise<
326
+ | { previousStatus: SystemHealthStatus; newStatus: SystemHealthStatus }
327
+ | undefined
328
+ > {
361
329
  const {
362
330
  systemId,
363
331
  service,
@@ -403,7 +371,11 @@ export async function recomputeSystemRollupHealth(args: {
403
371
  // Cache: evict the rollup key + broadcast to the cluster on ANY per-check
404
372
  // vector change — a check that flips while the rollup enum stays put still
405
373
  // changes the rollup's `checkStatuses`, and a reader gets that vector.
406
- await cache?.reconcile({ systemId, previous: previousState, next: newState });
374
+ await cache?.reconcile({
375
+ systemId,
376
+ previous: previousState,
377
+ next: newState,
378
+ });
407
379
  // Frontend signal: only a rollup-enum transition moves the badge, so a
408
380
  // per-check-only change needs no SYSTEM_STATUS_CHANGED refetch signal.
409
381
  if (newState.status !== previousState.status) {
@@ -612,6 +584,262 @@ async function notifyStateChange(props: {
612
584
  }
613
585
  }
614
586
 
587
+ /**
588
+ * Persist ONE completed health-check run (for one system + environment +
589
+ * source) and drive EVERYTHING that must react to it, in the correct order:
590
+ * the reactive `health` entity write (which does the durable run insert +
591
+ * hourly-aggregate increment and fires the authoritative `ENTITY_CHANGED`),
592
+ * the cache reconcile, the realtime run signal, the checkCompleted/checkFailed
593
+ * automation hooks, and - on a real status transition - the transition record,
594
+ * the subscriber notification, and (for an env-less run) the system-status
595
+ * signal.
596
+ *
597
+ * This is the SINGLE post-run path. A local run (the queue executor) and a
598
+ * SATELLITE run (ingested over RPC) both call it, so a satellite-detected
599
+ * outage fires the same notifications, automations, transitions, and signals a
600
+ * local one does - previously ingest only inserted the row, so satellite runs
601
+ * were silent. Keeping it in one function is what stops that from drifting
602
+ * again; the only difference between the two callers is the `sourceId` /
603
+ * `sourceLabel` / `runTimestamp` of the run, passed in.
604
+ */
605
+ export async function persistRunAndReact(params: {
606
+ db: Db;
607
+ service: HealthCheckService;
608
+ cache: HealthCheckCache;
609
+ signalService: SignalService;
610
+ notificationClient: NotificationClient;
611
+ catalogClient: CatalogClient;
612
+ maintenanceClient: MaintenanceClient;
613
+ incidentClient: IncidentClient;
614
+ getHealthEntity?: () => EntityHandle<HealthEntityState> | undefined;
615
+ getEmitHook: () => EmitHookFn | undefined;
616
+ collectorRegistry: CollectorRegistry;
617
+ advisoryLock: AdvisoryLockService;
618
+ logger: Logger;
619
+ systemId: string;
620
+ systemName: string;
621
+ configId: string;
622
+ configName?: string;
623
+ /** `null` is the env-less slice, which IS the system rollup. */
624
+ environmentId: string | null;
625
+ environmentName?: string;
626
+ status: HealthCheckStatus;
627
+ latencyMs?: number;
628
+ /** The full run result record persisted to `health_check_runs.result`. */
629
+ result: Record<string, unknown>;
630
+ /** `undefined` = local core; a satellite id otherwise. */
631
+ sourceId?: string;
632
+ sourceLabel: string;
633
+ /** Timestamp used for the hourly aggregate bucket (the run's execution time). */
634
+ runTimestamp: Date;
635
+ /**
636
+ * Record the run and its transition, but do NOT notify subscribers.
637
+ *
638
+ * For a run whose cause is a single shared failure that is ALREADY notified
639
+ * elsewhere. The unobservable-run path is the case: one satellite going
640
+ * offline makes every check assigned to it degrade at once, and
641
+ * `healthy -> degraded` is an escalation, so without this a single satellite
642
+ * outage fans out into one notification per check. The satellite's own
643
+ * connectivity subscription names the actual root cause once.
644
+ *
645
+ * The run, the transition and the health state are still written, so the UI
646
+ * stays honest - only the per-check alert is withheld.
647
+ */
648
+ suppressSubscriberNotification?: boolean;
649
+ }): Promise<void> {
650
+ const {
651
+ db,
652
+ service,
653
+ cache,
654
+ signalService,
655
+ notificationClient,
656
+ catalogClient,
657
+ maintenanceClient,
658
+ incidentClient,
659
+ getHealthEntity,
660
+ getEmitHook,
661
+ collectorRegistry,
662
+ advisoryLock,
663
+ logger,
664
+ systemId,
665
+ systemName,
666
+ configId,
667
+ configName,
668
+ environmentId,
669
+ environmentName,
670
+ status,
671
+ latencyMs,
672
+ result,
673
+ sourceId,
674
+ sourceLabel,
675
+ runTimestamp,
676
+ suppressSubscriberNotification = false,
677
+ } = params;
678
+
679
+ const envEntityId = encodeHealthEntityId({ systemId, environmentId });
680
+ const serializeEnvWrite = createHealthEntitySerializer({ advisoryLock })(
681
+ envEntityId,
682
+ );
683
+ // An env-less run IS the system rollup, so it broadcasts the system-level
684
+ // signal directly; a fanned-out env run leaves the rollup to the debounced
685
+ // rollup consumer (driven by this write's ENTITY_CHANGED).
686
+ const isFannedOut = environmentId !== null;
687
+
688
+ let previousState!: AggregatedHealth;
689
+ let previousStatus!: SystemHealthStatus;
690
+ let newState!: AggregatedHealth;
691
+ await writeHealthEntity({
692
+ handle: getHealthEntity?.(),
693
+ entityId: envEntityId,
694
+ apply: async () => {
695
+ // In-lock pre-run baseline: read inside the serialized critical section,
696
+ // before the insert, so a concurrent same-slice run cannot commit between
697
+ // the baseline read and this insert and make the cache gate miss a change.
698
+ previousState = await service.getSystemHealthStatus(
699
+ systemId,
700
+ environmentId,
701
+ );
702
+ previousStatus = previousState.status;
703
+ // Batch the run INSERT + aggregate SELECT/UPSERT under ONE scoped
704
+ // transaction so they commit atomically.
705
+ await withScopedTransaction(db, async (tx) => {
706
+ await tx.insert(healthCheckRuns).values({
707
+ configurationId: configId,
708
+ systemId,
709
+ environmentId,
710
+ status,
711
+ latencyMs,
712
+ result,
713
+ sourceId,
714
+ sourceLabel,
715
+ });
716
+ await incrementHourlyAggregate({
717
+ db: tx,
718
+ systemId,
719
+ configurationId: configId,
720
+ environmentId,
721
+ status,
722
+ latencyMs,
723
+ runTimestamp,
724
+ result,
725
+ collectorRegistry,
726
+ sourceLabel,
727
+ });
728
+ });
729
+ newState = await service.getSystemHealthStatus(systemId, environmentId);
730
+ return toHealthEntityView(newState);
731
+ },
732
+ serialize: serializeEnvWrite,
733
+ onError: (error) =>
734
+ logger.warn(`Failed to mirror health entity for ${envEntityId}`, error),
735
+ });
736
+
737
+ logger.debug(
738
+ `Ran health check ${configId} for system ${systemId}: ${status}`,
739
+ );
740
+
741
+ await cache.reconcile({
742
+ systemId,
743
+ environmentId,
744
+ previous: previousState,
745
+ next: newState,
746
+ });
747
+
748
+ await signalService.broadcast(HEALTH_CHECK_RUN_COMPLETED, {
749
+ systemId,
750
+ systemName,
751
+ configurationId: configId,
752
+ // The realtime signal names the check; fall back to its id when the name
753
+ // could not be resolved (best-effort, as elsewhere).
754
+ configurationName: configName ?? configId,
755
+ status,
756
+ latencyMs,
757
+ environmentId: environmentId ?? undefined,
758
+ environmentName,
759
+ });
760
+
761
+ await emitCheckCompletedHook({
762
+ getEmitHook,
763
+ systemId,
764
+ configurationId: configId,
765
+ status,
766
+ latencyMs,
767
+ result:
768
+ (result.metadata as { collectors?: Record<string, unknown> } | undefined)
769
+ ?.collectors ?? undefined,
770
+ environmentId,
771
+ });
772
+
773
+ // `newState.status` cannot be `unknown` here (a run just completed).
774
+ if (newState.status !== previousStatus && newState.status !== "unknown") {
775
+ await recordStateTransition({
776
+ db,
777
+ systemId,
778
+ configurationId: configId,
779
+ environmentId,
780
+ fromStatus: previousStatus === "unknown" ? undefined : previousStatus,
781
+ toStatus: newState.status,
782
+ });
783
+
784
+ if (suppressSubscriberNotification) {
785
+ logger.debug(
786
+ `Recorded ${newState.status} for ${configId}/${systemId} without notifying: ` +
787
+ "the underlying cause is notified once at its source",
788
+ );
789
+ } else {
790
+ await notifyStateChange({
791
+ notificationClient,
792
+ systemId,
793
+ systemName,
794
+ configurationId: configId,
795
+ configurationName: configName,
796
+ previousStatus:
797
+ previousStatus === "unknown" ? "healthy" : previousStatus,
798
+ newStatus: newState.status,
799
+ environmentId,
800
+ environmentName,
801
+ service,
802
+ catalogClient,
803
+ maintenanceClient,
804
+ incidentClient,
805
+ logger,
806
+ });
807
+ }
808
+
809
+ if (!isFannedOut) {
810
+ await signalService.broadcast(SYSTEM_STATUS_CHANGED, {
811
+ systemId,
812
+ previousStatus,
813
+ newStatus: newState.status,
814
+ });
815
+ }
816
+ }
817
+ }
818
+
819
+ /**
820
+ * The per-run portion of {@link persistRunAndReact}: everything that varies per
821
+ * run, WITHOUT the service dependencies (which the plugin binds once via a
822
+ * closure). The plugin hands the router a reactor of this shape so a satellite
823
+ * result drives the exact same post-run path as a local run - the deps are
824
+ * captured once, so the two callers cannot pass a different set and drift.
825
+ */
826
+ export type HealthRunReaction = Omit<
827
+ Parameters<typeof persistRunAndReact>[0],
828
+ | "db"
829
+ | "service"
830
+ | "cache"
831
+ | "signalService"
832
+ | "notificationClient"
833
+ | "catalogClient"
834
+ | "maintenanceClient"
835
+ | "incidentClient"
836
+ | "getHealthEntity"
837
+ | "getEmitHook"
838
+ | "collectorRegistry"
839
+ | "advisoryLock"
840
+ | "logger"
841
+ >;
842
+
615
843
  /**
616
844
  * Execute a health check job
617
845
  */
@@ -658,6 +886,15 @@ async function executeHealthCheckJob(props: {
658
886
  * is skipped and the run executes exactly as before (full timeout, no lane).
659
887
  */
660
888
  slowCheckRuntime?: SlowCheckRuntime | null;
889
+ /**
890
+ * Resolves the ids of every currently-online satellite.
891
+ *
892
+ * Injected rather than imported so this module keeps no dependency on the
893
+ * satellite plugin, and so the unobservable-check path is testable without
894
+ * one. When absent, satellite-only checks behave exactly as they did before:
895
+ * the core stays silent and lets the satellites report.
896
+ */
897
+ getOnlineSatelliteIds?: () => Promise<string[]>;
661
898
  }): Promise<void> {
662
899
  const {
663
900
  payload,
@@ -677,6 +914,7 @@ async function executeHealthCheckJob(props: {
677
914
  secretResolver,
678
915
  internalSecrets,
679
916
  slowCheckRuntime,
917
+ getOnlineSatelliteIds,
680
918
  } = props;
681
919
  const { configId, systemId } = payload;
682
920
 
@@ -750,16 +988,91 @@ async function executeHealthCheckJob(props: {
750
988
  return;
751
989
  }
752
990
 
753
- // If includeLocal is false and satellites are assigned, skip local execution
754
- // (satellites handle execution, local core doesn't run this check)
991
+ // If includeLocal is false and satellites are assigned, the SATELLITES
992
+ // execute this check and the core does not.
993
+ //
994
+ // But "the core does not run it" is not the same as "nothing needs to
995
+ // happen". If every assigned satellite is offline, nobody runs it, and
996
+ // returning silently (as this once did) leaves the check displaying its
997
+ // last known status forever - a dead probe reading exactly like a passing
998
+ // one. So an unobservable check records a `degraded` run instead.
755
999
  if (
756
1000
  !configRow.includeLocal &&
757
1001
  configRow.satelliteIds &&
758
1002
  configRow.satelliteIds.length > 0
759
1003
  ) {
760
- logger.debug(
761
- `Health check ${configId} for system ${systemId} is satellite-only, skipping local execution`,
1004
+ const satelliteIds = configRow.satelliteIds;
1005
+ // Left UNSET (not empty) when liveness cannot be resolved: an empty list
1006
+ // would read as "every satellite is offline" and mark the whole fleet's
1007
+ // satellite-only checks degraded on a transient lookup failure.
1008
+ let onlineSatelliteIds: string[] | undefined;
1009
+ if (getOnlineSatelliteIds) {
1010
+ try {
1011
+ onlineSatelliteIds = await getOnlineSatelliteIds();
1012
+ } catch (error) {
1013
+ logger.warn(
1014
+ `Could not resolve satellite liveness for ${configId}/${systemId}; treating as executing`,
1015
+ error,
1016
+ );
1017
+ }
1018
+ }
1019
+
1020
+ const outcome = resolveSatelliteOnlyOutcome({
1021
+ satelliteIds,
1022
+ ...(onlineSatelliteIds === undefined ? {} : { onlineSatelliteIds }),
1023
+ });
1024
+
1025
+ if (outcome === "satellites-executing") {
1026
+ logger.debug(
1027
+ `Health check ${configId} for system ${systemId} is satellite-only, skipping local execution`,
1028
+ );
1029
+ return;
1030
+ }
1031
+
1032
+ logger.warn(
1033
+ `Health check ${configId} for system ${systemId} has no online satellite ` +
1034
+ `(${satelliteIds.length} assigned); recording a degraded run so the gap is visible`,
762
1035
  );
1036
+
1037
+ let unobservableSystemName = systemId;
1038
+ try {
1039
+ const system = await catalogClient.getSystem({ systemId });
1040
+ if (system) unobservableSystemName = system.name;
1041
+ } catch {
1042
+ // Fall back to the id; a missing display name must not swallow the run.
1043
+ }
1044
+
1045
+ await persistRunAndReact({
1046
+ db,
1047
+ service,
1048
+ cache,
1049
+ signalService,
1050
+ notificationClient,
1051
+ catalogClient,
1052
+ maintenanceClient,
1053
+ incidentClient,
1054
+ ...(getHealthEntity ? { getHealthEntity } : {}),
1055
+ getEmitHook,
1056
+ collectorRegistry,
1057
+ advisoryLock,
1058
+ logger,
1059
+ systemId,
1060
+ systemName: unobservableSystemName,
1061
+ configId,
1062
+ ...(configRow.configName ? { configName: configRow.configName } : {}),
1063
+ // The job payload already names the single (config, system, env) slice
1064
+ // this tick owns, so the stale run lands on exactly the slice the
1065
+ // satellites would have reported for.
1066
+ ...buildUnobservableRun({
1067
+ environmentId: payload.environmentId,
1068
+ satelliteIds,
1069
+ }),
1070
+ runTimestamp: new Date(),
1071
+ // One offline satellite degrades EVERY check assigned to it in the same
1072
+ // tick. Notifying per check would turn a single root cause into a
1073
+ // storm; the satellite's connectivity subscription reports it once.
1074
+ suppressSubscriberNotification: true,
1075
+ });
763
1076
  return;
764
1077
  }
765
1078
 
@@ -948,8 +1261,12 @@ async function executeHealthCheckJob(props: {
948
1261
  // event-driven rollup consumer; an env-less run mutates the bare entity
949
1262
  // (which IS the rollup) and so notifies + broadcasts SYSTEM_STATUS_CHANGED
950
1263
  // directly.
951
- const runEnvironments: (EffectiveEnvironment | null)[] = [singleEnvironment];
952
- const isFannedOut = targetEnvironmentId !== null;
1264
+ const runEnvironments: (EffectiveEnvironment | null)[] = [
1265
+ singleEnvironment,
1266
+ ];
1267
+ // Whether this run fans out to a concrete environment is now derived inside
1268
+ // `persistRunAndReact` (an env-less run IS the rollup); nothing in the loop
1269
+ // body needs it directly.
953
1270
  for (const environment of runEnvironments) {
954
1271
  const environmentId = environment?.id ?? null;
955
1272
  // The env-qualified entity id this run mutates. For the env-less run
@@ -968,7 +1285,8 @@ async function executeHealthCheckJob(props: {
968
1285
  // stale cached status until the TTL. Assigned by whichever branch's
969
1286
  // `apply` runs; used for the transition log AND the cache reconcile.
970
1287
  let previousState!: AggregatedHealth;
971
- let previousStatus!: HealthCheckStatus;
1288
+ // May be `unknown`: the pre-run baseline of a check that had never run.
1289
+ let previousStatus!: SystemHealthStatus;
972
1290
 
973
1291
  // Curated, read-only run-context metadata exposed to collectors.
974
1292
  // Metadata only - never secrets or config. `environment` carries the
@@ -992,91 +1310,44 @@ async function executeHealthCheckJob(props: {
992
1310
  : {}),
993
1311
  };
994
1312
 
995
- // Templating context for the per-env config render pass (§6.3.3).
996
- // Carries only environment custom fields + curated check/system
997
- // metadata - never secrets. `{{ environment.baseUrl }}` resolves from
998
- // the resolved env's verbatim fields; an env-less run gets `{}` so a
999
- // reference renders to empty string (strict: false); see the debug log
1000
- // below.
1001
- const templateContext = {
1002
- environment: runContext.environment?.fields ?? {},
1003
- check: runContext.check,
1004
- system: runContext.system,
1005
- };
1313
+ // An env-less run renders any {{ environment.* }} reference to empty
1314
+ // string (the engine's buildTemplateContext maps a missing environment to
1315
+ // {}). Log it once at debug so it is visible without spamming every tick.
1006
1316
  if (!runContext.environment) {
1007
- // §11.6: render-empty when a run has no environment. An env-less run is
1008
- // a legitimate, documented configuration (the None assignment mode, or
1009
- // All-environments with no membership), and it recurs every interval -
1010
- // so this is `debug`, not `warn`, to avoid spamming the log. When an
1011
- // empty `{{ environment.* }}` render actually matters, the HTTP
1012
- // post-render `.url()` check already fails the run with a concrete
1013
- // "Rendered URL is invalid" error; we do not inspect every field here.
1014
1317
  logger.debug(
1015
1318
  `Health check ${configId} for system ${systemId} ran with no environment; ` +
1016
1319
  `any {{ environment.* }} references render to empty string`,
1017
1320
  );
1018
1321
  }
1019
1322
 
1020
- // (2) Environment/templating pass (NEW) - renders `{{ environment.* }}`
1021
- // etc. in `x-templatable` fields. Runs PER ENVIRONMENT, AFTER the secret
1022
- // resolution (secrets first, templating second - §6.3.4) and BEFORE the
1023
- // strategy client build, so each env gets its own rendered strategy
1024
- // config + client. The collector configs are rendered just before each
1025
- // collector executes (below) so the secretEnv resolution stays first.
1026
- const renderedStrategyConfig = renderTemplatableConfig({
1027
- config: strategyConfig,
1028
- schema: strategy.config.schema,
1029
- context: templateContext,
1030
- }) as BaseStrategyConfig;
1031
-
1032
- // Per-environment isolation: an unexpected failure persisting ONE
1033
- // environment's run must not abort the sibling environments' runs.
1034
- // Each iteration's run is independent (§7.2), so we log and continue.
1035
- try {
1036
- // Execute health check using createClient pattern with unified hard timeout
1037
- const start = performance.now();
1038
- let connectionTimeMs: number | undefined;
1039
- let connectedClient:
1040
- | ConnectedClient<TransportClient<never, unknown>>
1041
- | undefined;
1042
- const collectors = configRow.collectors ?? [];
1043
- const collectorResults: Record<string, unknown> = {};
1044
- let hasCollectorError = false;
1045
- let errorMessage: string | undefined;
1046
-
1047
- try {
1048
- // Platform-level hard timeout wrapping the entire execution sequence
1049
- await Promise.race([
1050
- (async () => {
1051
- // 1. Establish connection. The strategy client build moves INSIDE
1052
- // the per-env loop (§6.3.3): each env gets its own rendered config +
1053
- // client, so a single job no longer bakes in one env's rendered
1054
- // strategy config.
1055
- connectedClient = await strategy.createClient(renderedStrategyConfig);
1056
- connectionTimeMs = Math.round(performance.now() - start);
1057
-
1058
- // 2. Execute collectors in parallel
1059
- const collectorPromises = collectors.map(async (collectorEntry) => {
1060
- const registered = collectorRegistry.getCollector(
1061
- collectorEntry.collectorId,
1062
- );
1063
- if (!registered) {
1064
- logger.warn(
1065
- `Collector ${collectorEntry.collectorId} not found, skipping`,
1066
- );
1067
- return { storageKey: collectorEntry.id, skipped: true };
1068
- }
1069
-
1070
- const storageKey = collectorEntry.id;
1071
-
1072
- try {
1073
- // Resolve the collector's declared secretEnv for THIS run
1074
- // (central execution). The collector injects it and masks the
1075
- // values out of its output. A missing required secret throws
1076
- // and fails the collector clearly.
1077
- let secretEnv: Record<string, string> | undefined;
1323
+ // Per-environment isolation: an unexpected failure persisting ONE
1324
+ // environment's run must not abort the sibling environments' runs.
1325
+ // Each iteration's run is independent (§7.2), so we log and continue.
1326
+ try {
1327
+ // Execute through the SHARED engine (@checkstack/healthcheck-execution):
1328
+ // it renders the strategy + collector `x-templatable` fields against this
1329
+ // env/system's context, builds the transport client, runs the collectors,
1330
+ // and closes the client. This is the SAME engine the satellite uses, so
1331
+ // templating, secret/template ordering, and the per-collector fan-out
1332
+ // cannot drift between core and satellite - the drift that hid custom-
1333
+ // field templates on satellite runs. The core's own edges stay here as
1334
+ // hooks: DB-backed secret resolution, migrate-on-read, and the
1335
+ // assertion/ephemeral-strip post-processing.
1336
+ const outcome = await runHealthCheckCollection({
1337
+ strategy,
1338
+ strategyConfig,
1339
+ collectors: configRow.collectors ?? [],
1340
+ runContext,
1341
+ pluginId: configRow.strategyId,
1342
+ logger,
1343
+ timeoutMs: effectiveTimeout,
1344
+ hooks: {
1345
+ getCollector: (entry) =>
1346
+ collectorRegistry.getCollector(entry.collectorId),
1347
+ storageKeyOf: (entry) => entry.id,
1348
+ resolveSecretEnv: async (entry) => {
1078
1349
  const declared = secretEnvMappingSchema.safeParse(
1079
- (collectorEntry.config as { secretEnv?: unknown }).secretEnv,
1350
+ (entry.config as { secretEnv?: unknown }).secretEnv,
1080
1351
  );
1081
1352
  if (
1082
1353
  secretResolver &&
@@ -1086,96 +1357,64 @@ async function executeHealthCheckJob(props: {
1086
1357
  const resolved = await secretResolver.resolveForRun({
1087
1358
  secretEnv: declared.data,
1088
1359
  });
1089
- secretEnv = resolved.env;
1360
+ return resolved.env;
1090
1361
  }
1091
-
1092
- // Migrate the stored (UNVERSIONED) collector config via
1093
- // assume-v1-on-read: runs the declared migration chain, then
1094
- // validates. Migrations are idempotent, so an already-current
1095
- // config is a no-op. This runs BEFORE templating so the render
1096
- // pass sees the migrated shape; the secretEnv resolution above
1097
- // reads the raw `secretEnv` mapping (a constant string field
1098
- // unaffected by the strategy/collector reshapes), keeping the
1099
- // migrate -> secret resolve -> render -> execute order intact.
1100
- // Inflate this entry's secret markers / references (memory
1101
- // only) before its migrate+validate parse, mirroring the
1102
- // strategy-config inflation above.
1103
- let rawCollectorConfig = collectorEntry.config;
1362
+ return;
1363
+ },
1364
+ prepareCollectorConfig: async (entry, registered) => {
1365
+ // Inflate secret markers (memory-only) then migrate-on-read, so the
1366
+ // engine templates + executes the migrated, secret-resolved shape.
1367
+ let rawCollectorConfig = entry.config;
1104
1368
  if (internalSecrets && secretResolver) {
1105
1369
  const inflated = await inflateConfigSecrets({
1106
1370
  configurationId: configId,
1107
- scope: {
1108
- kind: "collector",
1109
- entryId: collectorEntry.id,
1110
- },
1371
+ scope: { kind: "collector", entryId: entry.id },
1111
1372
  schema: registered.collector.config.schema,
1112
- config: collectorEntry.config,
1373
+ config: entry.config,
1113
1374
  deps: { internalSecrets, secretResolver },
1114
1375
  });
1115
1376
  rawCollectorConfig = inflated.config;
1116
1377
  }
1117
- const migratedCollectorConfig =
1378
+ // `parseAssumingV1` returns the collector's own (generic
1379
+ // `unknown`) config type; the engine templates it as a record, so
1380
+ // narrow to the object shape every collector config actually is.
1381
+ const parsed =
1118
1382
  await registered.collector.config.parseAssumingV1(
1119
1383
  rawCollectorConfig,
1120
1384
  );
1121
-
1122
- // (2) Environment/templating pass for the collector config -
1123
- // runs AFTER the secretEnv resolution above (secrets first,
1124
- // templating second) and renders `{{ environment.* }}` in this
1125
- // collector's `x-templatable` fields against the per-env context.
1126
- const renderedCollectorConfig = renderTemplatableConfig({
1127
- config: migratedCollectorConfig,
1128
- schema: registered.collector.config.schema,
1129
- context: templateContext,
1130
- });
1131
-
1132
- const collectorResult = await registered.collector.execute({
1133
- config: renderedCollectorConfig,
1134
- client: connectedClient!.client,
1135
- pluginId: configRow.strategyId,
1136
- runContext,
1137
- ...(secretEnv ? { secretEnv } : {}),
1138
- });
1139
-
1140
- // Check for collector-level error
1141
- let collectorError: string | undefined;
1142
- if (collectorResult.error) {
1143
- collectorError = collectorResult.error;
1144
- }
1145
-
1146
- // Evaluate per-collector assertions (plain fields + JSONPath).
1147
- // ALL outcomes are stored (pass AND fail) so assertions are
1148
- // analyzable over time, not only visible on failure.
1385
+ return parsed as Record<string, unknown>;
1386
+ },
1387
+ mapResult: ({ entry, registered, collectorResult }) => {
1388
+ const collectorError = collectorResult.error;
1149
1389
  let assertionFailed: string | undefined;
1150
1390
  let assertionOutcomes: AssertionOutcome[] = [];
1151
1391
  if (collectorResult.result) {
1152
1392
  const evaluation = evaluateCollectorAssertionOutcomes({
1153
- assertions: collectorEntry.assertions,
1393
+ assertions: entry.assertions,
1154
1394
  result: collectorResult.result as Record<string, unknown>,
1155
1395
  });
1156
1396
  assertionFailed = evaluation.firstFailureMessage;
1157
1397
  assertionOutcomes = evaluation.outcomes;
1158
1398
  if (assertionFailed) {
1159
1399
  logger.debug(
1160
- `Collector ${storageKey} assertion failed: ${assertionFailed}`,
1400
+ `Collector ${entry.id} assertion failed: ${assertionFailed}`,
1161
1401
  );
1162
1402
  }
1163
1403
  }
1164
-
1165
- // Strip ephemeral fields before storage
1166
1404
  const strippedResult = stripEphemeralFields(
1167
1405
  collectorResult.result as Record<string, unknown>,
1168
1406
  registered.collector.result.schema,
1169
1407
  );
1170
-
1171
1408
  return {
1172
- storageKey,
1173
- skipped: false,
1174
- success: true,
1175
- collectorError,
1176
- assertionFailed,
1177
- result: {
1178
- _collectorId: collectorEntry.collectorId,
1409
+ storageKey: entry.id,
1410
+ success: !collectorError && !assertionFailed,
1411
+ error:
1412
+ collectorError ??
1413
+ (assertionFailed
1414
+ ? `Assertion failed: ${assertionFailed}`
1415
+ : undefined),
1416
+ storedResult: {
1417
+ _collectorId: entry.collectorId,
1179
1418
  _assertionFailed: assertionFailed,
1180
1419
  _collectorError: collectorError,
1181
1420
  ...(assertionOutcomes.length > 0
@@ -1184,417 +1423,266 @@ async function executeHealthCheckJob(props: {
1184
1423
  ...strippedResult,
1185
1424
  },
1186
1425
  };
1187
- } catch (error) {
1188
- const errorStr =
1189
- extractErrorMessage(error);
1190
- logger.debug(`Collector ${storageKey} failed: ${errorStr}`);
1426
+ },
1427
+ mapError: ({ entry, error }) => {
1428
+ const errorStr = extractErrorMessage(error);
1429
+ logger.debug(`Collector ${entry.id} failed: ${errorStr}`);
1191
1430
  return {
1192
- storageKey,
1193
- skipped: false,
1431
+ storageKey: entry.id,
1194
1432
  success: false,
1195
1433
  error: errorStr,
1196
- result: {
1197
- _collectorId: collectorEntry.collectorId,
1434
+ storedResult: {
1435
+ _collectorId: entry.collectorId,
1198
1436
  _assertionFailed: undefined,
1199
1437
  _collectorError: errorStr,
1200
1438
  },
1201
1439
  };
1202
- }
1203
- });
1440
+ },
1441
+ },
1442
+ });
1204
1443
 
1205
- // Wait for all collectors to complete
1206
- const settledResults = await Promise.allSettled(collectorPromises);
1444
+ if (outcome.aborted) {
1445
+ // The transport itself failed: the client build threw, or the hard
1446
+ // timeout fired. This is a transport failure, distinct from a completed
1447
+ // run whose collectors reported problems, so it takes the failure
1448
+ // result shape and (deliberately, matching prior behaviour) skips the
1449
+ // checkCompleted hook + SYSTEM_STATUS_CHANGED signal the success path
1450
+ // emits.
1451
+ const finalError = outcome.errorMessage;
1452
+
1453
+ const result = {
1454
+ status: "unhealthy" as const,
1455
+ latencyMs: outcome.latencyMs,
1456
+ message: finalError,
1457
+ metadata: {
1458
+ connected: outcome.connected,
1459
+ error: finalError,
1460
+ },
1461
+ };
1462
+ // Persist the run + aggregate THROUGH the reactive `health` entity:
1463
+ // `apply` does the durable write and returns the freshly-computed view.
1464
+ // The framework snapshots `prev` via `read` BEFORE this insert, so a real
1465
+ // status change emits exactly one correct `ENTITY_CHANGED` (§10.3). The
1466
+ // computed aggregated state is stashed for the transition/notify path.
1467
+ let newState!: AggregatedHealth;
1468
+ await writeHealthEntity({
1469
+ handle: getHealthEntity?.(),
1470
+ entityId: envEntityId,
1471
+ apply: async () => {
1472
+ // In-lock pre-run baseline (see the `previousState` declaration): read
1473
+ // here, inside the serialized critical section, before the insert.
1474
+ previousState = await service.getSystemHealthStatus(
1475
+ systemId,
1476
+ environmentId,
1477
+ );
1478
+ previousStatus = previousState.status;
1479
+ // §perf: batch the run INSERT + aggregate SELECT/UPSERT under ONE
1480
+ // `SET LOCAL search_path` transaction (3 scoped-db transactions → 1),
1481
+ // which also makes the run and its aggregate commit atomically.
1482
+ await withScopedTransaction(db, async (tx) => {
1483
+ await tx.insert(healthCheckRuns).values({
1484
+ configurationId: configId,
1485
+ systemId,
1486
+ environmentId,
1487
+ status: result.status,
1488
+ latencyMs: result.latencyMs,
1489
+ result: { ...result } as Record<string, unknown>,
1490
+ sourceId: undefined,
1491
+ sourceLabel: "Local",
1492
+ });
1207
1493
 
1208
- // Process results from all collectors
1209
- for (const settled of settledResults) {
1210
- if (settled.status === "rejected") {
1211
- // This shouldn't happen since we catch errors above, but handle it
1212
- hasCollectorError = true;
1213
- if (!errorMessage) errorMessage = String(settled.reason);
1214
- continue;
1215
- }
1494
+ await incrementHourlyAggregate({
1495
+ db: tx,
1496
+ systemId,
1497
+ configurationId: configId,
1498
+ environmentId,
1499
+ status: result.status,
1500
+ latencyMs: result.latencyMs,
1501
+ runTimestamp: new Date(),
1502
+ result: { ...result } as Record<string, unknown>,
1503
+ collectorRegistry,
1504
+ sourceLabel: "Local",
1505
+ });
1506
+ });
1216
1507
 
1217
- const result = settled.value;
1218
- if (result.skipped) continue;
1219
-
1220
- // Store the result
1221
- collectorResults[result.storageKey] = result.result;
1222
-
1223
- // Track errors
1224
- if (
1225
- !result.success ||
1226
- result.collectorError ||
1227
- result.assertionFailed
1228
- ) {
1229
- hasCollectorError = true;
1230
- if (!errorMessage) {
1231
- errorMessage =
1232
- result.error ||
1233
- result.collectorError ||
1234
- (result.assertionFailed
1235
- ? `Assertion failed: ${result.assertionFailed}`
1236
- : undefined);
1237
- }
1238
- }
1239
- }
1240
- })(),
1241
- new Promise<never>((_, reject) =>
1242
- setTimeout(
1243
- () =>
1244
- reject(
1245
- new Error(`Execution timeout after ${effectiveTimeout}ms`),
1508
+ // Env-scoped view: the per-env entity reflects only this env's runs.
1509
+ // Runs as its own batched read AFTER the write commits, so it sees
1510
+ // the just-inserted run.
1511
+ newState = await service.getSystemHealthStatus(
1512
+ systemId,
1513
+ environmentId,
1514
+ );
1515
+ return toHealthEntityView(newState);
1516
+ },
1517
+ serialize: serializeEnvWrite,
1518
+ onError: (error) =>
1519
+ logger.warn(
1520
+ `Failed to mirror health entity for ${envEntityId}`,
1521
+ error,
1246
1522
  ),
1247
- effectiveTimeout,
1248
- ),
1249
- ),
1250
- ]);
1251
- } catch (error) {
1252
- const latencyMs = Math.round(performance.now() - start);
1253
- const caughtError =
1254
- extractErrorMessage(error);
1255
-
1256
- // Use a specific error message if available, otherwise use the caught error
1257
- const finalError = errorMessage || caughtError;
1258
-
1259
- const result = {
1260
- status: "unhealthy" as const,
1261
- latencyMs,
1262
- message: finalError,
1263
- metadata: {
1264
- connected: !!connectedClient,
1265
- error: finalError,
1266
- },
1267
- };
1523
+ });
1524
+
1525
+ logger.debug(
1526
+ `Health check ${configId} for system ${systemId} failed: ${finalError}`,
1527
+ );
1268
1528
 
1269
- // Persist the run + aggregate THROUGH the reactive `health` entity:
1270
- // `apply` does the durable write and returns the freshly-computed view.
1271
- // The framework snapshots `prev` via `read` BEFORE this insert, so a real
1272
- // status change emits exactly one correct `ENTITY_CHANGED` (§10.3). The
1273
- // computed aggregated state is stashed for the transition/notify path.
1274
- let newState!: AggregatedHealth;
1275
- await writeHealthEntity({
1276
- handle: getHealthEntity?.(),
1277
- entityId: envEntityId,
1278
- apply: async () => {
1279
- // In-lock pre-run baseline (see the `previousState` declaration): read
1280
- // here, inside the serialized critical section, before the insert.
1281
- previousState = await service.getSystemHealthStatus(
1529
+ // Reconcile this environment's cached status: evict + broadcast to the
1530
+ // cluster ONLY when the per-check vector actually changed (a run that
1531
+ // leaves every check's status unchanged keeps the cache warm instead of
1532
+ // thrashing it every tick). The rollup key is reconciled separately by
1533
+ // the debounced rollup consumer (recomputeSystemRollupHealth), also
1534
+ // vector-gated.
1535
+ await cache.reconcile({
1282
1536
  systemId,
1283
1537
  environmentId,
1284
- );
1285
- previousStatus = previousState.status;
1286
- // §perf: batch the run INSERT + aggregate SELECT/UPSERT under ONE
1287
- // `SET LOCAL search_path` transaction (3 scoped-db transactions → 1),
1288
- // which also makes the run and its aggregate commit atomically.
1289
- await withScopedTransaction(db, async (tx) => {
1290
- await tx.insert(healthCheckRuns).values({
1291
- configurationId: configId,
1538
+ previous: previousState,
1539
+ next: newState,
1540
+ });
1541
+
1542
+ await signalService.broadcast(HEALTH_CHECK_RUN_COMPLETED, {
1543
+ systemId,
1544
+ systemName,
1545
+ configurationId: configId,
1546
+ configurationName: configRow.configName,
1547
+ status: result.status,
1548
+ latencyMs: result.latencyMs,
1549
+ // Env-scoped fan-out: `environment` is null for the env-less run, so
1550
+ // `?.` yields undefined and those runs broadcast exactly as before.
1551
+ environmentId: environment?.id,
1552
+ environmentName: environment?.name,
1553
+ });
1554
+
1555
+ // `newState.status` cannot be `unknown` here - a run just completed, so
1556
+ // the check has a measurement - but narrowing it keeps that guarantee
1557
+ // explicit rather than asserted with a cast.
1558
+ if (
1559
+ newState.status !== previousStatus &&
1560
+ newState.status !== "unknown"
1561
+ ) {
1562
+ // Record the aggregate transition so the sensing layer has a
1563
+ // reliable "in status since" for every status (Wave 2).
1564
+ await recordStateTransition({
1565
+ db,
1292
1566
  systemId,
1567
+ configurationId: configId,
1293
1568
  environmentId,
1294
- status: result.status,
1295
- latencyMs: result.latencyMs,
1296
- result: { ...result } as Record<string, unknown>,
1297
- sourceId: undefined,
1298
- sourceLabel: "Local",
1569
+ // NULL means "no prior measured status" - the column is nullable for
1570
+ // exactly this first-measurement case, so a system whose checks had
1571
+ // never run records an honest `null -> healthy` rather than
1572
+ // pretending it was healthy all along.
1573
+ fromStatus:
1574
+ previousStatus === "unknown" ? undefined : previousStatus,
1575
+ toStatus: newState.status,
1299
1576
  });
1300
1577
 
1301
- await incrementHourlyAggregate({
1302
- db: tx,
1578
+ await notifyStateChange({
1579
+ notificationClient,
1303
1580
  systemId,
1581
+ systemName,
1304
1582
  configurationId: configId,
1583
+ configurationName: configRow.configName,
1584
+ // A first measurement is not a transition anyone asked to hear about
1585
+ // when it lands healthy; `notifyStateChange` decides, and it needs a
1586
+ // concrete previous status to compare against.
1587
+ previousStatus:
1588
+ previousStatus === "unknown" ? "healthy" : previousStatus,
1589
+ newStatus: newState.status,
1305
1590
  environmentId,
1306
- status: result.status,
1307
- latencyMs: result.latencyMs,
1308
- runTimestamp: new Date(),
1309
- result: { ...result } as Record<string, unknown>,
1310
- collectorRegistry,
1311
- sourceLabel: "Local",
1591
+ environmentName: environment?.name,
1592
+ service,
1593
+ catalogClient,
1594
+ maintenanceClient,
1595
+ incidentClient,
1596
+ logger,
1312
1597
  });
1313
- });
1314
-
1315
- // Env-scoped view: the per-env entity reflects only this env's runs.
1316
- // Runs as its own batched read AFTER the write commits, so it sees
1317
- // the just-inserted run.
1318
- newState = await service.getSystemHealthStatus(systemId, environmentId);
1319
- return toHealthEntityView(newState);
1320
- },
1321
- serialize: serializeEnvWrite,
1322
- onError: (error) =>
1323
- logger.warn(
1324
- `Failed to mirror health entity for ${envEntityId}`,
1325
- error,
1326
- ),
1327
- });
1598
+ }
1328
1599
 
1329
- logger.debug(
1330
- `Health check ${configId} for system ${systemId} failed: ${finalError}`,
1331
- );
1600
+ // This environment's run is done (failed). Continue to the next
1601
+ // effective environment rather than ending the whole job.
1602
+ continue;
1603
+ }
1332
1604
 
1333
- // Reconcile this environment's cached status: evict + broadcast to the
1334
- // cluster ONLY when the per-check vector actually changed (a run that
1335
- // leaves every check's status unchanged keeps the cache warm instead of
1336
- // thrashing it every tick). The rollup key is reconciled separately by
1337
- // the debounced rollup consumer (recomputeSystemRollupHealth), also
1338
- // vector-gated.
1339
- await cache.reconcile({
1340
- systemId,
1341
- environmentId,
1342
- previous: previousState,
1343
- next: newState,
1344
- });
1605
+ // A COMPLETED run: the client built and the collectors ran. Its status is
1606
+ // decided by the collectors - a collector error or failed assertion
1607
+ // downgrades it - exactly as before.
1608
+ const status = outcome.hasCollectorError ? "unhealthy" : "healthy";
1609
+ const totalLatencyMs = outcome.latencyMs;
1610
+
1611
+ // Transport sub-phase timings measured AT THE PROBE and already filtered by
1612
+ // the engine to present phases. The satellite surfaces the same shape for
1613
+ // remote runs, so a run's `metadata.timings` is identical wherever it ran.
1614
+ const timings = outcome.clientTimings;
1615
+
1616
+ // Metrics (OTel no-ops unless enabled): the probe's total wall-clock and its
1617
+ // network sub-phases. The `phase` breakdown tells "slow target" (`wait`
1618
+ // grows) apart from "slow connection" (`connect`/`tls` grow) apart from
1619
+ // platform delay.
1620
+ healthcheckExecutionHistogram().record(totalLatencyMs, { status });
1621
+ if (timings) {
1622
+ for (const [phase, value] of Object.entries(timings)) {
1623
+ if (typeof value === "number" && Number.isFinite(value)) {
1624
+ healthcheckPhaseHistogram().record(value, {
1625
+ phase: phase.replace(/Ms$/, ""),
1626
+ });
1627
+ }
1628
+ }
1629
+ }
1345
1630
 
1346
- await signalService.broadcast(HEALTH_CHECK_RUN_COMPLETED, {
1347
- systemId,
1348
- systemName,
1349
- configurationId: configId,
1350
- configurationName: configRow.configName,
1351
- status: result.status,
1352
- latencyMs: result.latencyMs,
1353
- // Env-scoped fan-out: `environment` is null for the env-less run, so
1354
- // `?.` yields undefined and those runs broadcast exactly as before.
1355
- environmentId: environment?.id,
1356
- environmentName: environment?.name,
1357
- });
1631
+ const result = {
1632
+ status: status as "healthy" | "unhealthy",
1633
+ latencyMs: totalLatencyMs,
1634
+ message: outcome.hasCollectorError
1635
+ ? `Check failed: ${outcome.errorMessage}`
1636
+ : `Completed in ${totalLatencyMs}ms`,
1637
+ metadata: {
1638
+ connected: true,
1639
+ connectionTimeMs: outcome.connectionTimeMs,
1640
+ ...(timings ? { timings } : {}),
1641
+ collectors: outcome.collectorResults,
1642
+ },
1643
+ };
1358
1644
 
1359
- if (newState.status !== previousStatus) {
1360
- // Record the aggregate transition so the sensing layer has a
1361
- // reliable "in status since" for every status (Wave 2).
1362
- await recordStateTransition({
1645
+ // Persist this run and drive everything that reacts to it - the reactive
1646
+ // entity write, cache reconcile, realtime signal, automation hooks,
1647
+ // transition record, and subscriber notification - through the ONE
1648
+ // shared post-run path. Satellite-result ingest calls the same function,
1649
+ // so a satellite-detected change reacts identically and the two paths
1650
+ // cannot drift.
1651
+ await persistRunAndReact({
1363
1652
  db,
1364
- systemId,
1365
- configurationId: configId,
1366
- environmentId,
1367
- fromStatus: previousStatus,
1368
- toStatus: newState.status,
1369
- });
1370
-
1371
- await notifyStateChange({
1372
- notificationClient,
1373
- systemId,
1374
- systemName,
1375
- configurationId: configId,
1376
- configurationName: configRow.configName,
1377
- previousStatus,
1378
- newStatus: newState.status,
1379
- environmentId,
1380
- environmentName: environment?.name,
1381
1653
  service,
1654
+ cache,
1655
+ signalService,
1656
+ notificationClient,
1382
1657
  catalogClient,
1383
1658
  maintenanceClient,
1384
1659
  incidentClient,
1660
+ getHealthEntity,
1661
+ getEmitHook,
1662
+ collectorRegistry,
1663
+ advisoryLock,
1385
1664
  logger,
1386
- });
1387
- }
1388
-
1389
- // This environment's run is done (failed). Continue to the next
1390
- // effective environment rather than ending the whole job.
1391
- continue;
1392
- } finally {
1393
- if (connectedClient) {
1394
- try {
1395
- connectedClient.close();
1396
- } catch (error) {
1397
- logger.warn(`Failed to close connection: ${error}`);
1398
- }
1399
- }
1400
- }
1401
-
1402
- // Determine health status based on collector results
1403
- const status = hasCollectorError ? "unhealthy" : "healthy";
1404
- const totalLatencyMs = Math.round(performance.now() - start);
1405
-
1406
- // Lift the strategy's structured transport timings (DNS / connect / TLS /
1407
- // wait / transfer / processing) into the run metadata when the connected
1408
- // client surfaced any. Strategies that cannot measure sub-phases leave this
1409
- // undefined and the frontend falls back to the coarse connection split.
1410
- const timings = extractRunTimings(connectedClient);
1411
-
1412
- // Metrics (OTel no-ops unless enabled): the probe's total wall-clock and its
1413
- // network sub-phases. The `phase` breakdown is what tells "slow target"
1414
- // (`wait` grows) apart from "slow connection establishment" (`connect`/`tls`
1415
- // grow under a same-host stampede) apart from platform delay.
1416
- healthcheckExecutionHistogram().record(totalLatencyMs, { status });
1417
- if (timings) {
1418
- for (const [phase, value] of Object.entries(timings)) {
1419
- if (typeof value === "number" && Number.isFinite(value)) {
1420
- healthcheckPhaseHistogram().record(value, {
1421
- phase: phase.replace(/Ms$/, ""),
1422
- });
1423
- }
1424
- }
1425
- }
1426
-
1427
- const result = {
1428
- status: status as "healthy" | "unhealthy",
1429
- latencyMs: totalLatencyMs,
1430
- message: hasCollectorError
1431
- ? `Check failed: ${errorMessage}`
1432
- : `Completed in ${totalLatencyMs}ms`,
1433
- metadata: {
1434
- connected: true,
1435
- connectionTimeMs,
1436
- ...(timings ? { timings } : {}),
1437
- collectors: collectorResults,
1438
- },
1439
- };
1440
-
1441
- // Persist the run + aggregate THROUGH the reactive `health` entity on
1442
- // every run (§10.3): `apply` does the durable write (insert + hourly
1443
- // aggregate) and returns the freshly-computed view. The framework
1444
- // snapshots `prev` via the COMPUTE-ON-READ accessor BEFORE this insert, so
1445
- // an unchanged aggregate is a no-op and a real status change drives the
1446
- // directional/umbrella trigger events via `deriveHealthTriggerEvents` —
1447
- // exactly one correct `ENTITY_CHANGED` with accurate prev → next.
1448
- let newState!: AggregatedHealth;
1449
- await writeHealthEntity({
1450
- handle: getHealthEntity?.(),
1451
- entityId: envEntityId,
1452
- apply: async () => {
1453
- // In-lock pre-run baseline (see the `previousState` declaration): read
1454
- // here, inside the serialized critical section, before the insert.
1455
- previousState = await service.getSystemHealthStatus(
1456
1665
  systemId,
1666
+ systemName,
1667
+ configId,
1668
+ configName: configRow.configName,
1457
1669
  environmentId,
1458
- );
1459
- previousStatus = previousState.status;
1460
- // §perf: batch the run INSERT + aggregate SELECT/UPSERT under ONE
1461
- // `SET LOCAL search_path` transaction (3 scoped-db transactions → 1),
1462
- // which also makes the run and its aggregate commit atomically.
1463
- await withScopedTransaction(db, async (tx) => {
1464
- // Store result (spread to convert structured type to plain record for jsonb)
1465
- await tx.insert(healthCheckRuns).values({
1466
- configurationId: configId,
1467
- systemId,
1468
- environmentId,
1469
- status: result.status,
1470
- latencyMs: result.latencyMs,
1471
- result: { ...result } as Record<string, unknown>,
1472
- sourceId: undefined,
1473
- sourceLabel: "Local",
1474
- });
1475
-
1476
- // Trigger incremental hourly aggregation
1477
- await incrementHourlyAggregate({
1478
- db: tx,
1479
- systemId,
1480
- configurationId: configId,
1481
- environmentId,
1482
- status: result.status,
1483
- latencyMs: result.latencyMs,
1484
- runTimestamp: new Date(),
1485
- result: { ...result } as Record<string, unknown>,
1486
- collectorRegistry,
1487
- sourceLabel: "Local",
1488
- });
1489
- });
1490
-
1491
- // Env-scoped view: the per-env entity reflects only this env's runs.
1492
- // Runs as its own batched read AFTER the write commits, so it sees the
1493
- // just-inserted run.
1494
- newState = await service.getSystemHealthStatus(systemId, environmentId);
1495
- return toHealthEntityView(newState);
1496
- },
1497
- serialize: serializeEnvWrite,
1498
- onError: (error) =>
1499
- logger.warn(`Failed to mirror health entity for ${envEntityId}`, error),
1500
- });
1501
-
1502
- logger.debug(
1503
- `Ran health check ${configId} for system ${systemId}: ${result.status}`,
1504
- );
1505
-
1506
- // Reconcile this environment's cached status: evict + broadcast to the
1507
- // cluster ONLY when the per-check vector actually changed (a steady-state
1508
- // healthy run keeps the cache warm). The rollup key is reconciled by the
1509
- // debounced rollup consumer (recomputeSystemRollupHealth), also vector-gated.
1510
- await cache.reconcile({
1511
- systemId,
1512
- environmentId,
1513
- previous: previousState,
1514
- next: newState,
1515
- });
1516
-
1517
- // Broadcast enriched signal for realtime frontend updates (e.g., terminal feed)
1518
- await signalService.broadcast(HEALTH_CHECK_RUN_COMPLETED, {
1519
- systemId,
1520
- systemName,
1521
- configurationId: configId,
1522
- configurationName: configRow.configName,
1523
- status: result.status,
1524
- latencyMs: result.latencyMs,
1525
- // Env-scoped fan-out: `environment` is null for the env-less run, so
1526
- // `?.` yields undefined and those runs broadcast exactly as before.
1527
- environmentId: environment?.id,
1528
- environmentName: environment?.name,
1529
- });
1530
-
1531
- await emitCheckCompletedHook({
1532
- getEmitHook,
1533
- systemId,
1534
- configurationId: configId,
1535
- status: result.status,
1536
- latencyMs: result.latencyMs,
1537
- result: (result.metadata?.collectors as Record<string, unknown>) ?? undefined,
1538
- environmentId,
1539
- });
1540
-
1541
- if (newState.status !== previousStatus) {
1542
- // Record the aggregate transition so the sensing layer has a
1543
- // reliable "in status since" for every status (Wave 2).
1544
- await recordStateTransition({
1545
- db,
1546
- systemId,
1547
- configurationId: configId,
1548
- environmentId,
1549
- fromStatus: previousStatus,
1550
- toStatus: newState.status,
1551
- });
1552
-
1553
- await notifyStateChange({
1554
- notificationClient,
1555
- systemId,
1556
- systemName,
1557
- configurationId: configId,
1558
- configurationName: configRow.configName,
1559
- previousStatus,
1560
- newStatus: newState.status,
1561
- environmentId,
1562
- environmentName: environment?.name,
1563
- service,
1564
- catalogClient,
1565
- maintenanceClient,
1566
- incidentClient,
1567
- logger,
1568
- });
1569
-
1570
- // The system-level `SYSTEM_STATUS_CHANGED` signal must carry the ROLLUP
1571
- // status, not a per-env status. When fanned out, the post-loop rollup
1572
- // write broadcasts it once with the worst-status rollup; emitting it here
1573
- // per env would send up to N system-level signals/tick carrying per-env
1574
- // status. Only the env-less run (which IS the rollup — `!isFannedOut`)
1575
- // broadcasts the system-level signal from inside the loop.
1576
- if (!isFannedOut) {
1577
- await signalService.broadcast(SYSTEM_STATUS_CHANGED, {
1578
- systemId,
1579
- previousStatus: previousStatus as HealthCheckStatus,
1580
- newStatus: newState.status,
1670
+ environmentName: environment?.name,
1671
+ status: result.status,
1672
+ latencyMs: result.latencyMs,
1673
+ result: { ...result },
1674
+ sourceId: undefined,
1675
+ sourceLabel: "Local",
1676
+ runTimestamp: new Date(),
1581
1677
  });
1678
+ } catch (envError) {
1679
+ // Isolate this environment's failure; continue with the next env.
1680
+ logger.error(
1681
+ `Failed to run health check ${configId} for system ${systemId}` +
1682
+ (environmentId ? ` (environment ${environmentId})` : " (env-less)"),
1683
+ envError,
1684
+ );
1582
1685
  }
1583
-
1584
- // The directional + umbrella system-health hooks were removed in
1585
- // Phase 4 (§10.3): the `health` entity mirror above is the single
1586
- // source of truth, and its change deriver fires the
1587
- // `healthcheck.system_degraded` / `_healthy` / `_health_changed`
1588
- // trigger events through Stage-1 routing. Nothing to emit here.
1589
- }
1590
- } catch (envError) {
1591
- // Isolate this environment's failure; continue with the next env.
1592
- logger.error(
1593
- `Failed to run health check ${configId} for system ${systemId}` +
1594
- (environmentId ? ` (environment ${environmentId})` : " (env-less)"),
1595
- envError,
1596
- );
1597
- }
1598
1686
  } // end per-environment fan-out loop (for ... of runEnvironments)
1599
1687
 
1600
1688
  // The system ROLLUP (bare `<systemId>` entity) for a fanned-out env-scoped
@@ -1624,7 +1712,8 @@ async function executeHealthCheckJob(props: {
1624
1712
  // catastrophic tick for the same system can't commit between the baseline
1625
1713
  // read and this insert and make the cache change-gate miss a transition.
1626
1714
  let rollupPreState!: AggregatedHealth;
1627
- let previousStatus!: HealthCheckStatus;
1715
+ // May be `unknown`: the pre-run baseline of a check that had never run.
1716
+ let previousStatus!: SystemHealthStatus;
1628
1717
  let newState!: AggregatedHealth;
1629
1718
  await writeHealthEntity({
1630
1719
  handle: getHealthEntity?.(),
@@ -1719,14 +1808,16 @@ async function executeHealthCheckJob(props: {
1719
1808
  environmentId: null,
1720
1809
  });
1721
1810
 
1722
- if (newState.status !== previousStatus) {
1811
+ // `newState.status` cannot be `unknown` here (a run just completed).
1812
+ if (newState.status !== previousStatus && newState.status !== "unknown") {
1723
1813
  // Record the aggregate transition so the sensing layer has a
1724
1814
  // reliable "in status since" for every status (Wave 2).
1725
1815
  await recordStateTransition({
1726
1816
  db,
1727
1817
  systemId,
1728
1818
  configurationId: configId,
1729
- fromStatus: previousStatus,
1819
+ // `undefined` records NULL: no prior measured status.
1820
+ fromStatus: previousStatus === "unknown" ? undefined : previousStatus,
1730
1821
  toStatus: newState.status,
1731
1822
  });
1732
1823
 
@@ -1736,7 +1827,9 @@ async function executeHealthCheckJob(props: {
1736
1827
  systemName,
1737
1828
  configurationId: configId,
1738
1829
  configurationName: configName,
1739
- previousStatus,
1830
+ // A first measurement has no previous status to compare against.
1831
+ previousStatus:
1832
+ previousStatus === "unknown" ? "healthy" : previousStatus,
1740
1833
  newStatus: newState.status,
1741
1834
  service,
1742
1835
  catalogClient,
@@ -1791,6 +1884,15 @@ export async function setupHealthCheckWorker(props: {
1791
1884
  * don't exercise the bulkhead), or a concrete runtime to drive it.
1792
1885
  */
1793
1886
  slowCheckRuntime?: SlowCheckRuntime | null;
1887
+ /**
1888
+ * Resolves the ids of every currently-online satellite.
1889
+ *
1890
+ * Injected rather than imported so this module keeps no dependency on the
1891
+ * satellite plugin, and so the unobservable-check path is testable without
1892
+ * one. When absent, satellite-only checks behave exactly as they did before:
1893
+ * the core stays silent and lets the satellites report.
1894
+ */
1895
+ getOnlineSatelliteIds?: () => Promise<string[]>;
1794
1896
  }): Promise<void> {
1795
1897
  const {
1796
1898
  db,
@@ -1809,6 +1911,7 @@ export async function setupHealthCheckWorker(props: {
1809
1911
  cache,
1810
1912
  secretResolver,
1811
1913
  internalSecrets,
1914
+ getOnlineSatelliteIds,
1812
1915
  } = props;
1813
1916
 
1814
1917
  // Resolve the slow-check runtime once at startup unless the caller supplied
@@ -1845,6 +1948,7 @@ export async function setupHealthCheckWorker(props: {
1845
1948
  secretResolver,
1846
1949
  internalSecrets,
1847
1950
  slowCheckRuntime,
1951
+ ...(getOnlineSatelliteIds ? { getOnlineSatelliteIds } : {}),
1848
1952
  });
1849
1953
  },
1850
1954
  {
@@ -1855,4 +1959,3 @@ export async function setupHealthCheckWorker(props: {
1855
1959
 
1856
1960
  logger.debug("🎯 Health Check Worker subscribed to queue");
1857
1961
  }
1858
-