@checkstack/healthcheck-backend 1.21.2 → 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 +289 -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 +30 -31
- 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 +53 -14
- package/src/queue-executor.ts +554 -578
- package/src/realtime-aggregation.test.ts +10 -11
- package/src/realtime-aggregation.ts +12 -50
- 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,12 +27,19 @@ import {
|
|
|
31
27
|
SYSTEM_STATUS_CHANGED,
|
|
32
28
|
ENVIRONMENT_RESOLUTION_FAILED,
|
|
33
29
|
type HealthCheckStatus,
|
|
30
|
+
type SystemHealthStatus,
|
|
34
31
|
stripEphemeralFields,
|
|
32
|
+
HEALTH_CHECK_QUEUE,
|
|
33
|
+
type HealthCheckJobPayload,
|
|
35
34
|
} from "@checkstack/healthcheck-common";
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
// The run-queue contract (queue name + payload) now lives in the common leaf so
|
|
36
|
+
// every fast-path enqueuer shares one shape; re-exported from the owner here
|
|
37
|
+
// because this file's many internal importers reference these names.
|
|
38
|
+
export {
|
|
39
|
+
HEALTH_CHECK_QUEUE,
|
|
40
|
+
type HealthCheckJobPayload,
|
|
41
|
+
} from "@checkstack/healthcheck-common";
|
|
42
|
+
import { CatalogApi, type Environment } from "@checkstack/catalog-common";
|
|
40
43
|
import {
|
|
41
44
|
resolveEffectiveEnvironments,
|
|
42
45
|
type EffectiveEnvironment,
|
|
@@ -45,7 +48,7 @@ import { buildHealthTransitionNotification } from "./health-notification-content
|
|
|
45
48
|
import { MaintenanceApi } from "@checkstack/maintenance-common";
|
|
46
49
|
import { IncidentApi } from "@checkstack/incident-common";
|
|
47
50
|
import { NotificationApi } from "@checkstack/notification-common";
|
|
48
|
-
import { type InferClient, extractErrorMessage} from "@checkstack/common";
|
|
51
|
+
import { type InferClient, extractErrorMessage } from "@checkstack/common";
|
|
49
52
|
import { secretEnvMappingSchema } from "@checkstack/secrets-common";
|
|
50
53
|
import type {
|
|
51
54
|
SecretResolverService,
|
|
@@ -147,39 +150,6 @@ async function fetchRecentRunsForSlice(props: {
|
|
|
147
150
|
}));
|
|
148
151
|
}
|
|
149
152
|
|
|
150
|
-
/** The known transport timing phase keys, in transport order. */
|
|
151
|
-
const RUN_TIMING_KEYS = [
|
|
152
|
-
"dnsMs",
|
|
153
|
-
"connectMs",
|
|
154
|
-
"tlsMs",
|
|
155
|
-
"waitMs",
|
|
156
|
-
"transferMs",
|
|
157
|
-
"processingMs",
|
|
158
|
-
] as const;
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Build the run's `metadata.timings` from a connected client's surfaced
|
|
162
|
-
* transport timings, keeping only present, finite, non-negative phases. Returns
|
|
163
|
-
* `undefined` when no usable phase was measured so the field is omitted (old
|
|
164
|
-
* runs and single-phase strategies stay on the coarse fallback in the UI).
|
|
165
|
-
*/
|
|
166
|
-
function extractRunTimings(
|
|
167
|
-
connectedClient: ConnectedClient<TransportClient<never, unknown>> | undefined,
|
|
168
|
-
): RunTimings | undefined {
|
|
169
|
-
const raw: TransportTimings | undefined = connectedClient?.timings;
|
|
170
|
-
if (!raw) return undefined;
|
|
171
|
-
const result: RunTimings = {};
|
|
172
|
-
let any = false;
|
|
173
|
-
for (const key of RUN_TIMING_KEYS) {
|
|
174
|
-
const value = raw[key];
|
|
175
|
-
if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
|
|
176
|
-
result[key] = value;
|
|
177
|
-
any = true;
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
return any ? result : undefined;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
153
|
/**
|
|
184
154
|
* Emit the checkCompleted hook if available, plus the narrower
|
|
185
155
|
* `checkFailed` hook when the result wasn't `healthy` (so operators
|
|
@@ -244,12 +214,6 @@ async function emitCheckCompletedHook({
|
|
|
244
214
|
* The scheduling reconciler owns which (config, system, env) jobs exist; the
|
|
245
215
|
* `run_now` action enqueues one job per effective environment.
|
|
246
216
|
*/
|
|
247
|
-
export interface HealthCheckJobPayload {
|
|
248
|
-
configId: string;
|
|
249
|
-
systemId: string;
|
|
250
|
-
environmentId: string | null;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
217
|
/** Prefix every health-check recurring jobId shares (used for orphan scans). */
|
|
254
218
|
export const HEALTH_CHECK_JOB_PREFIX = "healthcheck:";
|
|
255
219
|
|
|
@@ -268,13 +232,6 @@ export function encodeHealthCheckJobId(props: {
|
|
|
268
232
|
return environmentId === null ? base : `${base}:${environmentId}`;
|
|
269
233
|
}
|
|
270
234
|
|
|
271
|
-
/**
|
|
272
|
-
* Queue name for health check execution. Exported so consumers like
|
|
273
|
-
* the `healthcheck.run_now` automation action can enqueue a one-off
|
|
274
|
-
* job without re-importing the recurring-job factory.
|
|
275
|
-
*/
|
|
276
|
-
export const HEALTH_CHECK_QUEUE = "health-checks";
|
|
277
|
-
|
|
278
235
|
/**
|
|
279
236
|
* Worker group for health check execution (work-queue mode)
|
|
280
237
|
*/
|
|
@@ -361,7 +318,10 @@ export async function recomputeSystemRollupHealth(args: {
|
|
|
361
318
|
*/
|
|
362
319
|
signalService?: SignalService;
|
|
363
320
|
cache?: HealthCheckCache;
|
|
364
|
-
}): Promise<
|
|
321
|
+
}): Promise<
|
|
322
|
+
| { previousStatus: SystemHealthStatus; newStatus: SystemHealthStatus }
|
|
323
|
+
| undefined
|
|
324
|
+
> {
|
|
365
325
|
const {
|
|
366
326
|
systemId,
|
|
367
327
|
service,
|
|
@@ -407,7 +367,11 @@ export async function recomputeSystemRollupHealth(args: {
|
|
|
407
367
|
// Cache: evict the rollup key + broadcast to the cluster on ANY per-check
|
|
408
368
|
// vector change — a check that flips while the rollup enum stays put still
|
|
409
369
|
// changes the rollup's `checkStatuses`, and a reader gets that vector.
|
|
410
|
-
await cache?.reconcile({
|
|
370
|
+
await cache?.reconcile({
|
|
371
|
+
systemId,
|
|
372
|
+
previous: previousState,
|
|
373
|
+
next: newState,
|
|
374
|
+
});
|
|
411
375
|
// Frontend signal: only a rollup-enum transition moves the badge, so a
|
|
412
376
|
// per-check-only change needs no SYSTEM_STATUS_CHANGED refetch signal.
|
|
413
377
|
if (newState.status !== previousState.status) {
|
|
@@ -616,6 +580,239 @@ async function notifyStateChange(props: {
|
|
|
616
580
|
}
|
|
617
581
|
}
|
|
618
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
|
+
|
|
619
816
|
/**
|
|
620
817
|
* Execute a health check job
|
|
621
818
|
*/
|
|
@@ -952,8 +1149,12 @@ async function executeHealthCheckJob(props: {
|
|
|
952
1149
|
// event-driven rollup consumer; an env-less run mutates the bare entity
|
|
953
1150
|
// (which IS the rollup) and so notifies + broadcasts SYSTEM_STATUS_CHANGED
|
|
954
1151
|
// directly.
|
|
955
|
-
const runEnvironments: (EffectiveEnvironment | null)[] = [
|
|
956
|
-
|
|
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.
|
|
957
1158
|
for (const environment of runEnvironments) {
|
|
958
1159
|
const environmentId = environment?.id ?? null;
|
|
959
1160
|
// The env-qualified entity id this run mutates. For the env-less run
|
|
@@ -972,7 +1173,8 @@ async function executeHealthCheckJob(props: {
|
|
|
972
1173
|
// stale cached status until the TTL. Assigned by whichever branch's
|
|
973
1174
|
// `apply` runs; used for the transition log AND the cache reconcile.
|
|
974
1175
|
let previousState!: AggregatedHealth;
|
|
975
|
-
|
|
1176
|
+
// May be `unknown`: the pre-run baseline of a check that had never run.
|
|
1177
|
+
let previousStatus!: SystemHealthStatus;
|
|
976
1178
|
|
|
977
1179
|
// Curated, read-only run-context metadata exposed to collectors.
|
|
978
1180
|
// Metadata only - never secrets or config. `environment` carries the
|
|
@@ -996,91 +1198,44 @@ async function executeHealthCheckJob(props: {
|
|
|
996
1198
|
: {}),
|
|
997
1199
|
};
|
|
998
1200
|
|
|
999
|
-
//
|
|
1000
|
-
//
|
|
1001
|
-
//
|
|
1002
|
-
// the resolved env's verbatim fields; an env-less run gets `{}` so a
|
|
1003
|
-
// reference renders to empty string (strict: false); see the debug log
|
|
1004
|
-
// below.
|
|
1005
|
-
const templateContext = {
|
|
1006
|
-
environment: runContext.environment?.fields ?? {},
|
|
1007
|
-
check: runContext.check,
|
|
1008
|
-
system: runContext.system,
|
|
1009
|
-
};
|
|
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.
|
|
1010
1204
|
if (!runContext.environment) {
|
|
1011
|
-
// §11.6: render-empty when a run has no environment. An env-less run is
|
|
1012
|
-
// a legitimate, documented configuration (the None assignment mode, or
|
|
1013
|
-
// All-environments with no membership), and it recurs every interval -
|
|
1014
|
-
// so this is `debug`, not `warn`, to avoid spamming the log. When an
|
|
1015
|
-
// empty `{{ environment.* }}` render actually matters, the HTTP
|
|
1016
|
-
// post-render `.url()` check already fails the run with a concrete
|
|
1017
|
-
// "Rendered URL is invalid" error; we do not inspect every field here.
|
|
1018
1205
|
logger.debug(
|
|
1019
1206
|
`Health check ${configId} for system ${systemId} ran with no environment; ` +
|
|
1020
1207
|
`any {{ environment.* }} references render to empty string`,
|
|
1021
1208
|
);
|
|
1022
1209
|
}
|
|
1023
1210
|
|
|
1024
|
-
//
|
|
1025
|
-
//
|
|
1026
|
-
//
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
try {
|
|
1052
|
-
// Platform-level hard timeout wrapping the entire execution sequence
|
|
1053
|
-
await Promise.race([
|
|
1054
|
-
(async () => {
|
|
1055
|
-
// 1. Establish connection. The strategy client build moves INSIDE
|
|
1056
|
-
// the per-env loop (§6.3.3): each env gets its own rendered config +
|
|
1057
|
-
// client, so a single job no longer bakes in one env's rendered
|
|
1058
|
-
// strategy config.
|
|
1059
|
-
connectedClient = await strategy.createClient(renderedStrategyConfig);
|
|
1060
|
-
connectionTimeMs = Math.round(performance.now() - start);
|
|
1061
|
-
|
|
1062
|
-
// 2. Execute collectors in parallel
|
|
1063
|
-
const collectorPromises = collectors.map(async (collectorEntry) => {
|
|
1064
|
-
const registered = collectorRegistry.getCollector(
|
|
1065
|
-
collectorEntry.collectorId,
|
|
1066
|
-
);
|
|
1067
|
-
if (!registered) {
|
|
1068
|
-
logger.warn(
|
|
1069
|
-
`Collector ${collectorEntry.collectorId} not found, skipping`,
|
|
1070
|
-
);
|
|
1071
|
-
return { storageKey: collectorEntry.id, skipped: true };
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
const storageKey = collectorEntry.id;
|
|
1075
|
-
|
|
1076
|
-
try {
|
|
1077
|
-
// Resolve the collector's declared secretEnv for THIS run
|
|
1078
|
-
// (central execution). The collector injects it and masks the
|
|
1079
|
-
// values out of its output. A missing required secret throws
|
|
1080
|
-
// and fails the collector clearly.
|
|
1081
|
-
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) => {
|
|
1082
1237
|
const declared = secretEnvMappingSchema.safeParse(
|
|
1083
|
-
(
|
|
1238
|
+
(entry.config as { secretEnv?: unknown }).secretEnv,
|
|
1084
1239
|
);
|
|
1085
1240
|
if (
|
|
1086
1241
|
secretResolver &&
|
|
@@ -1090,96 +1245,64 @@ async function executeHealthCheckJob(props: {
|
|
|
1090
1245
|
const resolved = await secretResolver.resolveForRun({
|
|
1091
1246
|
secretEnv: declared.data,
|
|
1092
1247
|
});
|
|
1093
|
-
|
|
1248
|
+
return resolved.env;
|
|
1094
1249
|
}
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
//
|
|
1099
|
-
//
|
|
1100
|
-
|
|
1101
|
-
// reads the raw `secretEnv` mapping (a constant string field
|
|
1102
|
-
// unaffected by the strategy/collector reshapes), keeping the
|
|
1103
|
-
// migrate -> secret resolve -> render -> execute order intact.
|
|
1104
|
-
// Inflate this entry's secret markers / references (memory
|
|
1105
|
-
// only) before its migrate+validate parse, mirroring the
|
|
1106
|
-
// strategy-config inflation above.
|
|
1107
|
-
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;
|
|
1108
1256
|
if (internalSecrets && secretResolver) {
|
|
1109
1257
|
const inflated = await inflateConfigSecrets({
|
|
1110
1258
|
configurationId: configId,
|
|
1111
|
-
scope: {
|
|
1112
|
-
kind: "collector",
|
|
1113
|
-
entryId: collectorEntry.id,
|
|
1114
|
-
},
|
|
1259
|
+
scope: { kind: "collector", entryId: entry.id },
|
|
1115
1260
|
schema: registered.collector.config.schema,
|
|
1116
|
-
config:
|
|
1261
|
+
config: entry.config,
|
|
1117
1262
|
deps: { internalSecrets, secretResolver },
|
|
1118
1263
|
});
|
|
1119
1264
|
rawCollectorConfig = inflated.config;
|
|
1120
1265
|
}
|
|
1121
|
-
|
|
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 =
|
|
1122
1270
|
await registered.collector.config.parseAssumingV1(
|
|
1123
1271
|
rawCollectorConfig,
|
|
1124
1272
|
);
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
// collector's `x-templatable` fields against the per-env context.
|
|
1130
|
-
const renderedCollectorConfig = renderTemplatableConfig({
|
|
1131
|
-
config: migratedCollectorConfig,
|
|
1132
|
-
schema: registered.collector.config.schema,
|
|
1133
|
-
context: templateContext,
|
|
1134
|
-
});
|
|
1135
|
-
|
|
1136
|
-
const collectorResult = await registered.collector.execute({
|
|
1137
|
-
config: renderedCollectorConfig,
|
|
1138
|
-
client: connectedClient!.client,
|
|
1139
|
-
pluginId: configRow.strategyId,
|
|
1140
|
-
runContext,
|
|
1141
|
-
...(secretEnv ? { secretEnv } : {}),
|
|
1142
|
-
});
|
|
1143
|
-
|
|
1144
|
-
// Check for collector-level error
|
|
1145
|
-
let collectorError: string | undefined;
|
|
1146
|
-
if (collectorResult.error) {
|
|
1147
|
-
collectorError = collectorResult.error;
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
|
-
// Evaluate per-collector assertions (plain fields + JSONPath).
|
|
1151
|
-
// ALL outcomes are stored (pass AND fail) so assertions are
|
|
1152
|
-
// 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;
|
|
1153
1277
|
let assertionFailed: string | undefined;
|
|
1154
1278
|
let assertionOutcomes: AssertionOutcome[] = [];
|
|
1155
1279
|
if (collectorResult.result) {
|
|
1156
1280
|
const evaluation = evaluateCollectorAssertionOutcomes({
|
|
1157
|
-
assertions:
|
|
1281
|
+
assertions: entry.assertions,
|
|
1158
1282
|
result: collectorResult.result as Record<string, unknown>,
|
|
1159
1283
|
});
|
|
1160
1284
|
assertionFailed = evaluation.firstFailureMessage;
|
|
1161
1285
|
assertionOutcomes = evaluation.outcomes;
|
|
1162
1286
|
if (assertionFailed) {
|
|
1163
1287
|
logger.debug(
|
|
1164
|
-
`Collector ${
|
|
1288
|
+
`Collector ${entry.id} assertion failed: ${assertionFailed}`,
|
|
1165
1289
|
);
|
|
1166
1290
|
}
|
|
1167
1291
|
}
|
|
1168
|
-
|
|
1169
|
-
// Strip ephemeral fields before storage
|
|
1170
1292
|
const strippedResult = stripEphemeralFields(
|
|
1171
1293
|
collectorResult.result as Record<string, unknown>,
|
|
1172
1294
|
registered.collector.result.schema,
|
|
1173
1295
|
);
|
|
1174
|
-
|
|
1175
1296
|
return {
|
|
1176
|
-
storageKey,
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
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,
|
|
1183
1306
|
_assertionFailed: assertionFailed,
|
|
1184
1307
|
_collectorError: collectorError,
|
|
1185
1308
|
...(assertionOutcomes.length > 0
|
|
@@ -1188,417 +1311,266 @@ async function executeHealthCheckJob(props: {
|
|
|
1188
1311
|
...strippedResult,
|
|
1189
1312
|
},
|
|
1190
1313
|
};
|
|
1191
|
-
}
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
logger.debug(`Collector ${
|
|
1314
|
+
},
|
|
1315
|
+
mapError: ({ entry, error }) => {
|
|
1316
|
+
const errorStr = extractErrorMessage(error);
|
|
1317
|
+
logger.debug(`Collector ${entry.id} failed: ${errorStr}`);
|
|
1195
1318
|
return {
|
|
1196
|
-
storageKey,
|
|
1197
|
-
skipped: false,
|
|
1319
|
+
storageKey: entry.id,
|
|
1198
1320
|
success: false,
|
|
1199
1321
|
error: errorStr,
|
|
1200
|
-
|
|
1201
|
-
_collectorId:
|
|
1322
|
+
storedResult: {
|
|
1323
|
+
_collectorId: entry.collectorId,
|
|
1202
1324
|
_assertionFailed: undefined,
|
|
1203
1325
|
_collectorError: errorStr,
|
|
1204
1326
|
},
|
|
1205
1327
|
};
|
|
1206
|
-
}
|
|
1207
|
-
}
|
|
1328
|
+
},
|
|
1329
|
+
},
|
|
1330
|
+
});
|
|
1208
1331
|
|
|
1209
|
-
|
|
1210
|
-
|
|
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
|
+
});
|
|
1211
1381
|
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
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
|
+
});
|
|
1220
1395
|
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
errorMessage =
|
|
1236
|
-
result.error ||
|
|
1237
|
-
result.collectorError ||
|
|
1238
|
-
(result.assertionFailed
|
|
1239
|
-
? `Assertion failed: ${result.assertionFailed}`
|
|
1240
|
-
: undefined);
|
|
1241
|
-
}
|
|
1242
|
-
}
|
|
1243
|
-
}
|
|
1244
|
-
})(),
|
|
1245
|
-
new Promise<never>((_, reject) =>
|
|
1246
|
-
setTimeout(
|
|
1247
|
-
() =>
|
|
1248
|
-
reject(
|
|
1249
|
-
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,
|
|
1250
1410
|
),
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
const latencyMs = Math.round(performance.now() - start);
|
|
1257
|
-
const caughtError =
|
|
1258
|
-
extractErrorMessage(error);
|
|
1259
|
-
|
|
1260
|
-
// Use a specific error message if available, otherwise use the caught error
|
|
1261
|
-
const finalError = errorMessage || caughtError;
|
|
1262
|
-
|
|
1263
|
-
const result = {
|
|
1264
|
-
status: "unhealthy" as const,
|
|
1265
|
-
latencyMs,
|
|
1266
|
-
message: finalError,
|
|
1267
|
-
metadata: {
|
|
1268
|
-
connected: !!connectedClient,
|
|
1269
|
-
error: finalError,
|
|
1270
|
-
},
|
|
1271
|
-
};
|
|
1411
|
+
});
|
|
1412
|
+
|
|
1413
|
+
logger.debug(
|
|
1414
|
+
`Health check ${configId} for system ${systemId} failed: ${finalError}`,
|
|
1415
|
+
);
|
|
1272
1416
|
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
handle: getHealthEntity?.(),
|
|
1281
|
-
entityId: envEntityId,
|
|
1282
|
-
apply: async () => {
|
|
1283
|
-
// In-lock pre-run baseline (see the `previousState` declaration): read
|
|
1284
|
-
// here, inside the serialized critical section, before the insert.
|
|
1285
|
-
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({
|
|
1286
1424
|
systemId,
|
|
1287
1425
|
environmentId,
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
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,
|
|
1296
1454
|
systemId,
|
|
1455
|
+
configurationId: configId,
|
|
1297
1456
|
environmentId,
|
|
1298
|
-
status
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
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,
|
|
1303
1464
|
});
|
|
1304
1465
|
|
|
1305
|
-
await
|
|
1306
|
-
|
|
1466
|
+
await notifyStateChange({
|
|
1467
|
+
notificationClient,
|
|
1307
1468
|
systemId,
|
|
1469
|
+
systemName,
|
|
1308
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,
|
|
1309
1478
|
environmentId,
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1479
|
+
environmentName: environment?.name,
|
|
1480
|
+
service,
|
|
1481
|
+
catalogClient,
|
|
1482
|
+
maintenanceClient,
|
|
1483
|
+
incidentClient,
|
|
1484
|
+
logger,
|
|
1316
1485
|
});
|
|
1317
|
-
}
|
|
1318
|
-
|
|
1319
|
-
// Env-scoped view: the per-env entity reflects only this env's runs.
|
|
1320
|
-
// Runs as its own batched read AFTER the write commits, so it sees
|
|
1321
|
-
// the just-inserted run.
|
|
1322
|
-
newState = await service.getSystemHealthStatus(systemId, environmentId);
|
|
1323
|
-
return toHealthEntityView(newState);
|
|
1324
|
-
},
|
|
1325
|
-
serialize: serializeEnvWrite,
|
|
1326
|
-
onError: (error) =>
|
|
1327
|
-
logger.warn(
|
|
1328
|
-
`Failed to mirror health entity for ${envEntityId}`,
|
|
1329
|
-
error,
|
|
1330
|
-
),
|
|
1331
|
-
});
|
|
1486
|
+
}
|
|
1332
1487
|
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1488
|
+
// This environment's run is done (failed). Continue to the next
|
|
1489
|
+
// effective environment rather than ending the whole job.
|
|
1490
|
+
continue;
|
|
1491
|
+
}
|
|
1336
1492
|
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
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
|
+
}
|
|
1349
1518
|
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
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
|
+
};
|
|
1362
1532
|
|
|
1363
|
-
|
|
1364
|
-
//
|
|
1365
|
-
//
|
|
1366
|
-
|
|
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({
|
|
1367
1540
|
db,
|
|
1368
|
-
systemId,
|
|
1369
|
-
configurationId: configId,
|
|
1370
|
-
environmentId,
|
|
1371
|
-
fromStatus: previousStatus,
|
|
1372
|
-
toStatus: newState.status,
|
|
1373
|
-
});
|
|
1374
|
-
|
|
1375
|
-
await notifyStateChange({
|
|
1376
|
-
notificationClient,
|
|
1377
|
-
systemId,
|
|
1378
|
-
systemName,
|
|
1379
|
-
configurationId: configId,
|
|
1380
|
-
configurationName: configRow.configName,
|
|
1381
|
-
previousStatus,
|
|
1382
|
-
newStatus: newState.status,
|
|
1383
|
-
environmentId,
|
|
1384
|
-
environmentName: environment?.name,
|
|
1385
1541
|
service,
|
|
1542
|
+
cache,
|
|
1543
|
+
signalService,
|
|
1544
|
+
notificationClient,
|
|
1386
1545
|
catalogClient,
|
|
1387
1546
|
maintenanceClient,
|
|
1388
1547
|
incidentClient,
|
|
1548
|
+
getHealthEntity,
|
|
1549
|
+
getEmitHook,
|
|
1550
|
+
collectorRegistry,
|
|
1551
|
+
advisoryLock,
|
|
1389
1552
|
logger,
|
|
1390
|
-
});
|
|
1391
|
-
}
|
|
1392
|
-
|
|
1393
|
-
// This environment's run is done (failed). Continue to the next
|
|
1394
|
-
// effective environment rather than ending the whole job.
|
|
1395
|
-
continue;
|
|
1396
|
-
} finally {
|
|
1397
|
-
if (connectedClient) {
|
|
1398
|
-
try {
|
|
1399
|
-
connectedClient.close();
|
|
1400
|
-
} catch (error) {
|
|
1401
|
-
logger.warn(`Failed to close connection: ${error}`);
|
|
1402
|
-
}
|
|
1403
|
-
}
|
|
1404
|
-
}
|
|
1405
|
-
|
|
1406
|
-
// Determine health status based on collector results
|
|
1407
|
-
const status = hasCollectorError ? "unhealthy" : "healthy";
|
|
1408
|
-
const totalLatencyMs = Math.round(performance.now() - start);
|
|
1409
|
-
|
|
1410
|
-
// Lift the strategy's structured transport timings (DNS / connect / TLS /
|
|
1411
|
-
// wait / transfer / processing) into the run metadata when the connected
|
|
1412
|
-
// client surfaced any. Strategies that cannot measure sub-phases leave this
|
|
1413
|
-
// undefined and the frontend falls back to the coarse connection split.
|
|
1414
|
-
const timings = extractRunTimings(connectedClient);
|
|
1415
|
-
|
|
1416
|
-
// Metrics (OTel no-ops unless enabled): the probe's total wall-clock and its
|
|
1417
|
-
// network sub-phases. The `phase` breakdown is what tells "slow target"
|
|
1418
|
-
// (`wait` grows) apart from "slow connection establishment" (`connect`/`tls`
|
|
1419
|
-
// grow under a same-host stampede) apart from platform delay.
|
|
1420
|
-
healthcheckExecutionHistogram().record(totalLatencyMs, { status });
|
|
1421
|
-
if (timings) {
|
|
1422
|
-
for (const [phase, value] of Object.entries(timings)) {
|
|
1423
|
-
if (typeof value === "number" && Number.isFinite(value)) {
|
|
1424
|
-
healthcheckPhaseHistogram().record(value, {
|
|
1425
|
-
phase: phase.replace(/Ms$/, ""),
|
|
1426
|
-
});
|
|
1427
|
-
}
|
|
1428
|
-
}
|
|
1429
|
-
}
|
|
1430
|
-
|
|
1431
|
-
const result = {
|
|
1432
|
-
status: status as "healthy" | "unhealthy",
|
|
1433
|
-
latencyMs: totalLatencyMs,
|
|
1434
|
-
message: hasCollectorError
|
|
1435
|
-
? `Check failed: ${errorMessage}`
|
|
1436
|
-
: `Completed in ${totalLatencyMs}ms`,
|
|
1437
|
-
metadata: {
|
|
1438
|
-
connected: true,
|
|
1439
|
-
connectionTimeMs,
|
|
1440
|
-
...(timings ? { timings } : {}),
|
|
1441
|
-
collectors: collectorResults,
|
|
1442
|
-
},
|
|
1443
|
-
};
|
|
1444
|
-
|
|
1445
|
-
// Persist the run + aggregate THROUGH the reactive `health` entity on
|
|
1446
|
-
// every run (§10.3): `apply` does the durable write (insert + hourly
|
|
1447
|
-
// aggregate) and returns the freshly-computed view. The framework
|
|
1448
|
-
// snapshots `prev` via the COMPUTE-ON-READ accessor BEFORE this insert, so
|
|
1449
|
-
// an unchanged aggregate is a no-op and a real status change drives the
|
|
1450
|
-
// directional/umbrella trigger events via `deriveHealthTriggerEvents` —
|
|
1451
|
-
// exactly one correct `ENTITY_CHANGED` with accurate prev → next.
|
|
1452
|
-
let newState!: AggregatedHealth;
|
|
1453
|
-
await writeHealthEntity({
|
|
1454
|
-
handle: getHealthEntity?.(),
|
|
1455
|
-
entityId: envEntityId,
|
|
1456
|
-
apply: async () => {
|
|
1457
|
-
// In-lock pre-run baseline (see the `previousState` declaration): read
|
|
1458
|
-
// here, inside the serialized critical section, before the insert.
|
|
1459
|
-
previousState = await service.getSystemHealthStatus(
|
|
1460
1553
|
systemId,
|
|
1554
|
+
systemName,
|
|
1555
|
+
configId,
|
|
1556
|
+
configName: configRow.configName,
|
|
1461
1557
|
environmentId,
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
await tx.insert(healthCheckRuns).values({
|
|
1470
|
-
configurationId: configId,
|
|
1471
|
-
systemId,
|
|
1472
|
-
environmentId,
|
|
1473
|
-
status: result.status,
|
|
1474
|
-
latencyMs: result.latencyMs,
|
|
1475
|
-
result: { ...result } as Record<string, unknown>,
|
|
1476
|
-
sourceId: undefined,
|
|
1477
|
-
sourceLabel: "Local",
|
|
1478
|
-
});
|
|
1479
|
-
|
|
1480
|
-
// Trigger incremental hourly aggregation
|
|
1481
|
-
await incrementHourlyAggregate({
|
|
1482
|
-
db: tx,
|
|
1483
|
-
systemId,
|
|
1484
|
-
configurationId: configId,
|
|
1485
|
-
environmentId,
|
|
1486
|
-
status: result.status,
|
|
1487
|
-
latencyMs: result.latencyMs,
|
|
1488
|
-
runTimestamp: new Date(),
|
|
1489
|
-
result: { ...result } as Record<string, unknown>,
|
|
1490
|
-
collectorRegistry,
|
|
1491
|
-
sourceLabel: "Local",
|
|
1492
|
-
});
|
|
1493
|
-
});
|
|
1494
|
-
|
|
1495
|
-
// Env-scoped view: the per-env entity reflects only this env's runs.
|
|
1496
|
-
// Runs as its own batched read AFTER the write commits, so it sees the
|
|
1497
|
-
// just-inserted run.
|
|
1498
|
-
newState = await service.getSystemHealthStatus(systemId, environmentId);
|
|
1499
|
-
return toHealthEntityView(newState);
|
|
1500
|
-
},
|
|
1501
|
-
serialize: serializeEnvWrite,
|
|
1502
|
-
onError: (error) =>
|
|
1503
|
-
logger.warn(`Failed to mirror health entity for ${envEntityId}`, error),
|
|
1504
|
-
});
|
|
1505
|
-
|
|
1506
|
-
logger.debug(
|
|
1507
|
-
`Ran health check ${configId} for system ${systemId}: ${result.status}`,
|
|
1508
|
-
);
|
|
1509
|
-
|
|
1510
|
-
// Reconcile this environment's cached status: evict + broadcast to the
|
|
1511
|
-
// cluster ONLY when the per-check vector actually changed (a steady-state
|
|
1512
|
-
// healthy run keeps the cache warm). The rollup key is reconciled by the
|
|
1513
|
-
// debounced rollup consumer (recomputeSystemRollupHealth), also vector-gated.
|
|
1514
|
-
await cache.reconcile({
|
|
1515
|
-
systemId,
|
|
1516
|
-
environmentId,
|
|
1517
|
-
previous: previousState,
|
|
1518
|
-
next: newState,
|
|
1519
|
-
});
|
|
1520
|
-
|
|
1521
|
-
// Broadcast enriched signal for realtime frontend updates (e.g., terminal feed)
|
|
1522
|
-
await signalService.broadcast(HEALTH_CHECK_RUN_COMPLETED, {
|
|
1523
|
-
systemId,
|
|
1524
|
-
systemName,
|
|
1525
|
-
configurationId: configId,
|
|
1526
|
-
configurationName: configRow.configName,
|
|
1527
|
-
status: result.status,
|
|
1528
|
-
latencyMs: result.latencyMs,
|
|
1529
|
-
// Env-scoped fan-out: `environment` is null for the env-less run, so
|
|
1530
|
-
// `?.` yields undefined and those runs broadcast exactly as before.
|
|
1531
|
-
environmentId: environment?.id,
|
|
1532
|
-
environmentName: environment?.name,
|
|
1533
|
-
});
|
|
1534
|
-
|
|
1535
|
-
await emitCheckCompletedHook({
|
|
1536
|
-
getEmitHook,
|
|
1537
|
-
systemId,
|
|
1538
|
-
configurationId: configId,
|
|
1539
|
-
status: result.status,
|
|
1540
|
-
latencyMs: result.latencyMs,
|
|
1541
|
-
result: (result.metadata?.collectors as Record<string, unknown>) ?? undefined,
|
|
1542
|
-
environmentId,
|
|
1543
|
-
});
|
|
1544
|
-
|
|
1545
|
-
if (newState.status !== previousStatus) {
|
|
1546
|
-
// Record the aggregate transition so the sensing layer has a
|
|
1547
|
-
// reliable "in status since" for every status (Wave 2).
|
|
1548
|
-
await recordStateTransition({
|
|
1549
|
-
db,
|
|
1550
|
-
systemId,
|
|
1551
|
-
configurationId: configId,
|
|
1552
|
-
environmentId,
|
|
1553
|
-
fromStatus: previousStatus,
|
|
1554
|
-
toStatus: newState.status,
|
|
1555
|
-
});
|
|
1556
|
-
|
|
1557
|
-
await notifyStateChange({
|
|
1558
|
-
notificationClient,
|
|
1559
|
-
systemId,
|
|
1560
|
-
systemName,
|
|
1561
|
-
configurationId: configId,
|
|
1562
|
-
configurationName: configRow.configName,
|
|
1563
|
-
previousStatus,
|
|
1564
|
-
newStatus: newState.status,
|
|
1565
|
-
environmentId,
|
|
1566
|
-
environmentName: environment?.name,
|
|
1567
|
-
service,
|
|
1568
|
-
catalogClient,
|
|
1569
|
-
maintenanceClient,
|
|
1570
|
-
incidentClient,
|
|
1571
|
-
logger,
|
|
1572
|
-
});
|
|
1573
|
-
|
|
1574
|
-
// The system-level `SYSTEM_STATUS_CHANGED` signal must carry the ROLLUP
|
|
1575
|
-
// status, not a per-env status. When fanned out, the post-loop rollup
|
|
1576
|
-
// write broadcasts it once with the worst-status rollup; emitting it here
|
|
1577
|
-
// per env would send up to N system-level signals/tick carrying per-env
|
|
1578
|
-
// status. Only the env-less run (which IS the rollup — `!isFannedOut`)
|
|
1579
|
-
// broadcasts the system-level signal from inside the loop.
|
|
1580
|
-
if (!isFannedOut) {
|
|
1581
|
-
await signalService.broadcast(SYSTEM_STATUS_CHANGED, {
|
|
1582
|
-
systemId,
|
|
1583
|
-
previousStatus: previousStatus as HealthCheckStatus,
|
|
1584
|
-
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(),
|
|
1585
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
|
+
);
|
|
1586
1573
|
}
|
|
1587
|
-
|
|
1588
|
-
// The directional + umbrella system-health hooks were removed in
|
|
1589
|
-
// Phase 4 (§10.3): the `health` entity mirror above is the single
|
|
1590
|
-
// source of truth, and its change deriver fires the
|
|
1591
|
-
// `healthcheck.system_degraded` / `_healthy` / `_health_changed`
|
|
1592
|
-
// trigger events through Stage-1 routing. Nothing to emit here.
|
|
1593
|
-
}
|
|
1594
|
-
} catch (envError) {
|
|
1595
|
-
// Isolate this environment's failure; continue with the next env.
|
|
1596
|
-
logger.error(
|
|
1597
|
-
`Failed to run health check ${configId} for system ${systemId}` +
|
|
1598
|
-
(environmentId ? ` (environment ${environmentId})` : " (env-less)"),
|
|
1599
|
-
envError,
|
|
1600
|
-
);
|
|
1601
|
-
}
|
|
1602
1574
|
} // end per-environment fan-out loop (for ... of runEnvironments)
|
|
1603
1575
|
|
|
1604
1576
|
// The system ROLLUP (bare `<systemId>` entity) for a fanned-out env-scoped
|
|
@@ -1628,7 +1600,8 @@ async function executeHealthCheckJob(props: {
|
|
|
1628
1600
|
// catastrophic tick for the same system can't commit between the baseline
|
|
1629
1601
|
// read and this insert and make the cache change-gate miss a transition.
|
|
1630
1602
|
let rollupPreState!: AggregatedHealth;
|
|
1631
|
-
|
|
1603
|
+
// May be `unknown`: the pre-run baseline of a check that had never run.
|
|
1604
|
+
let previousStatus!: SystemHealthStatus;
|
|
1632
1605
|
let newState!: AggregatedHealth;
|
|
1633
1606
|
await writeHealthEntity({
|
|
1634
1607
|
handle: getHealthEntity?.(),
|
|
@@ -1723,14 +1696,16 @@ async function executeHealthCheckJob(props: {
|
|
|
1723
1696
|
environmentId: null,
|
|
1724
1697
|
});
|
|
1725
1698
|
|
|
1726
|
-
|
|
1699
|
+
// `newState.status` cannot be `unknown` here (a run just completed).
|
|
1700
|
+
if (newState.status !== previousStatus && newState.status !== "unknown") {
|
|
1727
1701
|
// Record the aggregate transition so the sensing layer has a
|
|
1728
1702
|
// reliable "in status since" for every status (Wave 2).
|
|
1729
1703
|
await recordStateTransition({
|
|
1730
1704
|
db,
|
|
1731
1705
|
systemId,
|
|
1732
1706
|
configurationId: configId,
|
|
1733
|
-
|
|
1707
|
+
// `undefined` records NULL: no prior measured status.
|
|
1708
|
+
fromStatus: previousStatus === "unknown" ? undefined : previousStatus,
|
|
1734
1709
|
toStatus: newState.status,
|
|
1735
1710
|
});
|
|
1736
1711
|
|
|
@@ -1740,7 +1715,9 @@ async function executeHealthCheckJob(props: {
|
|
|
1740
1715
|
systemName,
|
|
1741
1716
|
configurationId: configId,
|
|
1742
1717
|
configurationName: configName,
|
|
1743
|
-
|
|
1718
|
+
// A first measurement has no previous status to compare against.
|
|
1719
|
+
previousStatus:
|
|
1720
|
+
previousStatus === "unknown" ? "healthy" : previousStatus,
|
|
1744
1721
|
newStatus: newState.status,
|
|
1745
1722
|
service,
|
|
1746
1723
|
catalogClient,
|
|
@@ -1859,4 +1836,3 @@ export async function setupHealthCheckWorker(props: {
|
|
|
1859
1836
|
|
|
1860
1837
|
logger.debug("🎯 Health Check Worker subscribed to queue");
|
|
1861
1838
|
}
|
|
1862
|
-
|