@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/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 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).
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
- const status = evaluateHealthStatus({ runs, thresholds });
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: runs.length,
674
- lastRunAt: runs[0]?.timestamp,
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
- // Evaluate current status (runs are in DESC order - newest first - as evaluateHealthStatus expects)
889
- const status = evaluateHealthStatus({
890
- runs,
891
- thresholds,
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
  }