@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/CHANGELOG.md +314 -0
- package/drizzle/0021_amazing_wolf_cub.sql +8 -0
- package/drizzle/meta/0021_snapshot.json +717 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +31 -29
- package/src/ai/system-signals-contributor.test.ts +1 -0
- package/src/cache.test.ts +3 -0
- package/src/effective-environments.test.ts +63 -2
- package/src/effective-environments.ts +34 -0
- package/src/health-entity.ts +8 -2
- package/src/health-state.ts +15 -6
- package/src/index.ts +33 -0
- package/src/queue-executor.test.ts +270 -0
- package/src/queue-executor.ts +672 -569
- package/src/router-satellite-ingest.test.ts +136 -0
- package/src/router.ts +65 -4
- package/src/satellite-liveness.test.ts +199 -0
- package/src/satellite-liveness.ts +106 -0
- package/src/schema.ts +21 -0
- package/src/service-batching.test.ts +3 -1
- package/src/service-ingest-assertions.test.ts +33 -60
- package/src/service-paused-filter.test.ts +9 -4
- package/src/service-rollup-worst-wins.test.ts +147 -55
- package/src/service.ts +355 -148
- package/src/state-evaluator.test.ts +49 -0
- package/src/system-health-override.ts +6 -1
- package/tsconfig.json +6 -0
package/src/queue-executor.ts
CHANGED
|
@@ -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
|
|
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<
|
|
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({
|
|
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,
|
|
754
|
-
//
|
|
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
|
-
|
|
761
|
-
|
|
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)[] = [
|
|
952
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
996
|
-
//
|
|
997
|
-
//
|
|
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
|
-
//
|
|
1021
|
-
//
|
|
1022
|
-
//
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
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
|
-
(
|
|
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
|
-
|
|
1360
|
+
return resolved.env;
|
|
1090
1361
|
}
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
//
|
|
1095
|
-
//
|
|
1096
|
-
|
|
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:
|
|
1373
|
+
config: entry.config,
|
|
1113
1374
|
deps: { internalSecrets, secretResolver },
|
|
1114
1375
|
});
|
|
1115
1376
|
rawCollectorConfig = inflated.config;
|
|
1116
1377
|
}
|
|
1117
|
-
|
|
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
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
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:
|
|
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 ${
|
|
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
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
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
|
-
}
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
logger.debug(`Collector ${
|
|
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
|
-
|
|
1197
|
-
_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
|
-
|
|
1206
|
-
|
|
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
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
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
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
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
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
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
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
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
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
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
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
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
|
|
1302
|
-
|
|
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
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
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
|
-
|
|
1330
|
-
|
|
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
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
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
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
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
|
-
|
|
1360
|
-
//
|
|
1361
|
-
//
|
|
1362
|
-
|
|
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
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|