@checkstack/healthcheck-backend 1.11.0 → 1.12.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 +281 -0
- package/package.json +29 -29
- package/src/automations.test.ts +43 -1
- package/src/automations.ts +22 -3
- package/src/hooks.ts +7 -0
- package/src/index.ts +21 -4
- package/src/queue-executor.test.ts +108 -16
- package/src/queue-executor.ts +116 -9
- package/src/router-pause-recompute.test.ts +142 -0
- package/src/router.ts +47 -0
- package/src/service-env-filter.test.ts +299 -0
- package/src/service-paused-filter.test.ts +391 -0
- package/src/service-rollup-worst-wins.test.ts +205 -0
- package/src/service.ts +274 -28
package/src/service.ts
CHANGED
|
@@ -531,6 +531,33 @@ export class HealthCheckService {
|
|
|
531
531
|
return results;
|
|
532
532
|
}
|
|
533
533
|
|
|
534
|
+
/**
|
|
535
|
+
* List the IDs of every system an ENABLED assignment of `configurationId`
|
|
536
|
+
* targets. Used by the pause/resume RPC handlers to know which systems'
|
|
537
|
+
* rollup `health` entity must be recomputed when a configuration's `paused`
|
|
538
|
+
* flag flips (because `getSystemHealthStatus` excludes paused configs, the
|
|
539
|
+
* recomputed rollup may transition healthy → degraded or vice-versa, and
|
|
540
|
+
* that transition drives downstream SLO downtime open/close).
|
|
541
|
+
*
|
|
542
|
+
* Only ENABLED assignments are returned: a disabled assignment never
|
|
543
|
+
* contributed to system health, so flipping `paused` on its config has no
|
|
544
|
+
* effect on that system's aggregate.
|
|
545
|
+
*/
|
|
546
|
+
async getSystemIdsForConfiguration(
|
|
547
|
+
configurationId: string,
|
|
548
|
+
): Promise<string[]> {
|
|
549
|
+
const rows = await this.db
|
|
550
|
+
.select({ systemId: systemHealthChecks.systemId })
|
|
551
|
+
.from(systemHealthChecks)
|
|
552
|
+
.where(
|
|
553
|
+
and(
|
|
554
|
+
eq(systemHealthChecks.configurationId, configurationId),
|
|
555
|
+
eq(systemHealthChecks.enabled, true),
|
|
556
|
+
),
|
|
557
|
+
);
|
|
558
|
+
return rows.map((r) => r.systemId);
|
|
559
|
+
}
|
|
560
|
+
|
|
534
561
|
/**
|
|
535
562
|
* Resolve the fully-defaulted notification policy for a single
|
|
536
563
|
* (system, configuration) association. Resolution order:
|
|
@@ -580,10 +607,14 @@ export class HealthCheckService {
|
|
|
580
607
|
*
|
|
581
608
|
* Environment dimension (Phase 3b, §7.4.2):
|
|
582
609
|
* - `environmentId` OMITTED (or `undefined`) ⇒ the **system rollup**: all
|
|
583
|
-
* runs for the system
|
|
584
|
-
*
|
|
585
|
-
*
|
|
586
|
-
*
|
|
610
|
+
* runs for the system, grouped by `environment_id` per association and
|
|
611
|
+
* evaluated per-env, then worst-wins ACROSS environments within each
|
|
612
|
+
* association (unhealthy > degraded > healthy). This is stable regardless
|
|
613
|
+
* of env insertion order or multi-pod racing; flattening envs into one
|
|
614
|
+
* list feeds interleaved statuses to the consecutive evaluator and breaks
|
|
615
|
+
* the streak on the first interleaving env (masking / flapping). For an
|
|
616
|
+
* assignment with a single env (or env-less only) this reduces to the
|
|
617
|
+
* pre-existing flat-window behavior.
|
|
587
618
|
* - `environmentId` a STRING ⇒ the per-environment slice: only runs whose
|
|
588
619
|
* `environment_id` equals that id.
|
|
589
620
|
* - `environmentId` `null` ⇒ the ENV-LESS slice: only runs with
|
|
@@ -614,6 +645,13 @@ export class HealthCheckService {
|
|
|
614
645
|
and(
|
|
615
646
|
eq(systemHealthChecks.systemId, systemId),
|
|
616
647
|
eq(systemHealthChecks.enabled, true),
|
|
648
|
+
// A paused configuration contributes no signal to the system's
|
|
649
|
+
// health: its execution is skipped (see queue-executor pause gate)
|
|
650
|
+
// and its historical runs MUST NOT keep the aggregate degraded
|
|
651
|
+
// while it is paused. Excluding it here makes the rollup reflect
|
|
652
|
+
// only the actively-running checks, so pausing the sole failing
|
|
653
|
+
// check clears the system's status and downstream SLO downtime.
|
|
654
|
+
eq(healthCheckConfigurations.paused, false),
|
|
617
655
|
),
|
|
618
656
|
);
|
|
619
657
|
|
|
@@ -634,6 +672,9 @@ export class HealthCheckService {
|
|
|
634
672
|
// adds no predicate; `null` filters to the env-less slice; a string
|
|
635
673
|
// filters to that environment. The lookup index leads with
|
|
636
674
|
// (system_id, environment_id, …) so the env-scoped query is index-efficient.
|
|
675
|
+
//
|
|
676
|
+
// For the rollup, we deliberately do NOT apply a single envFilter to one
|
|
677
|
+
// flat run list — see the per-association branch below for why.
|
|
637
678
|
const envFilter =
|
|
638
679
|
environmentId === undefined
|
|
639
680
|
? undefined
|
|
@@ -642,36 +683,102 @@ export class HealthCheckService {
|
|
|
642
683
|
: eq(healthCheckRuns.environmentId, environmentId);
|
|
643
684
|
|
|
644
685
|
for (const assoc of associations) {
|
|
645
|
-
const runs = await this.db
|
|
646
|
-
.select({
|
|
647
|
-
status: healthCheckRuns.status,
|
|
648
|
-
timestamp: healthCheckRuns.timestamp,
|
|
649
|
-
})
|
|
650
|
-
.from(healthCheckRuns)
|
|
651
|
-
.where(
|
|
652
|
-
and(
|
|
653
|
-
eq(healthCheckRuns.systemId, systemId),
|
|
654
|
-
eq(healthCheckRuns.configurationId, assoc.configurationId),
|
|
655
|
-
...(envFilter ? [envFilter] : []),
|
|
656
|
-
),
|
|
657
|
-
)
|
|
658
|
-
.orderBy(desc(healthCheckRuns.timestamp))
|
|
659
|
-
.limit(maxWindowSize);
|
|
660
|
-
|
|
661
686
|
// Extract and migrate thresholds from versioned config
|
|
662
687
|
let thresholds: StateThresholds | undefined;
|
|
663
688
|
if (assoc.stateThresholds) {
|
|
664
689
|
thresholds = await stateThresholds.parse(assoc.stateThresholds);
|
|
665
690
|
}
|
|
666
691
|
|
|
667
|
-
|
|
692
|
+
let status: HealthCheckStatus;
|
|
693
|
+
let runsConsidered: number;
|
|
694
|
+
let lastRunAt: Date | undefined;
|
|
695
|
+
|
|
696
|
+
if (environmentId === undefined) {
|
|
697
|
+
// System rollup: evaluate the threshold window PER ENVIRONMENT within
|
|
698
|
+
// the association, then take worst-wins ACROSS envs. Flattening every
|
|
699
|
+
// env's runs into one list feeds interleaved statuses to
|
|
700
|
+
// `evaluateConsecutive` (the default mode): the streak breaks on the
|
|
701
|
+
// first interleaving env, so the evaluator collapses to whatever
|
|
702
|
+
// single env's status the most recent run landed on. That masks any
|
|
703
|
+
// permanently-failing sibling env in the default mode ("the healthy
|
|
704
|
+
// env wins"), and flaps healthy↔degraded whenever env insertion
|
|
705
|
+
// order drifts across ticks (see the regression test
|
|
706
|
+
// `rollup — worst-wins across environments within an association`).
|
|
707
|
+
// Per-env evaluation makes the rollup worst-wins stable regardless of
|
|
708
|
+
// insertion order or multi-pod racing.
|
|
709
|
+
const runs = await this.db
|
|
710
|
+
.select({
|
|
711
|
+
status: healthCheckRuns.status,
|
|
712
|
+
timestamp: healthCheckRuns.timestamp,
|
|
713
|
+
environmentId: healthCheckRuns.environmentId,
|
|
714
|
+
})
|
|
715
|
+
.from(healthCheckRuns)
|
|
716
|
+
.where(
|
|
717
|
+
and(
|
|
718
|
+
eq(healthCheckRuns.systemId, systemId),
|
|
719
|
+
eq(healthCheckRuns.configurationId, assoc.configurationId),
|
|
720
|
+
),
|
|
721
|
+
)
|
|
722
|
+
.orderBy(desc(healthCheckRuns.timestamp))
|
|
723
|
+
.limit(maxWindowSize);
|
|
724
|
+
|
|
725
|
+
// Group by environmentId. `null` is its own group (the env-less slice
|
|
726
|
+
// of an assignment that has opted out, plus any pre-3b env-less runs).
|
|
727
|
+
const byEnv = new Map<string | null, { status: HealthCheckStatus; timestamp: Date }[]>();
|
|
728
|
+
for (const r of runs) {
|
|
729
|
+
const key = r.environmentId ?? null;
|
|
730
|
+
const bucket = byEnv.get(key);
|
|
731
|
+
if (bucket) {
|
|
732
|
+
bucket.push(r);
|
|
733
|
+
} else {
|
|
734
|
+
byEnv.set(key, [r]);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
status = "healthy";
|
|
739
|
+
runsConsidered = runs.length;
|
|
740
|
+
lastRunAt = runs[0]?.timestamp;
|
|
741
|
+
for (const envRuns of byEnv.values()) {
|
|
742
|
+
const envStatus = evaluateHealthStatus({ runs: envRuns, thresholds });
|
|
743
|
+
if (envStatus === "unhealthy") {
|
|
744
|
+
status = "unhealthy";
|
|
745
|
+
break; // worst: stop
|
|
746
|
+
}
|
|
747
|
+
if (envStatus === "degraded" && status === "healthy") {
|
|
748
|
+
status = "degraded";
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
} else {
|
|
752
|
+
// Per-env (string) or env-less (null) slice: that slice's flat run
|
|
753
|
+
// window is monotonic per-env, so the threshold evaluator sees no
|
|
754
|
+
// interleaving — the consecutive streak is well-defined.
|
|
755
|
+
const runs = await this.db
|
|
756
|
+
.select({
|
|
757
|
+
status: healthCheckRuns.status,
|
|
758
|
+
timestamp: healthCheckRuns.timestamp,
|
|
759
|
+
})
|
|
760
|
+
.from(healthCheckRuns)
|
|
761
|
+
.where(
|
|
762
|
+
and(
|
|
763
|
+
eq(healthCheckRuns.systemId, systemId),
|
|
764
|
+
eq(healthCheckRuns.configurationId, assoc.configurationId),
|
|
765
|
+
...(envFilter ? [envFilter] : []),
|
|
766
|
+
),
|
|
767
|
+
)
|
|
768
|
+
.orderBy(desc(healthCheckRuns.timestamp))
|
|
769
|
+
.limit(maxWindowSize);
|
|
770
|
+
|
|
771
|
+
status = evaluateHealthStatus({ runs, thresholds });
|
|
772
|
+
runsConsidered = runs.length;
|
|
773
|
+
lastRunAt = runs[0]?.timestamp;
|
|
774
|
+
}
|
|
668
775
|
|
|
669
776
|
checkStatuses.push({
|
|
670
777
|
configurationId: assoc.configurationId,
|
|
671
778
|
configurationName: assoc.configName,
|
|
672
779
|
status,
|
|
673
|
-
runsConsidered
|
|
674
|
-
lastRunAt
|
|
780
|
+
runsConsidered,
|
|
781
|
+
lastRunAt,
|
|
675
782
|
});
|
|
676
783
|
}
|
|
677
784
|
|
|
@@ -846,6 +953,7 @@ export class HealthCheckService {
|
|
|
846
953
|
strategyId: healthCheckConfigurations.strategyId,
|
|
847
954
|
intervalSeconds: healthCheckConfigurations.intervalSeconds,
|
|
848
955
|
enabled: systemHealthChecks.enabled,
|
|
956
|
+
paused: healthCheckConfigurations.paused,
|
|
849
957
|
stateThresholds: systemHealthChecks.stateThresholds,
|
|
850
958
|
})
|
|
851
959
|
.from(systemHealthChecks)
|
|
@@ -865,6 +973,7 @@ export class HealthCheckService {
|
|
|
865
973
|
id: healthCheckRuns.id,
|
|
866
974
|
status: healthCheckRuns.status,
|
|
867
975
|
timestamp: healthCheckRuns.timestamp,
|
|
976
|
+
environmentId: healthCheckRuns.environmentId,
|
|
868
977
|
})
|
|
869
978
|
.from(healthCheckRuns)
|
|
870
979
|
.where(
|
|
@@ -885,11 +994,89 @@ export class HealthCheckService {
|
|
|
885
994
|
thresholds = await stateThresholds.parse(assoc.stateThresholds);
|
|
886
995
|
}
|
|
887
996
|
|
|
888
|
-
//
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
997
|
+
// Group the fetched runs by environmentId (null = env-less slice). We
|
|
998
|
+
// query each env's slice separately below to evaluate it on its own
|
|
999
|
+
// monotonic run window and worst-wins across envs — this is the same
|
|
1000
|
+
// derivation `getSystemHealthStatus(systemId)` uses for the rollup; see
|
|
1001
|
+
// that method for the rationale (flattening envs feeds interleaved
|
|
1002
|
+
// statuses to the consecutive evaluator and masks sibling outages).
|
|
1003
|
+
const perEnvironment: {
|
|
1004
|
+
environmentId: string | null;
|
|
1005
|
+
status: HealthCheckStatus;
|
|
1006
|
+
recentRuns: { id: string; status: HealthCheckStatus; timestamp: Date }[];
|
|
1007
|
+
}[] = [];
|
|
1008
|
+
|
|
1009
|
+
// Stable ordering of env keys: env-less (`null`) first, then env ids in
|
|
1010
|
+
// the order they were first encountered in the mixed pool (membership
|
|
1011
|
+
// order is otherwise unobservable here without a catalog read; recent
|
|
1012
|
+
// runs surface stable, recent order).
|
|
1013
|
+
const envKeys: (string | null)[] = [];
|
|
1014
|
+
const seenEnv = new Set<string | null>();
|
|
1015
|
+
for (const r of runs) {
|
|
1016
|
+
const key = r.environmentId ?? null;
|
|
1017
|
+
if (!seenEnv.has(key)) {
|
|
1018
|
+
seenEnv.add(key);
|
|
1019
|
+
envKeys.push(key);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
// If no runs at all, surface a single env-less entry so UI can render
|
|
1023
|
+
// an empty row rather than nothing.
|
|
1024
|
+
if (envKeys.length === 0) envKeys.push(null);
|
|
1025
|
+
|
|
1026
|
+
let aggregateStatus: HealthCheckStatus = "healthy";
|
|
1027
|
+
for (const envId of envKeys) {
|
|
1028
|
+
const envRuns = await this.db
|
|
1029
|
+
.select({
|
|
1030
|
+
id: healthCheckRuns.id,
|
|
1031
|
+
status: healthCheckRuns.status,
|
|
1032
|
+
timestamp: healthCheckRuns.timestamp,
|
|
1033
|
+
})
|
|
1034
|
+
.from(healthCheckRuns)
|
|
1035
|
+
.where(
|
|
1036
|
+
and(
|
|
1037
|
+
eq(healthCheckRuns.systemId, systemId),
|
|
1038
|
+
eq(healthCheckRuns.configurationId, assoc.configurationId),
|
|
1039
|
+
envId === null
|
|
1040
|
+
? isNull(healthCheckRuns.environmentId)
|
|
1041
|
+
: eq(healthCheckRuns.environmentId, envId),
|
|
1042
|
+
),
|
|
1043
|
+
)
|
|
1044
|
+
.orderBy(desc(healthCheckRuns.timestamp))
|
|
1045
|
+
.limit(sparklineLimit);
|
|
1046
|
+
|
|
1047
|
+
const envStatus = evaluateHealthStatus({
|
|
1048
|
+
runs: envRuns,
|
|
1049
|
+
thresholds,
|
|
1050
|
+
});
|
|
1051
|
+
// Worst-wins across envs (unhealthy > degraded > healthy).
|
|
1052
|
+
if (envStatus === "unhealthy") {
|
|
1053
|
+
aggregateStatus = "unhealthy";
|
|
1054
|
+
} else if (envStatus === "degraded" && aggregateStatus === "healthy") {
|
|
1055
|
+
aggregateStatus = "degraded";
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
perEnvironment.push({
|
|
1059
|
+
environmentId: envId,
|
|
1060
|
+
status: envStatus,
|
|
1061
|
+
recentRuns: envRuns.toReversed().map((r) => ({
|
|
1062
|
+
id: r.id,
|
|
1063
|
+
status: r.status,
|
|
1064
|
+
timestamp: r.timestamp,
|
|
1065
|
+
})),
|
|
1066
|
+
});
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// Evaluate current status (runs are in DESC order - newest first - as evaluateHealthStatus expects).
|
|
1070
|
+
// For a paused configuration the runs are stale (execution is skipped),
|
|
1071
|
+
// so the evaluated `status` is NOT a meaningful current verdict — the
|
|
1072
|
+
// frontend renders a "Paused" pill from the `paused` flag instead.
|
|
1073
|
+
// We still compute it so the historical/sparkline path stays uniform,
|
|
1074
|
+
// and so a non-paused consumer that ignores `paused` sees a best-
|
|
1075
|
+
// effort status rather than a hard null. `aggregateStatus` is the
|
|
1076
|
+
// worst-wins-across-envs rollup derived above (it equals what
|
|
1077
|
+
// evaluateHealthStatus would return on the flat pool if only ONE env is
|
|
1078
|
+
// present, preserving per-check single-env behavior).
|
|
1079
|
+
const status = aggregateStatus;
|
|
893
1080
|
|
|
894
1081
|
checks.push({
|
|
895
1082
|
configurationId: assoc.configurationId,
|
|
@@ -897,13 +1084,16 @@ export class HealthCheckService {
|
|
|
897
1084
|
strategyId: assoc.strategyId,
|
|
898
1085
|
intervalSeconds: assoc.intervalSeconds,
|
|
899
1086
|
enabled: assoc.enabled,
|
|
1087
|
+
paused: assoc.paused,
|
|
900
1088
|
status,
|
|
901
1089
|
stateThresholds: thresholds,
|
|
902
1090
|
recentRuns: chronologicalRuns.map((r) => ({
|
|
903
1091
|
id: r.id,
|
|
904
1092
|
status: r.status,
|
|
905
1093
|
timestamp: r.timestamp,
|
|
1094
|
+
environmentId: r.environmentId,
|
|
906
1095
|
})),
|
|
1096
|
+
perEnvironment,
|
|
907
1097
|
});
|
|
908
1098
|
}
|
|
909
1099
|
|
|
@@ -921,6 +1111,7 @@ export class HealthCheckService {
|
|
|
921
1111
|
endDate?: Date;
|
|
922
1112
|
sourceFilter?: string;
|
|
923
1113
|
statusFilter?: HealthCheckStatus[];
|
|
1114
|
+
environmentId?: string | null;
|
|
924
1115
|
limit?: number;
|
|
925
1116
|
offset?: number;
|
|
926
1117
|
sortOrder: "asc" | "desc";
|
|
@@ -932,6 +1123,7 @@ export class HealthCheckService {
|
|
|
932
1123
|
endDate,
|
|
933
1124
|
sourceFilter,
|
|
934
1125
|
statusFilter,
|
|
1126
|
+
environmentId,
|
|
935
1127
|
limit = 10,
|
|
936
1128
|
offset = 0,
|
|
937
1129
|
sortOrder,
|
|
@@ -956,6 +1148,17 @@ export class HealthCheckService {
|
|
|
956
1148
|
conditions.push(inArray(healthCheckRuns.status, statusFilter));
|
|
957
1149
|
}
|
|
958
1150
|
|
|
1151
|
+
// Environment filtering (server-side). `null` selects the env-less slice;
|
|
1152
|
+
// a string selects that env; `undefined` leaves all envs in the window.
|
|
1153
|
+
// The drawer relies on this to scope its Recent Runs table to the env the
|
|
1154
|
+
// operator clicked, so the total + the paginated rows reflect only the
|
|
1155
|
+
// (check, environment) pair — not the mixed-env pool.
|
|
1156
|
+
if (environmentId === null) {
|
|
1157
|
+
conditions.push(isNull(healthCheckRuns.environmentId));
|
|
1158
|
+
} else if (environmentId !== undefined) {
|
|
1159
|
+
conditions.push(eq(healthCheckRuns.environmentId, environmentId));
|
|
1160
|
+
}
|
|
1161
|
+
|
|
959
1162
|
// Build where clause
|
|
960
1163
|
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
|
961
1164
|
|
|
@@ -1006,6 +1209,7 @@ export class HealthCheckService {
|
|
|
1006
1209
|
endDate: Date;
|
|
1007
1210
|
sourceFilter?: string;
|
|
1008
1211
|
statusFilter?: HealthCheckStatus[];
|
|
1212
|
+
environmentId?: string | null;
|
|
1009
1213
|
maxBuckets?: number;
|
|
1010
1214
|
}): Promise<RunStats> {
|
|
1011
1215
|
const {
|
|
@@ -1015,6 +1219,7 @@ export class HealthCheckService {
|
|
|
1015
1219
|
endDate,
|
|
1016
1220
|
sourceFilter,
|
|
1017
1221
|
statusFilter,
|
|
1222
|
+
environmentId,
|
|
1018
1223
|
maxBuckets = 24,
|
|
1019
1224
|
} = props;
|
|
1020
1225
|
|
|
@@ -1033,6 +1238,12 @@ export class HealthCheckService {
|
|
|
1033
1238
|
if (statusFilter && statusFilter.length > 0) {
|
|
1034
1239
|
conditions.push(inArray(healthCheckRuns.status, statusFilter));
|
|
1035
1240
|
}
|
|
1241
|
+
// Server-side env filter; same semantics as `getHistory`.
|
|
1242
|
+
if (environmentId === null) {
|
|
1243
|
+
conditions.push(isNull(healthCheckRuns.environmentId));
|
|
1244
|
+
} else if (environmentId !== undefined) {
|
|
1245
|
+
conditions.push(eq(healthCheckRuns.environmentId, environmentId));
|
|
1246
|
+
}
|
|
1036
1247
|
|
|
1037
1248
|
const rows = await this.db
|
|
1038
1249
|
.select({
|
|
@@ -1064,6 +1275,7 @@ export class HealthCheckService {
|
|
|
1064
1275
|
endDate?: Date;
|
|
1065
1276
|
sourceFilter?: string;
|
|
1066
1277
|
statusFilter?: HealthCheckStatus[];
|
|
1278
|
+
environmentId?: string | null;
|
|
1067
1279
|
limit?: number;
|
|
1068
1280
|
offset?: number;
|
|
1069
1281
|
sortOrder: "asc" | "desc";
|
|
@@ -1075,6 +1287,7 @@ export class HealthCheckService {
|
|
|
1075
1287
|
endDate,
|
|
1076
1288
|
sourceFilter,
|
|
1077
1289
|
statusFilter,
|
|
1290
|
+
environmentId,
|
|
1078
1291
|
limit = 10,
|
|
1079
1292
|
offset = 0,
|
|
1080
1293
|
sortOrder,
|
|
@@ -1099,6 +1312,13 @@ export class HealthCheckService {
|
|
|
1099
1312
|
conditions.push(inArray(healthCheckRuns.status, statusFilter));
|
|
1100
1313
|
}
|
|
1101
1314
|
|
|
1315
|
+
// Server-side env filter; same semantics as `getHistory`.
|
|
1316
|
+
if (environmentId === null) {
|
|
1317
|
+
conditions.push(isNull(healthCheckRuns.environmentId));
|
|
1318
|
+
} else if (environmentId !== undefined) {
|
|
1319
|
+
conditions.push(eq(healthCheckRuns.environmentId, environmentId));
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1102
1322
|
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
|
1103
1323
|
const total = await this.db.$count(healthCheckRuns, whereClause);
|
|
1104
1324
|
|
|
@@ -1173,6 +1393,7 @@ export class HealthCheckService {
|
|
|
1173
1393
|
endDate: Date;
|
|
1174
1394
|
sourceFilter?: string;
|
|
1175
1395
|
targetPoints?: number;
|
|
1396
|
+
environmentId?: string | null;
|
|
1176
1397
|
},
|
|
1177
1398
|
options: { includeAggregatedResult: boolean },
|
|
1178
1399
|
) {
|
|
@@ -1183,6 +1404,7 @@ export class HealthCheckService {
|
|
|
1183
1404
|
endDate,
|
|
1184
1405
|
sourceFilter,
|
|
1185
1406
|
targetPoints = 500,
|
|
1407
|
+
environmentId,
|
|
1186
1408
|
} = props;
|
|
1187
1409
|
|
|
1188
1410
|
// Calculate dynamic bucket interval
|
|
@@ -1204,6 +1426,25 @@ export class HealthCheckService {
|
|
|
1204
1426
|
? this.registry.getStrategy(config.strategyId)
|
|
1205
1427
|
: undefined;
|
|
1206
1428
|
|
|
1429
|
+
// Server-side env filter applied to ALL three tiers (raw runs, hourly
|
|
1430
|
+
// and daily aggregates), since `health_check_runs.environment_id` and
|
|
1431
|
+
// `health_check_aggregates.environment_id` are the same env-id domain.
|
|
1432
|
+
// The bucket uniqueness on `health_check_aggregates` includes `environmentId`
|
|
1433
|
+
// (with NULLS NOT DISTINCT), so `isNull(...)` selects the env-less buckets
|
|
1434
|
+
// and `eq(...)` selects that env's buckets.
|
|
1435
|
+
const envRunCondition =
|
|
1436
|
+
environmentId === undefined
|
|
1437
|
+
? undefined
|
|
1438
|
+
: environmentId === null
|
|
1439
|
+
? isNull(healthCheckRuns.environmentId)
|
|
1440
|
+
: eq(healthCheckRuns.environmentId, environmentId);
|
|
1441
|
+
const envAggCondition =
|
|
1442
|
+
environmentId === undefined
|
|
1443
|
+
? undefined
|
|
1444
|
+
: environmentId === null
|
|
1445
|
+
? isNull(healthCheckAggregates.environmentId)
|
|
1446
|
+
: eq(healthCheckAggregates.environmentId, environmentId);
|
|
1447
|
+
|
|
1207
1448
|
// Build source condition for raw runs
|
|
1208
1449
|
const rawConditions = [
|
|
1209
1450
|
eq(healthCheckRuns.systemId, systemId),
|
|
@@ -1215,6 +1456,7 @@ export class HealthCheckService {
|
|
|
1215
1456
|
: sourceFilter
|
|
1216
1457
|
? [eq(healthCheckRuns.sourceId, sourceFilter)]
|
|
1217
1458
|
: []),
|
|
1459
|
+
...(envRunCondition ? [envRunCondition] : []),
|
|
1218
1460
|
];
|
|
1219
1461
|
|
|
1220
1462
|
// Build source condition for hourly aggregates
|
|
@@ -1229,6 +1471,7 @@ export class HealthCheckService {
|
|
|
1229
1471
|
: sourceFilter
|
|
1230
1472
|
? [eq(healthCheckAggregates.sourceId, sourceFilter)]
|
|
1231
1473
|
: []),
|
|
1474
|
+
...(envAggCondition ? [envAggCondition] : []),
|
|
1232
1475
|
];
|
|
1233
1476
|
|
|
1234
1477
|
// Build source condition for daily aggregates
|
|
@@ -1243,6 +1486,7 @@ export class HealthCheckService {
|
|
|
1243
1486
|
: sourceFilter
|
|
1244
1487
|
? [eq(healthCheckAggregates.sourceId, sourceFilter)]
|
|
1245
1488
|
: []),
|
|
1489
|
+
...(envAggCondition ? [envAggCondition] : []),
|
|
1246
1490
|
];
|
|
1247
1491
|
|
|
1248
1492
|
// Query all three tiers in parallel
|
|
@@ -1705,6 +1949,7 @@ export class HealthCheckService {
|
|
|
1705
1949
|
const runs = await this.db
|
|
1706
1950
|
.select({
|
|
1707
1951
|
result: healthCheckRuns.result,
|
|
1952
|
+
environmentId: healthCheckRuns.environmentId,
|
|
1708
1953
|
})
|
|
1709
1954
|
.from(healthCheckRuns)
|
|
1710
1955
|
.where(
|
|
@@ -1722,6 +1967,7 @@ export class HealthCheckService {
|
|
|
1722
1967
|
configurationId: assignment.configurationId,
|
|
1723
1968
|
runs: runs.map((r) => ({
|
|
1724
1969
|
result: r.result,
|
|
1970
|
+
environmentId: r.environmentId,
|
|
1725
1971
|
})),
|
|
1726
1972
|
});
|
|
1727
1973
|
}
|