@checkstack/healthcheck-backend 1.11.1 → 1.13.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/src/service.ts CHANGED
@@ -33,6 +33,7 @@ import * as schema from "./schema";
33
33
  import {
34
34
  eq,
35
35
  and,
36
+ or,
36
37
  InferSelectModel,
37
38
  desc,
38
39
  gte,
@@ -531,6 +532,33 @@ export class HealthCheckService {
531
532
  return results;
532
533
  }
533
534
 
535
+ /**
536
+ * List the IDs of every system an ENABLED assignment of `configurationId`
537
+ * targets. Used by the pause/resume RPC handlers to know which systems'
538
+ * rollup `health` entity must be recomputed when a configuration's `paused`
539
+ * flag flips (because `getSystemHealthStatus` excludes paused configs, the
540
+ * recomputed rollup may transition healthy → degraded or vice-versa, and
541
+ * that transition drives downstream SLO downtime open/close).
542
+ *
543
+ * Only ENABLED assignments are returned: a disabled assignment never
544
+ * contributed to system health, so flipping `paused` on its config has no
545
+ * effect on that system's aggregate.
546
+ */
547
+ async getSystemIdsForConfiguration(
548
+ configurationId: string,
549
+ ): Promise<string[]> {
550
+ const rows = await this.db
551
+ .select({ systemId: systemHealthChecks.systemId })
552
+ .from(systemHealthChecks)
553
+ .where(
554
+ and(
555
+ eq(systemHealthChecks.configurationId, configurationId),
556
+ eq(systemHealthChecks.enabled, true),
557
+ ),
558
+ );
559
+ return rows.map((r) => r.systemId);
560
+ }
561
+
534
562
  /**
535
563
  * Resolve the fully-defaulted notification policy for a single
536
564
  * (system, configuration) association. Resolution order:
@@ -580,10 +608,14 @@ export class HealthCheckService {
580
608
  *
581
609
  * Environment dimension (Phase 3b, §7.4.2):
582
610
  * - `environmentId` OMITTED (or `undefined`) ⇒ the **system rollup**: all
583
- * runs for the system regardless of environment. "Any env unhealthy ⇒ at
584
- * least one unhealthy run in the window" already yields worst-status
585
- * semantics for the window-based evaluator, and it exactly matches the
586
- * pre-3b behavior when no environments exist (no extra catalog read).
611
+ * runs for the system, grouped by `environment_id` per association and
612
+ * evaluated per-env, then worst-wins ACROSS environments within each
613
+ * association (unhealthy > degraded > healthy). This is stable regardless
614
+ * of env insertion order or multi-pod racing; flattening envs into one
615
+ * list feeds interleaved statuses to the consecutive evaluator and breaks
616
+ * the streak on the first interleaving env (masking / flapping). For an
617
+ * assignment with a single env (or env-less only) this reduces to the
618
+ * pre-existing flat-window behavior.
587
619
  * - `environmentId` a STRING ⇒ the per-environment slice: only runs whose
588
620
  * `environment_id` equals that id.
589
621
  * - `environmentId` `null` ⇒ the ENV-LESS slice: only runs with
@@ -614,6 +646,13 @@ export class HealthCheckService {
614
646
  and(
615
647
  eq(systemHealthChecks.systemId, systemId),
616
648
  eq(systemHealthChecks.enabled, true),
649
+ // A paused configuration contributes no signal to the system's
650
+ // health: its execution is skipped (see queue-executor pause gate)
651
+ // and its historical runs MUST NOT keep the aggregate degraded
652
+ // while it is paused. Excluding it here makes the rollup reflect
653
+ // only the actively-running checks, so pausing the sole failing
654
+ // check clears the system's status and downstream SLO downtime.
655
+ eq(healthCheckConfigurations.paused, false),
617
656
  ),
618
657
  );
619
658
 
@@ -634,6 +673,9 @@ export class HealthCheckService {
634
673
  // adds no predicate; `null` filters to the env-less slice; a string
635
674
  // filters to that environment. The lookup index leads with
636
675
  // (system_id, environment_id, …) so the env-scoped query is index-efficient.
676
+ //
677
+ // For the rollup, we deliberately do NOT apply a single envFilter to one
678
+ // flat run list — see the per-association branch below for why.
637
679
  const envFilter =
638
680
  environmentId === undefined
639
681
  ? undefined
@@ -642,36 +684,102 @@ export class HealthCheckService {
642
684
  : eq(healthCheckRuns.environmentId, environmentId);
643
685
 
644
686
  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
687
  // Extract and migrate thresholds from versioned config
662
688
  let thresholds: StateThresholds | undefined;
663
689
  if (assoc.stateThresholds) {
664
690
  thresholds = await stateThresholds.parse(assoc.stateThresholds);
665
691
  }
666
692
 
667
- const status = evaluateHealthStatus({ runs, thresholds });
693
+ let status: HealthCheckStatus;
694
+ let runsConsidered: number;
695
+ let lastRunAt: Date | undefined;
696
+
697
+ if (environmentId === undefined) {
698
+ // System rollup: evaluate the threshold window PER ENVIRONMENT within
699
+ // the association, then take worst-wins ACROSS envs. Flattening every
700
+ // env's runs into one list feeds interleaved statuses to
701
+ // `evaluateConsecutive` (the default mode): the streak breaks on the
702
+ // first interleaving env, so the evaluator collapses to whatever
703
+ // single env's status the most recent run landed on. That masks any
704
+ // permanently-failing sibling env in the default mode ("the healthy
705
+ // env wins"), and flaps healthy↔degraded whenever env insertion
706
+ // order drifts across ticks (see the regression test
707
+ // `rollup — worst-wins across environments within an association`).
708
+ // Per-env evaluation makes the rollup worst-wins stable regardless of
709
+ // insertion order or multi-pod racing.
710
+ const runs = await this.db
711
+ .select({
712
+ status: healthCheckRuns.status,
713
+ timestamp: healthCheckRuns.timestamp,
714
+ environmentId: healthCheckRuns.environmentId,
715
+ })
716
+ .from(healthCheckRuns)
717
+ .where(
718
+ and(
719
+ eq(healthCheckRuns.systemId, systemId),
720
+ eq(healthCheckRuns.configurationId, assoc.configurationId),
721
+ ),
722
+ )
723
+ .orderBy(desc(healthCheckRuns.timestamp))
724
+ .limit(maxWindowSize);
725
+
726
+ // Group by environmentId. `null` is its own group (the env-less slice
727
+ // of an assignment that has opted out, plus any pre-3b env-less runs).
728
+ const byEnv = new Map<string | null, { status: HealthCheckStatus; timestamp: Date }[]>();
729
+ for (const r of runs) {
730
+ const key = r.environmentId ?? null;
731
+ const bucket = byEnv.get(key);
732
+ if (bucket) {
733
+ bucket.push(r);
734
+ } else {
735
+ byEnv.set(key, [r]);
736
+ }
737
+ }
738
+
739
+ status = "healthy";
740
+ runsConsidered = runs.length;
741
+ lastRunAt = runs[0]?.timestamp;
742
+ for (const envRuns of byEnv.values()) {
743
+ const envStatus = evaluateHealthStatus({ runs: envRuns, thresholds });
744
+ if (envStatus === "unhealthy") {
745
+ status = "unhealthy";
746
+ break; // worst: stop
747
+ }
748
+ if (envStatus === "degraded" && status === "healthy") {
749
+ status = "degraded";
750
+ }
751
+ }
752
+ } else {
753
+ // Per-env (string) or env-less (null) slice: that slice's flat run
754
+ // window is monotonic per-env, so the threshold evaluator sees no
755
+ // interleaving — the consecutive streak is well-defined.
756
+ const runs = await this.db
757
+ .select({
758
+ status: healthCheckRuns.status,
759
+ timestamp: healthCheckRuns.timestamp,
760
+ })
761
+ .from(healthCheckRuns)
762
+ .where(
763
+ and(
764
+ eq(healthCheckRuns.systemId, systemId),
765
+ eq(healthCheckRuns.configurationId, assoc.configurationId),
766
+ ...(envFilter ? [envFilter] : []),
767
+ ),
768
+ )
769
+ .orderBy(desc(healthCheckRuns.timestamp))
770
+ .limit(maxWindowSize);
771
+
772
+ status = evaluateHealthStatus({ runs, thresholds });
773
+ runsConsidered = runs.length;
774
+ lastRunAt = runs[0]?.timestamp;
775
+ }
668
776
 
669
777
  checkStatuses.push({
670
778
  configurationId: assoc.configurationId,
671
779
  configurationName: assoc.configName,
672
780
  status,
673
- runsConsidered: runs.length,
674
- lastRunAt: runs[0]?.timestamp,
781
+ runsConsidered,
782
+ lastRunAt,
675
783
  });
676
784
  }
677
785
 
@@ -846,6 +954,7 @@ export class HealthCheckService {
846
954
  strategyId: healthCheckConfigurations.strategyId,
847
955
  intervalSeconds: healthCheckConfigurations.intervalSeconds,
848
956
  enabled: systemHealthChecks.enabled,
957
+ paused: healthCheckConfigurations.paused,
849
958
  stateThresholds: systemHealthChecks.stateThresholds,
850
959
  })
851
960
  .from(systemHealthChecks)
@@ -865,6 +974,7 @@ export class HealthCheckService {
865
974
  id: healthCheckRuns.id,
866
975
  status: healthCheckRuns.status,
867
976
  timestamp: healthCheckRuns.timestamp,
977
+ environmentId: healthCheckRuns.environmentId,
868
978
  })
869
979
  .from(healthCheckRuns)
870
980
  .where(
@@ -885,11 +995,89 @@ export class HealthCheckService {
885
995
  thresholds = await stateThresholds.parse(assoc.stateThresholds);
886
996
  }
887
997
 
888
- // Evaluate current status (runs are in DESC order - newest first - as evaluateHealthStatus expects)
889
- const status = evaluateHealthStatus({
890
- runs,
891
- thresholds,
892
- });
998
+ // Group the fetched runs by environmentId (null = env-less slice). We
999
+ // query each env's slice separately below to evaluate it on its own
1000
+ // monotonic run window and worst-wins across envs — this is the same
1001
+ // derivation `getSystemHealthStatus(systemId)` uses for the rollup; see
1002
+ // that method for the rationale (flattening envs feeds interleaved
1003
+ // statuses to the consecutive evaluator and masks sibling outages).
1004
+ const perEnvironment: {
1005
+ environmentId: string | null;
1006
+ status: HealthCheckStatus;
1007
+ recentRuns: { id: string; status: HealthCheckStatus; timestamp: Date }[];
1008
+ }[] = [];
1009
+
1010
+ // Stable ordering of env keys: env-less (`null`) first, then env ids in
1011
+ // the order they were first encountered in the mixed pool (membership
1012
+ // order is otherwise unobservable here without a catalog read; recent
1013
+ // runs surface stable, recent order).
1014
+ const envKeys: (string | null)[] = [];
1015
+ const seenEnv = new Set<string | null>();
1016
+ for (const r of runs) {
1017
+ const key = r.environmentId ?? null;
1018
+ if (!seenEnv.has(key)) {
1019
+ seenEnv.add(key);
1020
+ envKeys.push(key);
1021
+ }
1022
+ }
1023
+ // If no runs at all, surface a single env-less entry so UI can render
1024
+ // an empty row rather than nothing.
1025
+ if (envKeys.length === 0) envKeys.push(null);
1026
+
1027
+ let aggregateStatus: HealthCheckStatus = "healthy";
1028
+ for (const envId of envKeys) {
1029
+ const envRuns = await this.db
1030
+ .select({
1031
+ id: healthCheckRuns.id,
1032
+ status: healthCheckRuns.status,
1033
+ timestamp: healthCheckRuns.timestamp,
1034
+ })
1035
+ .from(healthCheckRuns)
1036
+ .where(
1037
+ and(
1038
+ eq(healthCheckRuns.systemId, systemId),
1039
+ eq(healthCheckRuns.configurationId, assoc.configurationId),
1040
+ envId === null
1041
+ ? isNull(healthCheckRuns.environmentId)
1042
+ : eq(healthCheckRuns.environmentId, envId),
1043
+ ),
1044
+ )
1045
+ .orderBy(desc(healthCheckRuns.timestamp))
1046
+ .limit(sparklineLimit);
1047
+
1048
+ const envStatus = evaluateHealthStatus({
1049
+ runs: envRuns,
1050
+ thresholds,
1051
+ });
1052
+ // Worst-wins across envs (unhealthy > degraded > healthy).
1053
+ if (envStatus === "unhealthy") {
1054
+ aggregateStatus = "unhealthy";
1055
+ } else if (envStatus === "degraded" && aggregateStatus === "healthy") {
1056
+ aggregateStatus = "degraded";
1057
+ }
1058
+
1059
+ perEnvironment.push({
1060
+ environmentId: envId,
1061
+ status: envStatus,
1062
+ recentRuns: envRuns.toReversed().map((r) => ({
1063
+ id: r.id,
1064
+ status: r.status,
1065
+ timestamp: r.timestamp,
1066
+ })),
1067
+ });
1068
+ }
1069
+
1070
+ // Evaluate current status (runs are in DESC order - newest first - as evaluateHealthStatus expects).
1071
+ // For a paused configuration the runs are stale (execution is skipped),
1072
+ // so the evaluated `status` is NOT a meaningful current verdict — the
1073
+ // frontend renders a "Paused" pill from the `paused` flag instead.
1074
+ // We still compute it so the historical/sparkline path stays uniform,
1075
+ // and so a non-paused consumer that ignores `paused` sees a best-
1076
+ // effort status rather than a hard null. `aggregateStatus` is the
1077
+ // worst-wins-across-envs rollup derived above (it equals what
1078
+ // evaluateHealthStatus would return on the flat pool if only ONE env is
1079
+ // present, preserving per-check single-env behavior).
1080
+ const status = aggregateStatus;
893
1081
 
894
1082
  checks.push({
895
1083
  configurationId: assoc.configurationId,
@@ -897,13 +1085,16 @@ export class HealthCheckService {
897
1085
  strategyId: assoc.strategyId,
898
1086
  intervalSeconds: assoc.intervalSeconds,
899
1087
  enabled: assoc.enabled,
1088
+ paused: assoc.paused,
900
1089
  status,
901
1090
  stateThresholds: thresholds,
902
1091
  recentRuns: chronologicalRuns.map((r) => ({
903
1092
  id: r.id,
904
1093
  status: r.status,
905
1094
  timestamp: r.timestamp,
1095
+ environmentId: r.environmentId,
906
1096
  })),
1097
+ perEnvironment,
907
1098
  });
908
1099
  }
909
1100
 
@@ -921,6 +1112,7 @@ export class HealthCheckService {
921
1112
  endDate?: Date;
922
1113
  sourceFilter?: string;
923
1114
  statusFilter?: HealthCheckStatus[];
1115
+ environmentId?: string | null;
924
1116
  limit?: number;
925
1117
  offset?: number;
926
1118
  sortOrder: "asc" | "desc";
@@ -932,6 +1124,7 @@ export class HealthCheckService {
932
1124
  endDate,
933
1125
  sourceFilter,
934
1126
  statusFilter,
1127
+ environmentId,
935
1128
  limit = 10,
936
1129
  offset = 0,
937
1130
  sortOrder,
@@ -956,6 +1149,17 @@ export class HealthCheckService {
956
1149
  conditions.push(inArray(healthCheckRuns.status, statusFilter));
957
1150
  }
958
1151
 
1152
+ // Environment filtering (server-side). `null` selects the env-less slice;
1153
+ // a string selects that env; `undefined` leaves all envs in the window.
1154
+ // The drawer relies on this to scope its Recent Runs table to the env the
1155
+ // operator clicked, so the total + the paginated rows reflect only the
1156
+ // (check, environment) pair — not the mixed-env pool.
1157
+ if (environmentId === null) {
1158
+ conditions.push(isNull(healthCheckRuns.environmentId));
1159
+ } else if (environmentId !== undefined) {
1160
+ conditions.push(eq(healthCheckRuns.environmentId, environmentId));
1161
+ }
1162
+
959
1163
  // Build where clause
960
1164
  const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
961
1165
 
@@ -1006,6 +1210,7 @@ export class HealthCheckService {
1006
1210
  endDate: Date;
1007
1211
  sourceFilter?: string;
1008
1212
  statusFilter?: HealthCheckStatus[];
1213
+ environmentId?: string | null;
1009
1214
  maxBuckets?: number;
1010
1215
  }): Promise<RunStats> {
1011
1216
  const {
@@ -1015,6 +1220,7 @@ export class HealthCheckService {
1015
1220
  endDate,
1016
1221
  sourceFilter,
1017
1222
  statusFilter,
1223
+ environmentId,
1018
1224
  maxBuckets = 24,
1019
1225
  } = props;
1020
1226
 
@@ -1033,6 +1239,12 @@ export class HealthCheckService {
1033
1239
  if (statusFilter && statusFilter.length > 0) {
1034
1240
  conditions.push(inArray(healthCheckRuns.status, statusFilter));
1035
1241
  }
1242
+ // Server-side env filter; same semantics as `getHistory`.
1243
+ if (environmentId === null) {
1244
+ conditions.push(isNull(healthCheckRuns.environmentId));
1245
+ } else if (environmentId !== undefined) {
1246
+ conditions.push(eq(healthCheckRuns.environmentId, environmentId));
1247
+ }
1036
1248
 
1037
1249
  const rows = await this.db
1038
1250
  .select({
@@ -1057,13 +1269,36 @@ export class HealthCheckService {
1057
1269
  * Restricted to users with manage access.
1058
1270
  * @param sortOrder - 'asc' for chronological (oldest first), 'desc' for reverse (newest first)
1059
1271
  */
1272
+ /**
1273
+ * Distinct system ids that have at least one recorded run - the candidate
1274
+ * set for scoping the run-history feed by SYSTEM manage access (a system's
1275
+ * owning team sees every run of that system).
1276
+ */
1277
+ async getRunSystemIds(): Promise<string[]> {
1278
+ const rows = await this.db
1279
+ .selectDistinct({ systemId: healthCheckRuns.systemId })
1280
+ .from(healthCheckRuns);
1281
+ return rows.map((r) => r.systemId);
1282
+ }
1283
+
1060
1284
  async getDetailedHistory(props: {
1061
1285
  systemId?: string;
1062
1286
  configurationId?: string;
1287
+ /**
1288
+ * Restrict the feed to a team-scoped caller's slice: runs of their
1289
+ * configurations OR runs belonging to their systems (a system's owning
1290
+ * team sees every run of that system). Applied on TOP of the other
1291
+ * filters; both `total` and the page respect it, so pagination stays
1292
+ * correct for a filtered feed. At least one array must be non-empty
1293
+ * (an entirely-empty scope is the caller's "forbidden" case, decided
1294
+ * before the query).
1295
+ */
1296
+ teamScope?: { configurationIds: string[]; systemIds: string[] };
1063
1297
  startDate?: Date;
1064
1298
  endDate?: Date;
1065
1299
  sourceFilter?: string;
1066
1300
  statusFilter?: HealthCheckStatus[];
1301
+ environmentId?: string | null;
1067
1302
  limit?: number;
1068
1303
  offset?: number;
1069
1304
  sortOrder: "asc" | "desc";
@@ -1071,10 +1306,12 @@ export class HealthCheckService {
1071
1306
  const {
1072
1307
  systemId,
1073
1308
  configurationId,
1309
+ teamScope,
1074
1310
  startDate,
1075
1311
  endDate,
1076
1312
  sourceFilter,
1077
1313
  statusFilter,
1314
+ environmentId,
1078
1315
  limit = 10,
1079
1316
  offset = 0,
1080
1317
  sortOrder,
@@ -1084,6 +1321,28 @@ export class HealthCheckService {
1084
1321
  if (systemId) conditions.push(eq(healthCheckRuns.systemId, systemId));
1085
1322
  if (configurationId)
1086
1323
  conditions.push(eq(healthCheckRuns.configurationId, configurationId));
1324
+ if (teamScope) {
1325
+ // drizzle's inArray rejects empty arrays, so only include non-empty
1326
+ // branches of the OR.
1327
+ const scopeBranches = [];
1328
+ if (teamScope.configurationIds.length > 0) {
1329
+ scopeBranches.push(
1330
+ inArray(healthCheckRuns.configurationId, teamScope.configurationIds),
1331
+ );
1332
+ }
1333
+ if (teamScope.systemIds.length > 0) {
1334
+ scopeBranches.push(
1335
+ inArray(healthCheckRuns.systemId, teamScope.systemIds),
1336
+ );
1337
+ }
1338
+ if (scopeBranches.length === 0) {
1339
+ // Defensive: an empty scope must never widen to the full feed.
1340
+ return { runs: [], total: 0 };
1341
+ }
1342
+ conditions.push(
1343
+ scopeBranches.length === 1 ? scopeBranches[0] : or(...scopeBranches),
1344
+ );
1345
+ }
1087
1346
  if (startDate) conditions.push(gte(healthCheckRuns.timestamp, startDate));
1088
1347
  if (endDate) conditions.push(lte(healthCheckRuns.timestamp, endDate));
1089
1348
 
@@ -1099,6 +1358,13 @@ export class HealthCheckService {
1099
1358
  conditions.push(inArray(healthCheckRuns.status, statusFilter));
1100
1359
  }
1101
1360
 
1361
+ // Server-side env filter; same semantics as `getHistory`.
1362
+ if (environmentId === null) {
1363
+ conditions.push(isNull(healthCheckRuns.environmentId));
1364
+ } else if (environmentId !== undefined) {
1365
+ conditions.push(eq(healthCheckRuns.environmentId, environmentId));
1366
+ }
1367
+
1102
1368
  const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
1103
1369
  const total = await this.db.$count(healthCheckRuns, whereClause);
1104
1370
 
@@ -1173,6 +1439,7 @@ export class HealthCheckService {
1173
1439
  endDate: Date;
1174
1440
  sourceFilter?: string;
1175
1441
  targetPoints?: number;
1442
+ environmentId?: string | null;
1176
1443
  },
1177
1444
  options: { includeAggregatedResult: boolean },
1178
1445
  ) {
@@ -1183,6 +1450,7 @@ export class HealthCheckService {
1183
1450
  endDate,
1184
1451
  sourceFilter,
1185
1452
  targetPoints = 500,
1453
+ environmentId,
1186
1454
  } = props;
1187
1455
 
1188
1456
  // Calculate dynamic bucket interval
@@ -1204,6 +1472,25 @@ export class HealthCheckService {
1204
1472
  ? this.registry.getStrategy(config.strategyId)
1205
1473
  : undefined;
1206
1474
 
1475
+ // Server-side env filter applied to ALL three tiers (raw runs, hourly
1476
+ // and daily aggregates), since `health_check_runs.environment_id` and
1477
+ // `health_check_aggregates.environment_id` are the same env-id domain.
1478
+ // The bucket uniqueness on `health_check_aggregates` includes `environmentId`
1479
+ // (with NULLS NOT DISTINCT), so `isNull(...)` selects the env-less buckets
1480
+ // and `eq(...)` selects that env's buckets.
1481
+ const envRunCondition =
1482
+ environmentId === undefined
1483
+ ? undefined
1484
+ : environmentId === null
1485
+ ? isNull(healthCheckRuns.environmentId)
1486
+ : eq(healthCheckRuns.environmentId, environmentId);
1487
+ const envAggCondition =
1488
+ environmentId === undefined
1489
+ ? undefined
1490
+ : environmentId === null
1491
+ ? isNull(healthCheckAggregates.environmentId)
1492
+ : eq(healthCheckAggregates.environmentId, environmentId);
1493
+
1207
1494
  // Build source condition for raw runs
1208
1495
  const rawConditions = [
1209
1496
  eq(healthCheckRuns.systemId, systemId),
@@ -1215,6 +1502,7 @@ export class HealthCheckService {
1215
1502
  : sourceFilter
1216
1503
  ? [eq(healthCheckRuns.sourceId, sourceFilter)]
1217
1504
  : []),
1505
+ ...(envRunCondition ? [envRunCondition] : []),
1218
1506
  ];
1219
1507
 
1220
1508
  // Build source condition for hourly aggregates
@@ -1229,6 +1517,7 @@ export class HealthCheckService {
1229
1517
  : sourceFilter
1230
1518
  ? [eq(healthCheckAggregates.sourceId, sourceFilter)]
1231
1519
  : []),
1520
+ ...(envAggCondition ? [envAggCondition] : []),
1232
1521
  ];
1233
1522
 
1234
1523
  // Build source condition for daily aggregates
@@ -1243,6 +1532,7 @@ export class HealthCheckService {
1243
1532
  : sourceFilter
1244
1533
  ? [eq(healthCheckAggregates.sourceId, sourceFilter)]
1245
1534
  : []),
1535
+ ...(envAggCondition ? [envAggCondition] : []),
1246
1536
  ];
1247
1537
 
1248
1538
  // Query all three tiers in parallel
@@ -1705,6 +1995,7 @@ export class HealthCheckService {
1705
1995
  const runs = await this.db
1706
1996
  .select({
1707
1997
  result: healthCheckRuns.result,
1998
+ environmentId: healthCheckRuns.environmentId,
1708
1999
  })
1709
2000
  .from(healthCheckRuns)
1710
2001
  .where(
@@ -1722,6 +2013,7 @@ export class HealthCheckService {
1722
2013
  configurationId: assignment.configurationId,
1723
2014
  runs: runs.map((r) => ({
1724
2015
  result: r.result,
2016
+ environmentId: r.environmentId,
1725
2017
  })),
1726
2018
  });
1727
2019
  }