@checkstack/healthcheck-backend 1.21.3 → 1.22.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 +219 -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 +21 -20
- 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 +23 -0
- package/src/queue-executor.ts +545 -565
- package/src/router-satellite-ingest.test.ts +136 -0
- package/src/router.ts +65 -4
- 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 +3 -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,
|
|
@@ -156,39 +150,6 @@ async function fetchRecentRunsForSlice(props: {
|
|
|
156
150
|
}));
|
|
157
151
|
}
|
|
158
152
|
|
|
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
153
|
/**
|
|
193
154
|
* Emit the checkCompleted hook if available, plus the narrower
|
|
194
155
|
* `checkFailed` hook when the result wasn't `healthy` (so operators
|
|
@@ -357,7 +318,10 @@ export async function recomputeSystemRollupHealth(args: {
|
|
|
357
318
|
*/
|
|
358
319
|
signalService?: SignalService;
|
|
359
320
|
cache?: HealthCheckCache;
|
|
360
|
-
}): Promise<
|
|
321
|
+
}): Promise<
|
|
322
|
+
| { previousStatus: SystemHealthStatus; newStatus: SystemHealthStatus }
|
|
323
|
+
| undefined
|
|
324
|
+
> {
|
|
361
325
|
const {
|
|
362
326
|
systemId,
|
|
363
327
|
service,
|
|
@@ -403,7 +367,11 @@ export async function recomputeSystemRollupHealth(args: {
|
|
|
403
367
|
// Cache: evict the rollup key + broadcast to the cluster on ANY per-check
|
|
404
368
|
// vector change — a check that flips while the rollup enum stays put still
|
|
405
369
|
// changes the rollup's `checkStatuses`, and a reader gets that vector.
|
|
406
|
-
await cache?.reconcile({
|
|
370
|
+
await cache?.reconcile({
|
|
371
|
+
systemId,
|
|
372
|
+
previous: previousState,
|
|
373
|
+
next: newState,
|
|
374
|
+
});
|
|
407
375
|
// Frontend signal: only a rollup-enum transition moves the badge, so a
|
|
408
376
|
// per-check-only change needs no SYSTEM_STATUS_CHANGED refetch signal.
|
|
409
377
|
if (newState.status !== previousState.status) {
|
|
@@ -612,6 +580,239 @@ async function notifyStateChange(props: {
|
|
|
612
580
|
}
|
|
613
581
|
}
|
|
614
582
|
|
|
583
|
+
/**
|
|
584
|
+
* Persist ONE completed health-check run (for one system + environment +
|
|
585
|
+
* source) and drive EVERYTHING that must react to it, in the correct order:
|
|
586
|
+
* the reactive `health` entity write (which does the durable run insert +
|
|
587
|
+
* hourly-aggregate increment and fires the authoritative `ENTITY_CHANGED`),
|
|
588
|
+
* the cache reconcile, the realtime run signal, the checkCompleted/checkFailed
|
|
589
|
+
* automation hooks, and - on a real status transition - the transition record,
|
|
590
|
+
* the subscriber notification, and (for an env-less run) the system-status
|
|
591
|
+
* signal.
|
|
592
|
+
*
|
|
593
|
+
* This is the SINGLE post-run path. A local run (the queue executor) and a
|
|
594
|
+
* SATELLITE run (ingested over RPC) both call it, so a satellite-detected
|
|
595
|
+
* outage fires the same notifications, automations, transitions, and signals a
|
|
596
|
+
* local one does - previously ingest only inserted the row, so satellite runs
|
|
597
|
+
* were silent. Keeping it in one function is what stops that from drifting
|
|
598
|
+
* again; the only difference between the two callers is the `sourceId` /
|
|
599
|
+
* `sourceLabel` / `runTimestamp` of the run, passed in.
|
|
600
|
+
*/
|
|
601
|
+
export async function persistRunAndReact(params: {
|
|
602
|
+
db: Db;
|
|
603
|
+
service: HealthCheckService;
|
|
604
|
+
cache: HealthCheckCache;
|
|
605
|
+
signalService: SignalService;
|
|
606
|
+
notificationClient: NotificationClient;
|
|
607
|
+
catalogClient: CatalogClient;
|
|
608
|
+
maintenanceClient: MaintenanceClient;
|
|
609
|
+
incidentClient: IncidentClient;
|
|
610
|
+
getHealthEntity?: () => EntityHandle<HealthEntityState> | undefined;
|
|
611
|
+
getEmitHook: () => EmitHookFn | undefined;
|
|
612
|
+
collectorRegistry: CollectorRegistry;
|
|
613
|
+
advisoryLock: AdvisoryLockService;
|
|
614
|
+
logger: Logger;
|
|
615
|
+
systemId: string;
|
|
616
|
+
systemName: string;
|
|
617
|
+
configId: string;
|
|
618
|
+
configName?: string;
|
|
619
|
+
/** `null` is the env-less slice, which IS the system rollup. */
|
|
620
|
+
environmentId: string | null;
|
|
621
|
+
environmentName?: string;
|
|
622
|
+
status: HealthCheckStatus;
|
|
623
|
+
latencyMs?: number;
|
|
624
|
+
/** The full run result record persisted to `health_check_runs.result`. */
|
|
625
|
+
result: Record<string, unknown>;
|
|
626
|
+
/** `undefined` = local core; a satellite id otherwise. */
|
|
627
|
+
sourceId?: string;
|
|
628
|
+
sourceLabel: string;
|
|
629
|
+
/** Timestamp used for the hourly aggregate bucket (the run's execution time). */
|
|
630
|
+
runTimestamp: Date;
|
|
631
|
+
}): Promise<void> {
|
|
632
|
+
const {
|
|
633
|
+
db,
|
|
634
|
+
service,
|
|
635
|
+
cache,
|
|
636
|
+
signalService,
|
|
637
|
+
notificationClient,
|
|
638
|
+
catalogClient,
|
|
639
|
+
maintenanceClient,
|
|
640
|
+
incidentClient,
|
|
641
|
+
getHealthEntity,
|
|
642
|
+
getEmitHook,
|
|
643
|
+
collectorRegistry,
|
|
644
|
+
advisoryLock,
|
|
645
|
+
logger,
|
|
646
|
+
systemId,
|
|
647
|
+
systemName,
|
|
648
|
+
configId,
|
|
649
|
+
configName,
|
|
650
|
+
environmentId,
|
|
651
|
+
environmentName,
|
|
652
|
+
status,
|
|
653
|
+
latencyMs,
|
|
654
|
+
result,
|
|
655
|
+
sourceId,
|
|
656
|
+
sourceLabel,
|
|
657
|
+
runTimestamp,
|
|
658
|
+
} = params;
|
|
659
|
+
|
|
660
|
+
const envEntityId = encodeHealthEntityId({ systemId, environmentId });
|
|
661
|
+
const serializeEnvWrite = createHealthEntitySerializer({ advisoryLock })(
|
|
662
|
+
envEntityId,
|
|
663
|
+
);
|
|
664
|
+
// An env-less run IS the system rollup, so it broadcasts the system-level
|
|
665
|
+
// signal directly; a fanned-out env run leaves the rollup to the debounced
|
|
666
|
+
// rollup consumer (driven by this write's ENTITY_CHANGED).
|
|
667
|
+
const isFannedOut = environmentId !== null;
|
|
668
|
+
|
|
669
|
+
let previousState!: AggregatedHealth;
|
|
670
|
+
let previousStatus!: SystemHealthStatus;
|
|
671
|
+
let newState!: AggregatedHealth;
|
|
672
|
+
await writeHealthEntity({
|
|
673
|
+
handle: getHealthEntity?.(),
|
|
674
|
+
entityId: envEntityId,
|
|
675
|
+
apply: async () => {
|
|
676
|
+
// In-lock pre-run baseline: read inside the serialized critical section,
|
|
677
|
+
// before the insert, so a concurrent same-slice run cannot commit between
|
|
678
|
+
// the baseline read and this insert and make the cache gate miss a change.
|
|
679
|
+
previousState = await service.getSystemHealthStatus(
|
|
680
|
+
systemId,
|
|
681
|
+
environmentId,
|
|
682
|
+
);
|
|
683
|
+
previousStatus = previousState.status;
|
|
684
|
+
// Batch the run INSERT + aggregate SELECT/UPSERT under ONE scoped
|
|
685
|
+
// transaction so they commit atomically.
|
|
686
|
+
await withScopedTransaction(db, async (tx) => {
|
|
687
|
+
await tx.insert(healthCheckRuns).values({
|
|
688
|
+
configurationId: configId,
|
|
689
|
+
systemId,
|
|
690
|
+
environmentId,
|
|
691
|
+
status,
|
|
692
|
+
latencyMs,
|
|
693
|
+
result,
|
|
694
|
+
sourceId,
|
|
695
|
+
sourceLabel,
|
|
696
|
+
});
|
|
697
|
+
await incrementHourlyAggregate({
|
|
698
|
+
db: tx,
|
|
699
|
+
systemId,
|
|
700
|
+
configurationId: configId,
|
|
701
|
+
environmentId,
|
|
702
|
+
status,
|
|
703
|
+
latencyMs,
|
|
704
|
+
runTimestamp,
|
|
705
|
+
result,
|
|
706
|
+
collectorRegistry,
|
|
707
|
+
sourceLabel,
|
|
708
|
+
});
|
|
709
|
+
});
|
|
710
|
+
newState = await service.getSystemHealthStatus(systemId, environmentId);
|
|
711
|
+
return toHealthEntityView(newState);
|
|
712
|
+
},
|
|
713
|
+
serialize: serializeEnvWrite,
|
|
714
|
+
onError: (error) =>
|
|
715
|
+
logger.warn(`Failed to mirror health entity for ${envEntityId}`, error),
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
logger.debug(
|
|
719
|
+
`Ran health check ${configId} for system ${systemId}: ${status}`,
|
|
720
|
+
);
|
|
721
|
+
|
|
722
|
+
await cache.reconcile({
|
|
723
|
+
systemId,
|
|
724
|
+
environmentId,
|
|
725
|
+
previous: previousState,
|
|
726
|
+
next: newState,
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
await signalService.broadcast(HEALTH_CHECK_RUN_COMPLETED, {
|
|
730
|
+
systemId,
|
|
731
|
+
systemName,
|
|
732
|
+
configurationId: configId,
|
|
733
|
+
// The realtime signal names the check; fall back to its id when the name
|
|
734
|
+
// could not be resolved (best-effort, as elsewhere).
|
|
735
|
+
configurationName: configName ?? configId,
|
|
736
|
+
status,
|
|
737
|
+
latencyMs,
|
|
738
|
+
environmentId: environmentId ?? undefined,
|
|
739
|
+
environmentName,
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
await emitCheckCompletedHook({
|
|
743
|
+
getEmitHook,
|
|
744
|
+
systemId,
|
|
745
|
+
configurationId: configId,
|
|
746
|
+
status,
|
|
747
|
+
latencyMs,
|
|
748
|
+
result:
|
|
749
|
+
(result.metadata as { collectors?: Record<string, unknown> } | undefined)
|
|
750
|
+
?.collectors ?? undefined,
|
|
751
|
+
environmentId,
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
// `newState.status` cannot be `unknown` here (a run just completed).
|
|
755
|
+
if (newState.status !== previousStatus && newState.status !== "unknown") {
|
|
756
|
+
await recordStateTransition({
|
|
757
|
+
db,
|
|
758
|
+
systemId,
|
|
759
|
+
configurationId: configId,
|
|
760
|
+
environmentId,
|
|
761
|
+
fromStatus: previousStatus === "unknown" ? undefined : previousStatus,
|
|
762
|
+
toStatus: newState.status,
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
await notifyStateChange({
|
|
766
|
+
notificationClient,
|
|
767
|
+
systemId,
|
|
768
|
+
systemName,
|
|
769
|
+
configurationId: configId,
|
|
770
|
+
configurationName: configName,
|
|
771
|
+
previousStatus: previousStatus === "unknown" ? "healthy" : previousStatus,
|
|
772
|
+
newStatus: newState.status,
|
|
773
|
+
environmentId,
|
|
774
|
+
environmentName,
|
|
775
|
+
service,
|
|
776
|
+
catalogClient,
|
|
777
|
+
maintenanceClient,
|
|
778
|
+
incidentClient,
|
|
779
|
+
logger,
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
if (!isFannedOut) {
|
|
783
|
+
await signalService.broadcast(SYSTEM_STATUS_CHANGED, {
|
|
784
|
+
systemId,
|
|
785
|
+
previousStatus,
|
|
786
|
+
newStatus: newState.status,
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* The per-run portion of {@link persistRunAndReact}: everything that varies per
|
|
794
|
+
* run, WITHOUT the service dependencies (which the plugin binds once via a
|
|
795
|
+
* closure). The plugin hands the router a reactor of this shape so a satellite
|
|
796
|
+
* result drives the exact same post-run path as a local run - the deps are
|
|
797
|
+
* captured once, so the two callers cannot pass a different set and drift.
|
|
798
|
+
*/
|
|
799
|
+
export type HealthRunReaction = Omit<
|
|
800
|
+
Parameters<typeof persistRunAndReact>[0],
|
|
801
|
+
| "db"
|
|
802
|
+
| "service"
|
|
803
|
+
| "cache"
|
|
804
|
+
| "signalService"
|
|
805
|
+
| "notificationClient"
|
|
806
|
+
| "catalogClient"
|
|
807
|
+
| "maintenanceClient"
|
|
808
|
+
| "incidentClient"
|
|
809
|
+
| "getHealthEntity"
|
|
810
|
+
| "getEmitHook"
|
|
811
|
+
| "collectorRegistry"
|
|
812
|
+
| "advisoryLock"
|
|
813
|
+
| "logger"
|
|
814
|
+
>;
|
|
815
|
+
|
|
615
816
|
/**
|
|
616
817
|
* Execute a health check job
|
|
617
818
|
*/
|
|
@@ -948,8 +1149,12 @@ async function executeHealthCheckJob(props: {
|
|
|
948
1149
|
// event-driven rollup consumer; an env-less run mutates the bare entity
|
|
949
1150
|
// (which IS the rollup) and so notifies + broadcasts SYSTEM_STATUS_CHANGED
|
|
950
1151
|
// directly.
|
|
951
|
-
const runEnvironments: (EffectiveEnvironment | null)[] = [
|
|
952
|
-
|
|
1152
|
+
const runEnvironments: (EffectiveEnvironment | null)[] = [
|
|
1153
|
+
singleEnvironment,
|
|
1154
|
+
];
|
|
1155
|
+
// Whether this run fans out to a concrete environment is now derived inside
|
|
1156
|
+
// `persistRunAndReact` (an env-less run IS the rollup); nothing in the loop
|
|
1157
|
+
// body needs it directly.
|
|
953
1158
|
for (const environment of runEnvironments) {
|
|
954
1159
|
const environmentId = environment?.id ?? null;
|
|
955
1160
|
// The env-qualified entity id this run mutates. For the env-less run
|
|
@@ -968,7 +1173,8 @@ async function executeHealthCheckJob(props: {
|
|
|
968
1173
|
// stale cached status until the TTL. Assigned by whichever branch's
|
|
969
1174
|
// `apply` runs; used for the transition log AND the cache reconcile.
|
|
970
1175
|
let previousState!: AggregatedHealth;
|
|
971
|
-
|
|
1176
|
+
// May be `unknown`: the pre-run baseline of a check that had never run.
|
|
1177
|
+
let previousStatus!: SystemHealthStatus;
|
|
972
1178
|
|
|
973
1179
|
// Curated, read-only run-context metadata exposed to collectors.
|
|
974
1180
|
// Metadata only - never secrets or config. `environment` carries the
|
|
@@ -992,91 +1198,44 @@ async function executeHealthCheckJob(props: {
|
|
|
992
1198
|
: {}),
|
|
993
1199
|
};
|
|
994
1200
|
|
|
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
|
-
};
|
|
1201
|
+
// An env-less run renders any {{ environment.* }} reference to empty
|
|
1202
|
+
// string (the engine's buildTemplateContext maps a missing environment to
|
|
1203
|
+
// {}). Log it once at debug so it is visible without spamming every tick.
|
|
1006
1204
|
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
1205
|
logger.debug(
|
|
1015
1206
|
`Health check ${configId} for system ${systemId} ran with no environment; ` +
|
|
1016
1207
|
`any {{ environment.* }} references render to empty string`,
|
|
1017
1208
|
);
|
|
1018
1209
|
}
|
|
1019
1210
|
|
|
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;
|
|
1211
|
+
// Per-environment isolation: an unexpected failure persisting ONE
|
|
1212
|
+
// environment's run must not abort the sibling environments' runs.
|
|
1213
|
+
// Each iteration's run is independent (§7.2), so we log and continue.
|
|
1214
|
+
try {
|
|
1215
|
+
// Execute through the SHARED engine (@checkstack/healthcheck-execution):
|
|
1216
|
+
// it renders the strategy + collector `x-templatable` fields against this
|
|
1217
|
+
// env/system's context, builds the transport client, runs the collectors,
|
|
1218
|
+
// and closes the client. This is the SAME engine the satellite uses, so
|
|
1219
|
+
// templating, secret/template ordering, and the per-collector fan-out
|
|
1220
|
+
// cannot drift between core and satellite - the drift that hid custom-
|
|
1221
|
+
// field templates on satellite runs. The core's own edges stay here as
|
|
1222
|
+
// hooks: DB-backed secret resolution, migrate-on-read, and the
|
|
1223
|
+
// assertion/ephemeral-strip post-processing.
|
|
1224
|
+
const outcome = await runHealthCheckCollection({
|
|
1225
|
+
strategy,
|
|
1226
|
+
strategyConfig,
|
|
1227
|
+
collectors: configRow.collectors ?? [],
|
|
1228
|
+
runContext,
|
|
1229
|
+
pluginId: configRow.strategyId,
|
|
1230
|
+
logger,
|
|
1231
|
+
timeoutMs: effectiveTimeout,
|
|
1232
|
+
hooks: {
|
|
1233
|
+
getCollector: (entry) =>
|
|
1234
|
+
collectorRegistry.getCollector(entry.collectorId),
|
|
1235
|
+
storageKeyOf: (entry) => entry.id,
|
|
1236
|
+
resolveSecretEnv: async (entry) => {
|
|
1078
1237
|
const declared = secretEnvMappingSchema.safeParse(
|
|
1079
|
-
(
|
|
1238
|
+
(entry.config as { secretEnv?: unknown }).secretEnv,
|
|
1080
1239
|
);
|
|
1081
1240
|
if (
|
|
1082
1241
|
secretResolver &&
|
|
@@ -1086,96 +1245,64 @@ async function executeHealthCheckJob(props: {
|
|
|
1086
1245
|
const resolved = await secretResolver.resolveForRun({
|
|
1087
1246
|
secretEnv: declared.data,
|
|
1088
1247
|
});
|
|
1089
|
-
|
|
1248
|
+
return resolved.env;
|
|
1090
1249
|
}
|
|
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;
|
|
1250
|
+
return;
|
|
1251
|
+
},
|
|
1252
|
+
prepareCollectorConfig: async (entry, registered) => {
|
|
1253
|
+
// Inflate secret markers (memory-only) then migrate-on-read, so the
|
|
1254
|
+
// engine templates + executes the migrated, secret-resolved shape.
|
|
1255
|
+
let rawCollectorConfig = entry.config;
|
|
1104
1256
|
if (internalSecrets && secretResolver) {
|
|
1105
1257
|
const inflated = await inflateConfigSecrets({
|
|
1106
1258
|
configurationId: configId,
|
|
1107
|
-
scope: {
|
|
1108
|
-
kind: "collector",
|
|
1109
|
-
entryId: collectorEntry.id,
|
|
1110
|
-
},
|
|
1259
|
+
scope: { kind: "collector", entryId: entry.id },
|
|
1111
1260
|
schema: registered.collector.config.schema,
|
|
1112
|
-
config:
|
|
1261
|
+
config: entry.config,
|
|
1113
1262
|
deps: { internalSecrets, secretResolver },
|
|
1114
1263
|
});
|
|
1115
1264
|
rawCollectorConfig = inflated.config;
|
|
1116
1265
|
}
|
|
1117
|
-
|
|
1266
|
+
// `parseAssumingV1` returns the collector's own (generic
|
|
1267
|
+
// `unknown`) config type; the engine templates it as a record, so
|
|
1268
|
+
// narrow to the object shape every collector config actually is.
|
|
1269
|
+
const parsed =
|
|
1118
1270
|
await registered.collector.config.parseAssumingV1(
|
|
1119
1271
|
rawCollectorConfig,
|
|
1120
1272
|
);
|
|
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.
|
|
1273
|
+
return parsed as Record<string, unknown>;
|
|
1274
|
+
},
|
|
1275
|
+
mapResult: ({ entry, registered, collectorResult }) => {
|
|
1276
|
+
const collectorError = collectorResult.error;
|
|
1149
1277
|
let assertionFailed: string | undefined;
|
|
1150
1278
|
let assertionOutcomes: AssertionOutcome[] = [];
|
|
1151
1279
|
if (collectorResult.result) {
|
|
1152
1280
|
const evaluation = evaluateCollectorAssertionOutcomes({
|
|
1153
|
-
assertions:
|
|
1281
|
+
assertions: entry.assertions,
|
|
1154
1282
|
result: collectorResult.result as Record<string, unknown>,
|
|
1155
1283
|
});
|
|
1156
1284
|
assertionFailed = evaluation.firstFailureMessage;
|
|
1157
1285
|
assertionOutcomes = evaluation.outcomes;
|
|
1158
1286
|
if (assertionFailed) {
|
|
1159
1287
|
logger.debug(
|
|
1160
|
-
`Collector ${
|
|
1288
|
+
`Collector ${entry.id} assertion failed: ${assertionFailed}`,
|
|
1161
1289
|
);
|
|
1162
1290
|
}
|
|
1163
1291
|
}
|
|
1164
|
-
|
|
1165
|
-
// Strip ephemeral fields before storage
|
|
1166
1292
|
const strippedResult = stripEphemeralFields(
|
|
1167
1293
|
collectorResult.result as Record<string, unknown>,
|
|
1168
1294
|
registered.collector.result.schema,
|
|
1169
1295
|
);
|
|
1170
|
-
|
|
1171
1296
|
return {
|
|
1172
|
-
storageKey,
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1297
|
+
storageKey: entry.id,
|
|
1298
|
+
success: !collectorError && !assertionFailed,
|
|
1299
|
+
error:
|
|
1300
|
+
collectorError ??
|
|
1301
|
+
(assertionFailed
|
|
1302
|
+
? `Assertion failed: ${assertionFailed}`
|
|
1303
|
+
: undefined),
|
|
1304
|
+
storedResult: {
|
|
1305
|
+
_collectorId: entry.collectorId,
|
|
1179
1306
|
_assertionFailed: assertionFailed,
|
|
1180
1307
|
_collectorError: collectorError,
|
|
1181
1308
|
...(assertionOutcomes.length > 0
|
|
@@ -1184,417 +1311,266 @@ async function executeHealthCheckJob(props: {
|
|
|
1184
1311
|
...strippedResult,
|
|
1185
1312
|
},
|
|
1186
1313
|
};
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
logger.debug(`Collector ${
|
|
1314
|
+
},
|
|
1315
|
+
mapError: ({ entry, error }) => {
|
|
1316
|
+
const errorStr = extractErrorMessage(error);
|
|
1317
|
+
logger.debug(`Collector ${entry.id} failed: ${errorStr}`);
|
|
1191
1318
|
return {
|
|
1192
|
-
storageKey,
|
|
1193
|
-
skipped: false,
|
|
1319
|
+
storageKey: entry.id,
|
|
1194
1320
|
success: false,
|
|
1195
1321
|
error: errorStr,
|
|
1196
|
-
|
|
1197
|
-
_collectorId:
|
|
1322
|
+
storedResult: {
|
|
1323
|
+
_collectorId: entry.collectorId,
|
|
1198
1324
|
_assertionFailed: undefined,
|
|
1199
1325
|
_collectorError: errorStr,
|
|
1200
1326
|
},
|
|
1201
1327
|
};
|
|
1202
|
-
}
|
|
1203
|
-
}
|
|
1328
|
+
},
|
|
1329
|
+
},
|
|
1330
|
+
});
|
|
1204
1331
|
|
|
1205
|
-
|
|
1206
|
-
|
|
1332
|
+
if (outcome.aborted) {
|
|
1333
|
+
// The transport itself failed: the client build threw, or the hard
|
|
1334
|
+
// timeout fired. This is a transport failure, distinct from a completed
|
|
1335
|
+
// run whose collectors reported problems, so it takes the failure
|
|
1336
|
+
// result shape and (deliberately, matching prior behaviour) skips the
|
|
1337
|
+
// checkCompleted hook + SYSTEM_STATUS_CHANGED signal the success path
|
|
1338
|
+
// emits.
|
|
1339
|
+
const finalError = outcome.errorMessage;
|
|
1340
|
+
|
|
1341
|
+
const result = {
|
|
1342
|
+
status: "unhealthy" as const,
|
|
1343
|
+
latencyMs: outcome.latencyMs,
|
|
1344
|
+
message: finalError,
|
|
1345
|
+
metadata: {
|
|
1346
|
+
connected: outcome.connected,
|
|
1347
|
+
error: finalError,
|
|
1348
|
+
},
|
|
1349
|
+
};
|
|
1350
|
+
// Persist the run + aggregate THROUGH the reactive `health` entity:
|
|
1351
|
+
// `apply` does the durable write and returns the freshly-computed view.
|
|
1352
|
+
// The framework snapshots `prev` via `read` BEFORE this insert, so a real
|
|
1353
|
+
// status change emits exactly one correct `ENTITY_CHANGED` (§10.3). The
|
|
1354
|
+
// computed aggregated state is stashed for the transition/notify path.
|
|
1355
|
+
let newState!: AggregatedHealth;
|
|
1356
|
+
await writeHealthEntity({
|
|
1357
|
+
handle: getHealthEntity?.(),
|
|
1358
|
+
entityId: envEntityId,
|
|
1359
|
+
apply: async () => {
|
|
1360
|
+
// In-lock pre-run baseline (see the `previousState` declaration): read
|
|
1361
|
+
// here, inside the serialized critical section, before the insert.
|
|
1362
|
+
previousState = await service.getSystemHealthStatus(
|
|
1363
|
+
systemId,
|
|
1364
|
+
environmentId,
|
|
1365
|
+
);
|
|
1366
|
+
previousStatus = previousState.status;
|
|
1367
|
+
// §perf: batch the run INSERT + aggregate SELECT/UPSERT under ONE
|
|
1368
|
+
// `SET LOCAL search_path` transaction (3 scoped-db transactions → 1),
|
|
1369
|
+
// which also makes the run and its aggregate commit atomically.
|
|
1370
|
+
await withScopedTransaction(db, async (tx) => {
|
|
1371
|
+
await tx.insert(healthCheckRuns).values({
|
|
1372
|
+
configurationId: configId,
|
|
1373
|
+
systemId,
|
|
1374
|
+
environmentId,
|
|
1375
|
+
status: result.status,
|
|
1376
|
+
latencyMs: result.latencyMs,
|
|
1377
|
+
result: { ...result } as Record<string, unknown>,
|
|
1378
|
+
sourceId: undefined,
|
|
1379
|
+
sourceLabel: "Local",
|
|
1380
|
+
});
|
|
1207
1381
|
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1382
|
+
await incrementHourlyAggregate({
|
|
1383
|
+
db: tx,
|
|
1384
|
+
systemId,
|
|
1385
|
+
configurationId: configId,
|
|
1386
|
+
environmentId,
|
|
1387
|
+
status: result.status,
|
|
1388
|
+
latencyMs: result.latencyMs,
|
|
1389
|
+
runTimestamp: new Date(),
|
|
1390
|
+
result: { ...result } as Record<string, unknown>,
|
|
1391
|
+
collectorRegistry,
|
|
1392
|
+
sourceLabel: "Local",
|
|
1393
|
+
});
|
|
1394
|
+
});
|
|
1216
1395
|
|
|
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`),
|
|
1396
|
+
// Env-scoped view: the per-env entity reflects only this env's runs.
|
|
1397
|
+
// Runs as its own batched read AFTER the write commits, so it sees
|
|
1398
|
+
// the just-inserted run.
|
|
1399
|
+
newState = await service.getSystemHealthStatus(
|
|
1400
|
+
systemId,
|
|
1401
|
+
environmentId,
|
|
1402
|
+
);
|
|
1403
|
+
return toHealthEntityView(newState);
|
|
1404
|
+
},
|
|
1405
|
+
serialize: serializeEnvWrite,
|
|
1406
|
+
onError: (error) =>
|
|
1407
|
+
logger.warn(
|
|
1408
|
+
`Failed to mirror health entity for ${envEntityId}`,
|
|
1409
|
+
error,
|
|
1246
1410
|
),
|
|
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
|
-
};
|
|
1411
|
+
});
|
|
1412
|
+
|
|
1413
|
+
logger.debug(
|
|
1414
|
+
`Health check ${configId} for system ${systemId} failed: ${finalError}`,
|
|
1415
|
+
);
|
|
1268
1416
|
|
|
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(
|
|
1417
|
+
// Reconcile this environment's cached status: evict + broadcast to the
|
|
1418
|
+
// cluster ONLY when the per-check vector actually changed (a run that
|
|
1419
|
+
// leaves every check's status unchanged keeps the cache warm instead of
|
|
1420
|
+
// thrashing it every tick). The rollup key is reconciled separately by
|
|
1421
|
+
// the debounced rollup consumer (recomputeSystemRollupHealth), also
|
|
1422
|
+
// vector-gated.
|
|
1423
|
+
await cache.reconcile({
|
|
1282
1424
|
systemId,
|
|
1283
1425
|
environmentId,
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1426
|
+
previous: previousState,
|
|
1427
|
+
next: newState,
|
|
1428
|
+
});
|
|
1429
|
+
|
|
1430
|
+
await signalService.broadcast(HEALTH_CHECK_RUN_COMPLETED, {
|
|
1431
|
+
systemId,
|
|
1432
|
+
systemName,
|
|
1433
|
+
configurationId: configId,
|
|
1434
|
+
configurationName: configRow.configName,
|
|
1435
|
+
status: result.status,
|
|
1436
|
+
latencyMs: result.latencyMs,
|
|
1437
|
+
// Env-scoped fan-out: `environment` is null for the env-less run, so
|
|
1438
|
+
// `?.` yields undefined and those runs broadcast exactly as before.
|
|
1439
|
+
environmentId: environment?.id,
|
|
1440
|
+
environmentName: environment?.name,
|
|
1441
|
+
});
|
|
1442
|
+
|
|
1443
|
+
// `newState.status` cannot be `unknown` here - a run just completed, so
|
|
1444
|
+
// the check has a measurement - but narrowing it keeps that guarantee
|
|
1445
|
+
// explicit rather than asserted with a cast.
|
|
1446
|
+
if (
|
|
1447
|
+
newState.status !== previousStatus &&
|
|
1448
|
+
newState.status !== "unknown"
|
|
1449
|
+
) {
|
|
1450
|
+
// Record the aggregate transition so the sensing layer has a
|
|
1451
|
+
// reliable "in status since" for every status (Wave 2).
|
|
1452
|
+
await recordStateTransition({
|
|
1453
|
+
db,
|
|
1292
1454
|
systemId,
|
|
1455
|
+
configurationId: configId,
|
|
1293
1456
|
environmentId,
|
|
1294
|
-
status
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1457
|
+
// NULL means "no prior measured status" - the column is nullable for
|
|
1458
|
+
// exactly this first-measurement case, so a system whose checks had
|
|
1459
|
+
// never run records an honest `null -> healthy` rather than
|
|
1460
|
+
// pretending it was healthy all along.
|
|
1461
|
+
fromStatus:
|
|
1462
|
+
previousStatus === "unknown" ? undefined : previousStatus,
|
|
1463
|
+
toStatus: newState.status,
|
|
1299
1464
|
});
|
|
1300
1465
|
|
|
1301
|
-
await
|
|
1302
|
-
|
|
1466
|
+
await notifyStateChange({
|
|
1467
|
+
notificationClient,
|
|
1303
1468
|
systemId,
|
|
1469
|
+
systemName,
|
|
1304
1470
|
configurationId: configId,
|
|
1471
|
+
configurationName: configRow.configName,
|
|
1472
|
+
// A first measurement is not a transition anyone asked to hear about
|
|
1473
|
+
// when it lands healthy; `notifyStateChange` decides, and it needs a
|
|
1474
|
+
// concrete previous status to compare against.
|
|
1475
|
+
previousStatus:
|
|
1476
|
+
previousStatus === "unknown" ? "healthy" : previousStatus,
|
|
1477
|
+
newStatus: newState.status,
|
|
1305
1478
|
environmentId,
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1479
|
+
environmentName: environment?.name,
|
|
1480
|
+
service,
|
|
1481
|
+
catalogClient,
|
|
1482
|
+
maintenanceClient,
|
|
1483
|
+
incidentClient,
|
|
1484
|
+
logger,
|
|
1312
1485
|
});
|
|
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
|
-
});
|
|
1486
|
+
}
|
|
1328
1487
|
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1488
|
+
// This environment's run is done (failed). Continue to the next
|
|
1489
|
+
// effective environment rather than ending the whole job.
|
|
1490
|
+
continue;
|
|
1491
|
+
}
|
|
1332
1492
|
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1493
|
+
// A COMPLETED run: the client built and the collectors ran. Its status is
|
|
1494
|
+
// decided by the collectors - a collector error or failed assertion
|
|
1495
|
+
// downgrades it - exactly as before.
|
|
1496
|
+
const status = outcome.hasCollectorError ? "unhealthy" : "healthy";
|
|
1497
|
+
const totalLatencyMs = outcome.latencyMs;
|
|
1498
|
+
|
|
1499
|
+
// Transport sub-phase timings measured AT THE PROBE and already filtered by
|
|
1500
|
+
// the engine to present phases. The satellite surfaces the same shape for
|
|
1501
|
+
// remote runs, so a run's `metadata.timings` is identical wherever it ran.
|
|
1502
|
+
const timings = outcome.clientTimings;
|
|
1503
|
+
|
|
1504
|
+
// Metrics (OTel no-ops unless enabled): the probe's total wall-clock and its
|
|
1505
|
+
// network sub-phases. The `phase` breakdown tells "slow target" (`wait`
|
|
1506
|
+
// grows) apart from "slow connection" (`connect`/`tls` grow) apart from
|
|
1507
|
+
// platform delay.
|
|
1508
|
+
healthcheckExecutionHistogram().record(totalLatencyMs, { status });
|
|
1509
|
+
if (timings) {
|
|
1510
|
+
for (const [phase, value] of Object.entries(timings)) {
|
|
1511
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
1512
|
+
healthcheckPhaseHistogram().record(value, {
|
|
1513
|
+
phase: phase.replace(/Ms$/, ""),
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1345
1518
|
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1519
|
+
const result = {
|
|
1520
|
+
status: status as "healthy" | "unhealthy",
|
|
1521
|
+
latencyMs: totalLatencyMs,
|
|
1522
|
+
message: outcome.hasCollectorError
|
|
1523
|
+
? `Check failed: ${outcome.errorMessage}`
|
|
1524
|
+
: `Completed in ${totalLatencyMs}ms`,
|
|
1525
|
+
metadata: {
|
|
1526
|
+
connected: true,
|
|
1527
|
+
connectionTimeMs: outcome.connectionTimeMs,
|
|
1528
|
+
...(timings ? { timings } : {}),
|
|
1529
|
+
collectors: outcome.collectorResults,
|
|
1530
|
+
},
|
|
1531
|
+
};
|
|
1358
1532
|
|
|
1359
|
-
|
|
1360
|
-
//
|
|
1361
|
-
//
|
|
1362
|
-
|
|
1533
|
+
// Persist this run and drive everything that reacts to it - the reactive
|
|
1534
|
+
// entity write, cache reconcile, realtime signal, automation hooks,
|
|
1535
|
+
// transition record, and subscriber notification - through the ONE
|
|
1536
|
+
// shared post-run path. Satellite-result ingest calls the same function,
|
|
1537
|
+
// so a satellite-detected change reacts identically and the two paths
|
|
1538
|
+
// cannot drift.
|
|
1539
|
+
await persistRunAndReact({
|
|
1363
1540
|
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
1541
|
service,
|
|
1542
|
+
cache,
|
|
1543
|
+
signalService,
|
|
1544
|
+
notificationClient,
|
|
1382
1545
|
catalogClient,
|
|
1383
1546
|
maintenanceClient,
|
|
1384
1547
|
incidentClient,
|
|
1548
|
+
getHealthEntity,
|
|
1549
|
+
getEmitHook,
|
|
1550
|
+
collectorRegistry,
|
|
1551
|
+
advisoryLock,
|
|
1385
1552
|
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
1553
|
systemId,
|
|
1554
|
+
systemName,
|
|
1555
|
+
configId,
|
|
1556
|
+
configName: configRow.configName,
|
|
1457
1557
|
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,
|
|
1558
|
+
environmentName: environment?.name,
|
|
1559
|
+
status: result.status,
|
|
1560
|
+
latencyMs: result.latencyMs,
|
|
1561
|
+
result: { ...result },
|
|
1562
|
+
sourceId: undefined,
|
|
1563
|
+
sourceLabel: "Local",
|
|
1564
|
+
runTimestamp: new Date(),
|
|
1581
1565
|
});
|
|
1566
|
+
} catch (envError) {
|
|
1567
|
+
// Isolate this environment's failure; continue with the next env.
|
|
1568
|
+
logger.error(
|
|
1569
|
+
`Failed to run health check ${configId} for system ${systemId}` +
|
|
1570
|
+
(environmentId ? ` (environment ${environmentId})` : " (env-less)"),
|
|
1571
|
+
envError,
|
|
1572
|
+
);
|
|
1582
1573
|
}
|
|
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
1574
|
} // end per-environment fan-out loop (for ... of runEnvironments)
|
|
1599
1575
|
|
|
1600
1576
|
// The system ROLLUP (bare `<systemId>` entity) for a fanned-out env-scoped
|
|
@@ -1624,7 +1600,8 @@ async function executeHealthCheckJob(props: {
|
|
|
1624
1600
|
// catastrophic tick for the same system can't commit between the baseline
|
|
1625
1601
|
// read and this insert and make the cache change-gate miss a transition.
|
|
1626
1602
|
let rollupPreState!: AggregatedHealth;
|
|
1627
|
-
|
|
1603
|
+
// May be `unknown`: the pre-run baseline of a check that had never run.
|
|
1604
|
+
let previousStatus!: SystemHealthStatus;
|
|
1628
1605
|
let newState!: AggregatedHealth;
|
|
1629
1606
|
await writeHealthEntity({
|
|
1630
1607
|
handle: getHealthEntity?.(),
|
|
@@ -1719,14 +1696,16 @@ async function executeHealthCheckJob(props: {
|
|
|
1719
1696
|
environmentId: null,
|
|
1720
1697
|
});
|
|
1721
1698
|
|
|
1722
|
-
|
|
1699
|
+
// `newState.status` cannot be `unknown` here (a run just completed).
|
|
1700
|
+
if (newState.status !== previousStatus && newState.status !== "unknown") {
|
|
1723
1701
|
// Record the aggregate transition so the sensing layer has a
|
|
1724
1702
|
// reliable "in status since" for every status (Wave 2).
|
|
1725
1703
|
await recordStateTransition({
|
|
1726
1704
|
db,
|
|
1727
1705
|
systemId,
|
|
1728
1706
|
configurationId: configId,
|
|
1729
|
-
|
|
1707
|
+
// `undefined` records NULL: no prior measured status.
|
|
1708
|
+
fromStatus: previousStatus === "unknown" ? undefined : previousStatus,
|
|
1730
1709
|
toStatus: newState.status,
|
|
1731
1710
|
});
|
|
1732
1711
|
|
|
@@ -1736,7 +1715,9 @@ async function executeHealthCheckJob(props: {
|
|
|
1736
1715
|
systemName,
|
|
1737
1716
|
configurationId: configId,
|
|
1738
1717
|
configurationName: configName,
|
|
1739
|
-
|
|
1718
|
+
// A first measurement has no previous status to compare against.
|
|
1719
|
+
previousStatus:
|
|
1720
|
+
previousStatus === "unknown" ? "healthy" : previousStatus,
|
|
1740
1721
|
newStatus: newState.status,
|
|
1741
1722
|
service,
|
|
1742
1723
|
catalogClient,
|
|
@@ -1855,4 +1836,3 @@ export async function setupHealthCheckWorker(props: {
|
|
|
1855
1836
|
|
|
1856
1837
|
logger.debug("🎯 Health Check Worker subscribed to queue");
|
|
1857
1838
|
}
|
|
1858
|
-
|