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