@checkstack/healthcheck-backend 0.8.3 → 0.10.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 CHANGED
@@ -1,5 +1,63 @@
1
1
  # @checkstack/healthcheck-backend
2
2
 
3
+ ## 0.10.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 869b4ab: ## Health Check Execution Improvements
8
+
9
+ ### Breaking Changes (backend-api)
10
+
11
+ - `HealthCheckStrategy.createClient()` now accepts `unknown` instead of `TConfig` due to TypeScript contravariance constraints. Implementations should use `this.config.validate(config)` to narrow the type.
12
+
13
+ ### Features
14
+
15
+ - **Platform-level hard timeout**: The executor now wraps the entire health check execution (connection + all collectors) in a single timeout, ensuring checks never hang indefinitely.
16
+ - **Parallel collector execution**: Collectors now run in parallel using `Promise.allSettled()`, improving performance while ensuring all collectors complete regardless of individual failures.
17
+ - **Base strategy config schema**: All strategy configs now extend `baseStrategyConfigSchema` which provides a standardized `timeout` field with sensible defaults (30s, min 100ms).
18
+
19
+ ### Fixes
20
+
21
+ - Fixed HTTP and Jenkins strategies clearing timeouts before reading the full response body.
22
+ - Simplified registry type signatures by using default type parameters.
23
+
24
+ ### Patch Changes
25
+
26
+ - Updated dependencies [869b4ab]
27
+ - @checkstack/backend-api@0.8.0
28
+ - @checkstack/catalog-backend@0.2.13
29
+ - @checkstack/command-backend@0.1.11
30
+ - @checkstack/integration-backend@0.1.11
31
+ - @checkstack/queue-api@0.2.5
32
+
33
+ ## 0.9.0
34
+
35
+ ### Minor Changes
36
+
37
+ - 3dd1914: Migrate health check strategies to VersionedAggregated with \_type discriminator
38
+
39
+ All 13 health check strategies now use `VersionedAggregated` for their `aggregatedResult` property, enabling automatic bucket merging with 100% mathematical fidelity.
40
+
41
+ **Key changes:**
42
+
43
+ - **`_type` discriminator**: All aggregated state objects now include a required `_type` field (`"average"`, `"rate"`, `"counter"`, `"minmax"`) for reliable type detection
44
+ - The `HealthCheckStrategy` interface now requires `aggregatedResult` to be a `VersionedAggregated<AggregatedResultShape>`
45
+ - Strategy/collector `mergeResult` methods return state objects with `_type` (e.g., `{ _type: "average", _sum, _count, avg }`)
46
+ - `mergeAggregatedBucketResults`, `combineBuckets`, and `reaggregateBuckets` now require `registry` and `strategyId` parameters
47
+ - `HealthCheckService` constructor now requires both `registry` and `collectorRegistry` parameters
48
+ - Frontend `extractComputedValue` now uses `_type` discriminator for robust type detection
49
+
50
+ **Breaking Change**: State objects now require `_type`. Merge functions automatically add `_type` to output. The bucket merging functions and `HealthCheckService` now require additional required parameters.
51
+
52
+ ### Patch Changes
53
+
54
+ - Updated dependencies [3dd1914]
55
+ - @checkstack/backend-api@0.7.0
56
+ - @checkstack/catalog-backend@0.2.12
57
+ - @checkstack/command-backend@0.1.10
58
+ - @checkstack/integration-backend@0.1.10
59
+ - @checkstack/queue-api@0.2.4
60
+
3
61
  ## 0.8.3
4
62
 
5
63
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-backend",
3
- "version": "0.8.3",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "scripts": {
@@ -10,17 +10,17 @@
10
10
  "lint:code": "eslint . --max-warnings 0"
11
11
  },
12
12
  "dependencies": {
13
- "@checkstack/backend-api": "0.5.2",
14
- "@checkstack/catalog-backend": "0.2.10",
15
- "@checkstack/catalog-common": "1.2.6",
16
- "@checkstack/command-backend": "0.1.8",
17
- "@checkstack/common": "0.6.1",
18
- "@checkstack/healthcheck-common": "0.8.1",
19
- "@checkstack/incident-common": "0.4.2",
20
- "@checkstack/integration-backend": "0.1.8",
21
- "@checkstack/maintenance-common": "0.4.4",
22
- "@checkstack/queue-api": "0.2.2",
23
- "@checkstack/signal-common": "0.1.5",
13
+ "@checkstack/backend-api": "0.7.0",
14
+ "@checkstack/catalog-backend": "0.2.12",
15
+ "@checkstack/catalog-common": "1.2.7",
16
+ "@checkstack/command-backend": "0.1.10",
17
+ "@checkstack/common": "0.6.2",
18
+ "@checkstack/healthcheck-common": "0.8.2",
19
+ "@checkstack/incident-common": "0.4.3",
20
+ "@checkstack/integration-backend": "0.1.10",
21
+ "@checkstack/maintenance-common": "0.4.5",
22
+ "@checkstack/queue-api": "0.2.4",
23
+ "@checkstack/signal-common": "0.1.6",
24
24
  "@hono/zod-validator": "^0.7.6",
25
25
  "drizzle-orm": "^0.45.1",
26
26
  "hono": "^4.0.0",
@@ -30,7 +30,7 @@
30
30
  "devDependencies": {
31
31
  "@checkstack/drizzle-helper": "0.0.3",
32
32
  "@checkstack/scripts": "0.1.1",
33
- "@checkstack/test-utils-backend": "0.1.8",
33
+ "@checkstack/test-utils-backend": "0.1.10",
34
34
  "@checkstack/tsconfig": "0.0.3",
35
35
  "@orpc/server": "^1.13.2",
36
36
  "@types/bun": "^1.0.0",
@@ -1,4 +1,4 @@
1
- import { describe, it, expect } from "bun:test";
1
+ import { describe, it, expect, mock } from "bun:test";
2
2
  import {
3
3
  calculatePercentile,
4
4
  calculateLatencyStats,
@@ -7,8 +7,35 @@ import {
7
7
  mergeTieredBuckets,
8
8
  combineBuckets,
9
9
  reaggregateBuckets,
10
+ mergeAggregatedBucketResults,
10
11
  type NormalizedBucket,
11
12
  } from "./aggregation-utils";
13
+ import {
14
+ VersionedAggregated,
15
+ aggregatedCounter,
16
+ aggregatedAverage,
17
+ type CollectorRegistry,
18
+ type HealthCheckRegistry,
19
+ } from "@checkstack/backend-api";
20
+
21
+ // Helper to create mock registries for testing
22
+ const createMockRegistries = () => {
23
+ const collectorRegistry: CollectorRegistry = {
24
+ register: mock(() => {}),
25
+ getCollector: mock(() => undefined),
26
+ getCollectors: mock(() => []),
27
+ getCollectorsForPlugin: mock(() => []),
28
+ };
29
+
30
+ const registry: HealthCheckRegistry = {
31
+ getStrategy: mock(() => undefined),
32
+ register: mock(() => {}),
33
+ getStrategies: mock(() => []),
34
+ getStrategiesWithMeta: mock(() => []),
35
+ };
36
+
37
+ return { collectorRegistry, registry, strategyId: "test-strategy" };
38
+ };
12
39
 
13
40
  // Helper to create a NormalizedBucket for testing
14
41
  function createBucket(params: {
@@ -409,7 +436,9 @@ describe("aggregation-utils", () => {
409
436
 
410
437
  it("returns empty bucket for empty input", () => {
411
438
  const targetStart = new Date(0);
439
+ const mocks = createMockRegistries();
412
440
  const result = combineBuckets({
441
+ ...mocks,
413
442
  buckets: [],
414
443
  targetBucketStart: targetStart,
415
444
  targetBucketEndMs: HOUR,
@@ -442,7 +471,9 @@ describe("aggregation-utils", () => {
442
471
  }),
443
472
  ];
444
473
 
474
+ const mocks = createMockRegistries();
445
475
  const result = combineBuckets({
476
+ ...mocks,
446
477
  buckets,
447
478
  targetBucketStart: new Date(0),
448
479
  targetBucketEndMs: 2 * HOUR,
@@ -470,7 +501,9 @@ describe("aggregation-utils", () => {
470
501
  }),
471
502
  ];
472
503
 
504
+ const mocks = createMockRegistries();
473
505
  const result = combineBuckets({
506
+ ...mocks,
474
507
  buckets,
475
508
  targetBucketStart: new Date(0),
476
509
  targetBucketEndMs: 2 * HOUR,
@@ -495,7 +528,9 @@ describe("aggregation-utils", () => {
495
528
  }),
496
529
  ];
497
530
 
531
+ const mocks = createMockRegistries();
498
532
  const result = combineBuckets({
533
+ ...mocks,
499
534
  buckets,
500
535
  targetBucketStart: new Date(0),
501
536
  targetBucketEndMs: 2 * HOUR,
@@ -520,7 +555,9 @@ describe("aggregation-utils", () => {
520
555
  }),
521
556
  ];
522
557
 
558
+ const mocks = createMockRegistries();
523
559
  const result = combineBuckets({
560
+ ...mocks,
524
561
  buckets,
525
562
  targetBucketStart: new Date(0),
526
563
  targetBucketEndMs: 2 * HOUR,
@@ -545,7 +582,9 @@ describe("aggregation-utils", () => {
545
582
  }),
546
583
  ];
547
584
 
585
+ const mocks = createMockRegistries();
548
586
  const result = combineBuckets({
587
+ ...mocks,
549
588
  buckets,
550
589
  targetBucketStart: new Date(0),
551
590
  targetBucketEndMs: 2 * HOUR,
@@ -560,7 +599,9 @@ describe("aggregation-utils", () => {
560
599
  createBucket({ startMs: HOUR, durationMs: HOUR, sourceTier: "hourly" }),
561
600
  ];
562
601
 
602
+ const mocks = createMockRegistries();
563
603
  const result = combineBuckets({
604
+ ...mocks,
564
605
  buckets,
565
606
  targetBucketStart: new Date(0),
566
607
  targetBucketEndMs: 2 * HOUR,
@@ -576,7 +617,9 @@ describe("aggregation-utils", () => {
576
617
  const HOUR = 60 * MINUTE;
577
618
 
578
619
  it("returns empty array for empty input", () => {
620
+ const mocks = createMockRegistries();
579
621
  const result = reaggregateBuckets({
622
+ ...mocks,
580
623
  sourceBuckets: [],
581
624
  targetIntervalMs: HOUR,
582
625
  rangeStart: new Date(0),
@@ -609,7 +652,9 @@ describe("aggregation-utils", () => {
609
652
  }),
610
653
  ];
611
654
 
655
+ const mocks = createMockRegistries();
612
656
  const result = reaggregateBuckets({
657
+ ...mocks,
613
658
  sourceBuckets,
614
659
  targetIntervalMs: HOUR,
615
660
  rangeStart: new Date(0),
@@ -643,7 +688,9 @@ describe("aggregation-utils", () => {
643
688
  }),
644
689
  ];
645
690
 
691
+ const mocks = createMockRegistries();
646
692
  const result = reaggregateBuckets({
693
+ ...mocks,
647
694
  sourceBuckets,
648
695
  targetIntervalMs: HOUR,
649
696
  rangeStart: new Date(0),
@@ -670,7 +717,9 @@ describe("aggregation-utils", () => {
670
717
  }),
671
718
  ];
672
719
 
720
+ const mocks = createMockRegistries();
673
721
  const result = reaggregateBuckets({
722
+ ...mocks,
674
723
  sourceBuckets,
675
724
  targetIntervalMs: HOUR,
676
725
  rangeStart,
@@ -693,7 +742,9 @@ describe("aggregation-utils", () => {
693
742
  createBucket({ startMs: HOUR, durationMs: MINUTE, sourceTier: "raw" }),
694
743
  ];
695
744
 
745
+ const mocks = createMockRegistries();
696
746
  const result = reaggregateBuckets({
747
+ ...mocks,
697
748
  sourceBuckets,
698
749
  targetIntervalMs: HOUR,
699
750
  rangeStart: new Date(0),
@@ -706,4 +757,228 @@ describe("aggregation-utils", () => {
706
757
  expect(result[2].bucketStart.getTime()).toBe(2 * HOUR);
707
758
  });
708
759
  });
760
+
761
+ describe("mergeAggregatedBucketResults - strategy metadata merging", () => {
762
+ it("merges strategy-level fields using strategy's mergeAggregatedStates", () => {
763
+ // Create a strategy with VersionedAggregated that tracks error counts
764
+ const strategyAggregatedResult = new VersionedAggregated({
765
+ version: 1,
766
+ fields: {
767
+ errorCount: aggregatedCounter({}),
768
+ avgResponseTime: aggregatedAverage({}),
769
+ },
770
+ });
771
+
772
+ const mockRegistry: HealthCheckRegistry = {
773
+ getStrategy: mock(() => ({
774
+ id: "test-strategy",
775
+ displayName: "Test",
776
+ description: "Test",
777
+ config: { version: 1 } as never,
778
+ result: { version: 1 } as never,
779
+ aggregatedResult: strategyAggregatedResult,
780
+ createClient: mock() as never,
781
+ mergeResult: mock() as never,
782
+ })),
783
+ register: mock(() => {}),
784
+ getStrategies: mock(() => []),
785
+ getStrategiesWithMeta: mock(() => []),
786
+ };
787
+
788
+ const mockCollectorRegistry: CollectorRegistry = {
789
+ register: mock(() => {}),
790
+ getCollector: mock(() => undefined),
791
+ getCollectors: mock(() => []),
792
+ getCollectorsForPlugin: mock(() => []),
793
+ };
794
+
795
+ // Two buckets with strategy-level aggregated data
796
+ const bucket1 = {
797
+ errorCount: { count: 5 },
798
+ avgResponseTime: { _sum: 500, _count: 10, avg: 50 },
799
+ };
800
+
801
+ const bucket2 = {
802
+ errorCount: { count: 3 },
803
+ avgResponseTime: { _sum: 300, _count: 5, avg: 60 },
804
+ };
805
+
806
+ const result = mergeAggregatedBucketResults({
807
+ aggregatedResults: [bucket1, bucket2],
808
+ collectorRegistry: mockCollectorRegistry,
809
+ registry: mockRegistry,
810
+ strategyId: "test-strategy",
811
+ });
812
+
813
+ expect(result).toBeDefined();
814
+ // Error count should be summed: 5 + 3 = 8
815
+ expect((result as Record<string, unknown>).errorCount).toEqual({
816
+ _type: "counter",
817
+ count: 8,
818
+ });
819
+ // Average should be recomputed: (500 + 300) / (10 + 5) = 53.33
820
+ const avgResult = (result as Record<string, unknown>)
821
+ .avgResponseTime as Record<string, number>;
822
+ expect(avgResult._sum).toBe(800);
823
+ expect(avgResult._count).toBe(15);
824
+ expect(avgResult.avg).toBeCloseTo(53.33, 1);
825
+ });
826
+
827
+ it("preserves strategy fields when only one bucket exists", () => {
828
+ const mocks = createMockRegistries();
829
+
830
+ const singleBucket = {
831
+ errorCount: { count: 5 },
832
+ avgResponseTime: { _sum: 500, _count: 10, avg: 50 },
833
+ };
834
+
835
+ const result = mergeAggregatedBucketResults({
836
+ aggregatedResults: [singleBucket],
837
+ ...mocks,
838
+ });
839
+
840
+ expect(result).toEqual(singleBucket);
841
+ });
842
+
843
+ it("returns undefined for empty aggregated results", () => {
844
+ const mocks = createMockRegistries();
845
+
846
+ const result = mergeAggregatedBucketResults({
847
+ aggregatedResults: [],
848
+ ...mocks,
849
+ });
850
+
851
+ expect(result).toBeUndefined();
852
+ });
853
+
854
+ it("merges both strategy and collector fields together", () => {
855
+ // Create a strategy with VersionedAggregated
856
+ const strategyAggregatedResult = new VersionedAggregated({
857
+ version: 1,
858
+ fields: { errorCount: aggregatedCounter({}) },
859
+ });
860
+
861
+ // Create a collector with VersionedAggregated
862
+ const collectorAggregatedResult = new VersionedAggregated({
863
+ version: 1,
864
+ fields: { cpuUsage: aggregatedAverage({}) },
865
+ });
866
+
867
+ const mockRegistry: HealthCheckRegistry = {
868
+ getStrategy: mock(() => ({
869
+ id: "test-strategy",
870
+ displayName: "Test",
871
+ description: "Test",
872
+ config: { version: 1 } as never,
873
+ result: { version: 1 } as never,
874
+ aggregatedResult: strategyAggregatedResult,
875
+ createClient: mock() as never,
876
+ mergeResult: mock() as never,
877
+ })),
878
+ register: mock(() => {}),
879
+ getStrategies: mock(() => []),
880
+ getStrategiesWithMeta: mock(() => []),
881
+ };
882
+
883
+ const mockCollectorRegistry = {
884
+ register: mock(() => {}),
885
+ getCollector: mock((id: string) => {
886
+ if (id === "test-collector") {
887
+ return {
888
+ collector: {
889
+ id: "test-collector",
890
+ displayName: "Test Collector",
891
+ description: "Test",
892
+ result: { version: 1 },
893
+ aggregatedResult: collectorAggregatedResult,
894
+ mergeResult: mock(),
895
+ },
896
+ qualifiedId: "test-plugin.test-collector",
897
+ ownerPlugin: "test-plugin",
898
+ pluginId: "test-plugin",
899
+ };
900
+ }
901
+ return undefined;
902
+ }),
903
+ getCollectors: mock(() => []),
904
+ getCollectorsForPlugin: mock(() => []),
905
+ } as unknown as CollectorRegistry;
906
+
907
+ // Two buckets with both strategy and collector data
908
+ const bucket1 = {
909
+ errorCount: { count: 2 },
910
+ collectors: {
911
+ "uuid-1": {
912
+ _collectorId: "test-collector",
913
+ cpuUsage: { _sum: 50, _count: 5, avg: 10 },
914
+ },
915
+ },
916
+ };
917
+
918
+ const bucket2 = {
919
+ errorCount: { count: 3 },
920
+ collectors: {
921
+ "uuid-1": {
922
+ _collectorId: "test-collector",
923
+ cpuUsage: { _sum: 100, _count: 10, avg: 10 },
924
+ },
925
+ },
926
+ };
927
+
928
+ const result = mergeAggregatedBucketResults({
929
+ aggregatedResults: [bucket1, bucket2],
930
+ collectorRegistry: mockCollectorRegistry,
931
+ registry: mockRegistry,
932
+ strategyId: "test-strategy",
933
+ });
934
+
935
+ expect(result).toBeDefined();
936
+ const typedResult = result as Record<string, unknown>;
937
+
938
+ // Strategy-level field merged
939
+ expect(typedResult.errorCount).toEqual({ _type: "counter", count: 5 });
940
+
941
+ // Collector-level field merged
942
+ const collectors = typedResult.collectors as Record<
943
+ string,
944
+ Record<string, unknown>
945
+ >;
946
+ expect(collectors["uuid-1"]._collectorId).toBe("test-collector");
947
+ const cpuUsage = collectors["uuid-1"].cpuUsage as Record<string, number>;
948
+ expect(cpuUsage._sum).toBe(150);
949
+ expect(cpuUsage._count).toBe(15);
950
+ });
951
+
952
+ it("falls back to preserving first result when strategy not found", () => {
953
+ const mockRegistry: HealthCheckRegistry = {
954
+ getStrategy: mock(() => undefined), // Strategy not found
955
+ register: mock(() => {}),
956
+ getStrategies: mock(() => []),
957
+ getStrategiesWithMeta: mock(() => []),
958
+ };
959
+
960
+ const mockCollectorRegistry: CollectorRegistry = {
961
+ register: mock(() => {}),
962
+ getCollector: mock(() => undefined),
963
+ getCollectors: mock(() => []),
964
+ getCollectorsForPlugin: mock(() => []),
965
+ };
966
+
967
+ const bucket1 = { errorCount: { count: 5 } };
968
+ const bucket2 = { errorCount: { count: 3 } };
969
+
970
+ const result = mergeAggregatedBucketResults({
971
+ aggregatedResults: [bucket1, bucket2],
972
+ collectorRegistry: mockCollectorRegistry,
973
+ registry: mockRegistry,
974
+ strategyId: "unknown-strategy",
975
+ });
976
+
977
+ // Should preserve the first bucket's strategy fields (fallback behavior)
978
+ expect(result).toBeDefined();
979
+ expect((result as Record<string, unknown>).errorCount).toEqual({
980
+ count: 5,
981
+ });
982
+ });
983
+ });
709
984
  });
@@ -1,4 +1,7 @@
1
- import type { CollectorRegistry } from "@checkstack/backend-api";
1
+ import type {
2
+ CollectorRegistry,
3
+ HealthCheckRegistry,
4
+ } from "@checkstack/backend-api";
2
5
 
3
6
  // ===== Percentile Calculation =====
4
7
 
@@ -163,6 +166,156 @@ export function aggregateCollectorData(
163
166
  return result;
164
167
  }
165
168
 
169
+ // ===== Bucket Result Merging =====
170
+
171
+ /**
172
+ * Merge pre-computed aggregated results from multiple buckets.
173
+ * Uses each collector's VersionedAggregated.mergeAggregatedStates for precise re-aggregation.
174
+ * Also merges strategy-level fields using the strategy's mergeAggregatedStates.
175
+ *
176
+ * @param aggregatedResults - Array of aggregatedResult objects from buckets
177
+ * @param collectorRegistry - Registry to look up collector merge functions
178
+ * @param registry - HealthCheckRegistry for strategy-level merging
179
+ * @param strategyId - Strategy ID for strategy-level merging
180
+ * @returns Merged aggregated result, or undefined if no data
181
+ */
182
+ export function mergeAggregatedBucketResults(params: {
183
+ aggregatedResults: Array<Record<string, unknown> | undefined>;
184
+ collectorRegistry: CollectorRegistry;
185
+ registry: HealthCheckRegistry;
186
+ strategyId: string;
187
+ }): Record<string, unknown> | undefined {
188
+ const { aggregatedResults, collectorRegistry, registry, strategyId } = params;
189
+
190
+ // Filter out undefined results
191
+ const validResults = aggregatedResults.filter(
192
+ (r): r is Record<string, unknown> => r !== undefined,
193
+ );
194
+
195
+ if (validResults.length === 0) {
196
+ return undefined;
197
+ }
198
+
199
+ // If only one result, return it directly
200
+ if (validResults.length === 1) {
201
+ return validResults[0];
202
+ }
203
+
204
+ // === Strategy-level field merging ===
205
+ let mergedStrategyFields: Record<string, unknown> = {};
206
+
207
+ const registeredStrategy = registry.getStrategy(strategyId);
208
+ if (registeredStrategy?.aggregatedResult) {
209
+ // Extract strategy-level fields (everything except 'collectors')
210
+ const strategyDataSets: Array<Record<string, unknown>> = [];
211
+ for (const result of validResults) {
212
+ const { collectors: _collectors, ...strategyFields } = result;
213
+ if (Object.keys(strategyFields).length > 0) {
214
+ strategyDataSets.push(strategyFields);
215
+ }
216
+ }
217
+
218
+ // Merge strategy-level fields using the strategy's mergeAggregatedStates
219
+ if (strategyDataSets.length > 0) {
220
+ let merged: Record<string, unknown> | undefined;
221
+ for (const data of strategyDataSets) {
222
+ merged =
223
+ merged === undefined
224
+ ? data
225
+ : registeredStrategy.aggregatedResult.mergeAggregatedStates(
226
+ merged,
227
+ data,
228
+ );
229
+ }
230
+ if (merged) {
231
+ mergedStrategyFields = merged;
232
+ }
233
+ }
234
+ } else {
235
+ // Strategy not found - preserve strategy fields from first result
236
+ const { collectors: _collectors, ...firstStrategyFields } = validResults[0];
237
+ mergedStrategyFields = firstStrategyFields;
238
+ }
239
+
240
+ // === Collector-level field merging ===
241
+
242
+ // Extract collectors from each result
243
+ const allCollectors = validResults.map(
244
+ (r) => (r.collectors ?? {}) as Record<string, Record<string, unknown>>,
245
+ );
246
+
247
+ // Find all unique collector UUIDs across all results
248
+ const allUuids = new Set<string>();
249
+ for (const collectors of allCollectors) {
250
+ for (const uuid of Object.keys(collectors)) {
251
+ allUuids.add(uuid);
252
+ }
253
+ }
254
+
255
+ // Merge each collector's data
256
+ const mergedCollectors: Record<string, Record<string, unknown>> = {};
257
+
258
+ for (const uuid of allUuids) {
259
+ // Collect all data for this UUID
260
+ const dataForUuid: Array<Record<string, unknown>> = [];
261
+ let collectorId: string | undefined;
262
+
263
+ for (const collectors of allCollectors) {
264
+ const data = collectors[uuid];
265
+ if (data) {
266
+ collectorId = collectorId ?? (data._collectorId as string);
267
+ dataForUuid.push(data);
268
+ }
269
+ }
270
+
271
+ if (!collectorId || dataForUuid.length === 0) {
272
+ continue;
273
+ }
274
+
275
+ // Get the collector's merge function
276
+ const registered = collectorRegistry.getCollector(collectorId);
277
+ if (!registered?.collector.aggregatedResult) {
278
+ // Can't merge, preserve first data
279
+ mergedCollectors[uuid] = dataForUuid[0];
280
+ continue;
281
+ }
282
+
283
+ // Merge all data using mergeAggregatedStates
284
+ // Strip _collectorId before merging
285
+ let merged: Record<string, unknown> | undefined;
286
+ for (const data of dataForUuid) {
287
+ const { _collectorId, ...stateData } = data;
288
+ merged =
289
+ merged === undefined
290
+ ? stateData
291
+ : registered.collector.aggregatedResult.mergeAggregatedStates(
292
+ merged,
293
+ stateData,
294
+ );
295
+ }
296
+
297
+ if (merged) {
298
+ mergedCollectors[uuid] = {
299
+ _collectorId: collectorId,
300
+ ...merged,
301
+ };
302
+ }
303
+ }
304
+
305
+ // Combine strategy fields and collector fields
306
+ const hasCollectors = Object.keys(mergedCollectors).length > 0;
307
+ const hasStrategyFields = Object.keys(mergedStrategyFields).length > 0;
308
+
309
+ if (!hasCollectors && !hasStrategyFields) {
310
+ return undefined;
311
+ }
312
+
313
+ return {
314
+ ...mergedStrategyFields,
315
+ ...(hasCollectors ? { collectors: mergedCollectors } : {}),
316
+ };
317
+ }
318
+
166
319
  // ===== Cross-Tier Aggregation =====
167
320
 
168
321
  /**
@@ -316,13 +469,26 @@ export function mergeTieredBuckets(params: {
316
469
  /**
317
470
  * Combine multiple buckets into a single bucket.
318
471
  * Used when re-aggregating smaller buckets into larger target buckets.
472
+ *
473
+ * Uses automatic merging via each strategy's and collector's
474
+ * VersionedAggregated.mergeAggregatedStates method.
319
475
  */
320
476
  export function combineBuckets(params: {
321
477
  buckets: NormalizedBucket[];
322
478
  targetBucketStart: Date;
323
479
  targetBucketEndMs: number;
480
+ collectorRegistry: CollectorRegistry;
481
+ registry: HealthCheckRegistry;
482
+ strategyId: string;
324
483
  }): NormalizedBucket {
325
- const { buckets, targetBucketStart, targetBucketEndMs } = params;
484
+ const {
485
+ buckets,
486
+ targetBucketStart,
487
+ targetBucketEndMs,
488
+ collectorRegistry,
489
+ registry,
490
+ strategyId,
491
+ } = params;
326
492
 
327
493
  if (buckets.length === 0) {
328
494
  return {
@@ -356,7 +522,7 @@ export function combineBuckets(params: {
356
522
  // Track which tier the data primarily comes from
357
523
  let lowestPriorityTier: NormalizedBucket["sourceTier"] = "raw";
358
524
 
359
- // Track aggregatedResults - only preserve if single bucket or all from raw
525
+ // Collect aggregatedResults for merging
360
526
  const aggregatedResults: Array<Record<string, unknown> | undefined> = [];
361
527
 
362
528
  for (const bucket of buckets) {
@@ -388,18 +554,13 @@ export function combineBuckets(params: {
388
554
  aggregatedResults.push(bucket.aggregatedResult);
389
555
  }
390
556
 
391
- // Preserve aggregatedResult if there's exactly one bucket (no re-aggregation needed)
392
- // or if there's exactly one non-undefined result and all buckets are raw
393
- let preservedAggregatedResult: Record<string, unknown> | undefined;
394
- if (buckets.length === 1) {
395
- preservedAggregatedResult = buckets[0].aggregatedResult;
396
- } else if (
397
- lowestPriorityTier === "raw" &&
398
- aggregatedResults.filter((r) => r !== undefined).length === 1
399
- ) {
400
- // All raw buckets, and exactly one has aggregatedResult
401
- preservedAggregatedResult = aggregatedResults.find((r) => r !== undefined);
402
- }
557
+ // Merge aggregatedResults using registries for precise re-aggregation
558
+ const mergedAggregatedResult = mergeAggregatedBucketResults({
559
+ aggregatedResults,
560
+ collectorRegistry,
561
+ registry,
562
+ strategyId,
563
+ });
403
564
 
404
565
  return {
405
566
  bucketStart: targetBucketStart,
@@ -413,8 +574,8 @@ export function combineBuckets(params: {
413
574
  maxLatencyMs: maxValues.length > 0 ? Math.max(...maxValues) : undefined,
414
575
  // Use max of p95s as conservative upper-bound approximation
415
576
  p95LatencyMs: p95Values.length > 0 ? Math.max(...p95Values) : undefined,
416
- // Preserve aggregatedResult only when no actual re-aggregation is needed
417
- aggregatedResult: preservedAggregatedResult,
577
+ // Automatically merged aggregatedResult
578
+ aggregatedResult: mergedAggregatedResult,
418
579
  sourceTier: lowestPriorityTier,
419
580
  };
420
581
  }
@@ -423,6 +584,9 @@ export function combineBuckets(params: {
423
584
  * Re-aggregate a list of normalized buckets into target-sized buckets.
424
585
  * Groups source buckets by target bucket boundaries and combines them.
425
586
  *
587
+ * Uses automatic merging of aggregatedResult via each strategy's and
588
+ * collector's VersionedAggregated.mergeAggregatedStates method.
589
+ *
426
590
  * @param rangeEnd - The end of the query range. The last bucket will extend
427
591
  * to this time to ensure data is visually represented up to the query end.
428
592
  */
@@ -431,8 +595,19 @@ export function reaggregateBuckets(params: {
431
595
  targetIntervalMs: number;
432
596
  rangeStart: Date;
433
597
  rangeEnd: Date;
598
+ collectorRegistry: CollectorRegistry;
599
+ registry: HealthCheckRegistry;
600
+ strategyId: string;
434
601
  }): NormalizedBucket[] {
435
- const { sourceBuckets, targetIntervalMs, rangeStart, rangeEnd } = params;
602
+ const {
603
+ sourceBuckets,
604
+ targetIntervalMs,
605
+ rangeStart,
606
+ rangeEnd,
607
+ collectorRegistry,
608
+ registry,
609
+ strategyId,
610
+ } = params;
436
611
 
437
612
  if (sourceBuckets.length === 0) {
438
613
  return [];
@@ -473,6 +648,9 @@ export function reaggregateBuckets(params: {
473
648
  buckets,
474
649
  targetBucketStart,
475
650
  targetBucketEndMs,
651
+ collectorRegistry,
652
+ registry,
653
+ strategyId,
476
654
  }),
477
655
  );
478
656
  }
@@ -5,6 +5,7 @@ describe("HealthCheckService.getAggregatedHistory", () => {
5
5
  // Mock database and registry
6
6
  let mockDb: ReturnType<typeof createMockDb>;
7
7
  let mockRegistry: ReturnType<typeof createMockRegistry>;
8
+ let mockCollectorRegistry: { getCollector: ReturnType<typeof mock> };
8
9
  let service: HealthCheckService;
9
10
  // Store mock data for different queries
10
11
  let mockConfigResult: { id: string; strategyId: string } | null = null;
@@ -78,7 +79,12 @@ describe("HealthCheckService.getAggregatedHistory", () => {
78
79
  mockDailyAggregates = [];
79
80
  mockDb = createMockDb();
80
81
  mockRegistry = createMockRegistry();
81
- service = new HealthCheckService(mockDb as never, mockRegistry as never);
82
+ mockCollectorRegistry = { getCollector: mock(() => undefined) };
83
+ service = new HealthCheckService(
84
+ mockDb as never,
85
+ mockRegistry as never,
86
+ mockCollectorRegistry as never,
87
+ );
82
88
  });
83
89
 
84
90
  describe("dynamic bucket interval calculation", () => {
@@ -59,7 +59,7 @@ describe("HealthCheckService.getAvailabilityStats", () => {
59
59
  mockDailyAggregates = [];
60
60
  mockRetentionConfig = undefined;
61
61
  mockDb = createMockDb();
62
- service = new HealthCheckService(mockDb as never);
62
+ service = new HealthCheckService(mockDb as never, {} as never, {} as never);
63
63
  });
64
64
 
65
65
  describe("with no data", () => {
package/src/index.ts CHANGED
@@ -184,6 +184,7 @@ export default createBackendPlugin({
184
184
  onHook,
185
185
  emitHook,
186
186
  healthCheckRegistry,
187
+ collectorRegistry,
187
188
  }) => {
188
189
  // Store emitHook for the queue worker (Closure-based Hook Getter pattern)
189
190
  storedEmitHook = emitHook;
@@ -195,7 +196,11 @@ export default createBackendPlugin({
195
196
  });
196
197
 
197
198
  // Subscribe to catalog system deletion to clean up associations
198
- const service = new HealthCheckService(database, healthCheckRegistry);
199
+ const service = new HealthCheckService(
200
+ database,
201
+ healthCheckRegistry,
202
+ collectorRegistry,
203
+ );
199
204
  onHook(
200
205
  catalogHooks.systemDeleted,
201
206
  async (payload) => {
@@ -14,6 +14,8 @@ import {
14
14
  import {
15
15
  type HealthCheckRegistry,
16
16
  Versioned,
17
+ VersionedAggregated,
18
+ aggregatedCounter,
17
19
  z,
18
20
  } from "@checkstack/backend-api";
19
21
  import { mock } from "bun:test";
@@ -26,15 +28,15 @@ const createMockRegistry = (): HealthCheckRegistry => ({
26
28
  description: "Mock",
27
29
  config: new Versioned({
28
30
  version: 1,
29
- schema: z.object({}),
31
+ schema: z.object({ timeout: z.number().default(30_000) }),
30
32
  }),
31
33
  result: new Versioned({
32
34
  version: 1,
33
35
  schema: z.object({}),
34
36
  }),
35
- aggregatedResult: new Versioned({
37
+ aggregatedResult: new VersionedAggregated({
36
38
  version: 1,
37
- schema: z.object({}),
39
+ fields: { count: aggregatedCounter({}) },
38
40
  }),
39
41
  createClient: mock(async () => ({
40
42
  client: {
@@ -5,6 +5,9 @@ import {
5
5
  type CollectorRegistry,
6
6
  evaluateAssertions,
7
7
  type SafeDatabase,
8
+ type BaseStrategyConfig,
9
+ type ConnectedClient,
10
+ type TransportClient,
8
11
  } from "@checkstack/backend-api";
9
12
  import { QueueManager } from "@checkstack/queue-api";
10
13
  import {
@@ -237,7 +240,7 @@ async function executeHealthCheckJob(props: {
237
240
  const { configId, systemId } = payload;
238
241
 
239
242
  // Create service for aggregated state evaluation
240
- const service = new HealthCheckService(db);
243
+ const service = new HealthCheckService(db, registry, collectorRegistry);
241
244
 
242
245
  // Capture aggregated state BEFORE this run for comparison
243
246
  const previousState = await service.getSystemHealthStatus(systemId);
@@ -305,24 +308,175 @@ async function executeHealthCheckJob(props: {
305
308
  return;
306
309
  }
307
310
 
308
- // Execute health check using createClient pattern
311
+ // Extract timeout from strategy config for platform-level enforcement
312
+ const strategyConfig = configRow.config as unknown as BaseStrategyConfig;
313
+ const executionTimeout = strategyConfig.timeout ?? 60_000;
314
+
315
+ // Execute health check using createClient pattern with unified hard timeout
309
316
  const start = performance.now();
310
- let connectedClient;
317
+ let connectionTimeMs: number | undefined;
318
+ let connectedClient:
319
+ | ConnectedClient<TransportClient<never, unknown>>
320
+ | undefined;
321
+ const collectors = configRow.collectors ?? [];
322
+ const collectorResults: Record<string, unknown> = {};
323
+ let hasCollectorError = false;
324
+ let errorMessage: string | undefined;
325
+
311
326
  try {
312
- connectedClient = await strategy.createClient(
313
- configRow.config as Record<string, unknown>,
314
- );
327
+ // Platform-level hard timeout wrapping the entire execution sequence
328
+ await Promise.race([
329
+ (async () => {
330
+ // 1. Establish connection
331
+ connectedClient = await strategy.createClient(strategyConfig);
332
+ connectionTimeMs = Math.round(performance.now() - start);
333
+
334
+ // 2. Execute collectors in parallel
335
+ const collectorPromises = collectors.map(async (collectorEntry) => {
336
+ const registered = collectorRegistry.getCollector(
337
+ collectorEntry.collectorId,
338
+ );
339
+ if (!registered) {
340
+ logger.warn(
341
+ `Collector ${collectorEntry.collectorId} not found, skipping`,
342
+ );
343
+ return { storageKey: collectorEntry.id, skipped: true };
344
+ }
345
+
346
+ const storageKey = collectorEntry.id;
347
+
348
+ try {
349
+ const collectorResult = await registered.collector.execute({
350
+ config: collectorEntry.config,
351
+ client: connectedClient!.client,
352
+ pluginId: configRow.strategyId,
353
+ });
354
+
355
+ // Check for collector-level error
356
+ let collectorError: string | undefined;
357
+ if (collectorResult.error) {
358
+ collectorError = collectorResult.error;
359
+ }
360
+
361
+ // Evaluate per-collector assertions
362
+ let assertionFailed: string | undefined;
363
+ if (
364
+ collectorEntry.assertions &&
365
+ collectorEntry.assertions.length > 0 &&
366
+ collectorResult.result
367
+ ) {
368
+ const failedAssertion = evaluateAssertions(
369
+ collectorEntry.assertions,
370
+ collectorResult.result as Record<string, unknown>,
371
+ );
372
+ if (failedAssertion) {
373
+ assertionFailed = `${failedAssertion.field} ${
374
+ failedAssertion.operator
375
+ } ${failedAssertion.value ?? ""}`;
376
+ logger.debug(
377
+ `Collector ${storageKey} assertion failed: ${assertionFailed}`,
378
+ );
379
+ }
380
+ }
381
+
382
+ // Strip ephemeral fields before storage
383
+ const strippedResult = stripEphemeralFields(
384
+ collectorResult.result as Record<string, unknown>,
385
+ registered.collector.result.schema,
386
+ );
387
+
388
+ return {
389
+ storageKey,
390
+ skipped: false,
391
+ success: true,
392
+ collectorError,
393
+ assertionFailed,
394
+ result: {
395
+ _collectorId: collectorEntry.collectorId,
396
+ _assertionFailed: assertionFailed,
397
+ ...strippedResult,
398
+ },
399
+ };
400
+ } catch (error) {
401
+ const errorStr =
402
+ error instanceof Error ? error.message : String(error);
403
+ logger.debug(`Collector ${storageKey} failed: ${errorStr}`);
404
+ return {
405
+ storageKey,
406
+ skipped: false,
407
+ success: false,
408
+ error: errorStr,
409
+ result: {
410
+ _collectorId: collectorEntry.collectorId,
411
+ _assertionFailed: undefined,
412
+ error: errorStr,
413
+ },
414
+ };
415
+ }
416
+ });
417
+
418
+ // Wait for all collectors to complete
419
+ const settledResults = await Promise.allSettled(collectorPromises);
420
+
421
+ // Process results from all collectors
422
+ for (const settled of settledResults) {
423
+ if (settled.status === "rejected") {
424
+ // This shouldn't happen since we catch errors above, but handle it
425
+ hasCollectorError = true;
426
+ if (!errorMessage) errorMessage = String(settled.reason);
427
+ continue;
428
+ }
429
+
430
+ const result = settled.value;
431
+ if (result.skipped) continue;
432
+
433
+ // Store the result
434
+ collectorResults[result.storageKey] = result.result;
435
+
436
+ // Track errors
437
+ if (
438
+ !result.success ||
439
+ result.collectorError ||
440
+ result.assertionFailed
441
+ ) {
442
+ hasCollectorError = true;
443
+ if (!errorMessage) {
444
+ errorMessage =
445
+ result.error ||
446
+ result.collectorError ||
447
+ (result.assertionFailed
448
+ ? `Assertion failed: ${result.assertionFailed}`
449
+ : undefined);
450
+ }
451
+ }
452
+ }
453
+ })(),
454
+ new Promise<never>((_, reject) =>
455
+ setTimeout(
456
+ () =>
457
+ reject(
458
+ new Error(`Execution timeout after ${executionTimeout}ms`),
459
+ ),
460
+ executionTimeout,
461
+ ),
462
+ ),
463
+ ]);
315
464
  } catch (error) {
316
- // Connection failed
317
465
  const latencyMs = Math.round(performance.now() - start);
318
- const errorMessage =
319
- error instanceof Error ? error.message : "Connection failed";
466
+ const caughtError =
467
+ error instanceof Error ? error.message : String(error);
468
+
469
+ // Use a specific error message if available, otherwise use the caught error
470
+ const finalError = errorMessage || caughtError;
320
471
 
321
472
  const result = {
322
473
  status: "unhealthy" as const,
323
474
  latencyMs,
324
- message: errorMessage,
325
- metadata: { connected: false, error: errorMessage },
475
+ message: finalError,
476
+ metadata: {
477
+ connected: !!connectedClient,
478
+ error: finalError,
479
+ },
326
480
  };
327
481
 
328
482
  await db.insert(healthCheckRuns).values({
@@ -333,7 +487,6 @@ async function executeHealthCheckJob(props: {
333
487
  result: { ...result } as Record<string, unknown>,
334
488
  });
335
489
 
336
- // Trigger incremental hourly aggregation
337
490
  await incrementHourlyAggregate({
338
491
  db,
339
492
  systemId,
@@ -346,10 +499,9 @@ async function executeHealthCheckJob(props: {
346
499
  });
347
500
 
348
501
  logger.debug(
349
- `Health check ${configId} for system ${systemId} failed: ${errorMessage}`,
502
+ `Health check ${configId} for system ${systemId} failed: ${finalError}`,
350
503
  );
351
504
 
352
- // Broadcast failure signal
353
505
  await signalService.broadcast(HEALTH_CHECK_RUN_COMPLETED, {
354
506
  systemId,
355
507
  systemName,
@@ -359,7 +511,6 @@ async function executeHealthCheckJob(props: {
359
511
  latencyMs: result.latencyMs,
360
512
  });
361
513
 
362
- // Check and notify state change
363
514
  const newState = await service.getSystemHealthStatus(systemId);
364
515
  if (newState.status !== previousStatus) {
365
516
  await notifyStateChange({
@@ -374,98 +525,14 @@ async function executeHealthCheckJob(props: {
374
525
  }
375
526
 
376
527
  return;
377
- }
378
-
379
- const connectionTimeMs = Math.round(performance.now() - start);
380
-
381
- // Execute collectors
382
- const collectors = configRow.collectors ?? [];
383
- const collectorResults: Record<string, unknown> = {};
384
- let hasCollectorError = false;
385
- let errorMessage: string | undefined;
386
-
387
- try {
388
- for (const collectorEntry of collectors) {
389
- const registered = collectorRegistry.getCollector(
390
- collectorEntry.collectorId,
391
- );
392
- if (!registered) {
393
- logger.warn(
394
- `Collector ${collectorEntry.collectorId} not found, skipping`,
395
- );
396
- continue;
397
- }
398
-
399
- // Use the collector's UUID as the storage key
400
- const storageKey = collectorEntry.id;
401
-
528
+ } finally {
529
+ if (connectedClient) {
402
530
  try {
403
- const collectorResult = await registered.collector.execute({
404
- config: collectorEntry.config,
405
- client: connectedClient.client,
406
- pluginId: configRow.strategyId,
407
- });
408
-
409
- // Check for collector-level error
410
- if (collectorResult.error) {
411
- hasCollectorError = true;
412
- errorMessage = collectorResult.error;
413
- }
414
-
415
- // Evaluate per-collector assertions
416
- let assertionFailed: string | undefined;
417
- if (
418
- collectorEntry.assertions &&
419
- collectorEntry.assertions.length > 0 &&
420
- collectorResult.result
421
- ) {
422
- const assertions = collectorEntry.assertions;
423
- const failedAssertion = evaluateAssertions(
424
- assertions,
425
- collectorResult.result as Record<string, unknown>,
426
- );
427
- if (failedAssertion) {
428
- hasCollectorError = true;
429
- assertionFailed = `${failedAssertion.field} ${
430
- failedAssertion.operator
431
- } ${failedAssertion.value ?? ""}`;
432
- errorMessage = `Assertion failed: ${assertionFailed}`;
433
- logger.debug(
434
- `Collector ${storageKey} assertion failed: ${errorMessage}`,
435
- );
436
- }
437
- }
438
-
439
- // Strip ephemeral fields (like HTTP body) before storage to save space
440
- const strippedResult = stripEphemeralFields(
441
- collectorResult.result as Record<string, unknown>,
442
- registered.collector.result.schema,
443
- );
444
-
445
- // Store result under the collector's UUID, with collector type and assertion metadata
446
- collectorResults[storageKey] = {
447
- _collectorId: collectorEntry.collectorId, // Store the type for frontend schema linking
448
- _assertionFailed: assertionFailed, // null if no assertion failed
449
- ...strippedResult,
450
- };
531
+ connectedClient.close();
451
532
  } catch (error) {
452
- hasCollectorError = true;
453
- errorMessage = error instanceof Error ? error.message : String(error);
454
- collectorResults[storageKey] = {
455
- _collectorId: collectorEntry.collectorId,
456
- _assertionFailed: undefined,
457
- error: errorMessage,
458
- };
459
- logger.debug(`Collector ${storageKey} failed: ${errorMessage}`);
533
+ logger.warn(`Failed to close connection: ${error}`);
460
534
  }
461
535
  }
462
- } finally {
463
- // Clean up connection
464
- try {
465
- connectedClient.close();
466
- } catch {
467
- // Ignore close errors
468
- }
469
536
  }
470
537
 
471
538
  // Determine health status based on collector results
@@ -120,7 +120,7 @@ describe("HealthCheckService data ordering", () => {
120
120
  mockRuns = [newRun, midRun, oldRun];
121
121
 
122
122
  const mockDb = createMockDb();
123
- service = new HealthCheckService(mockDb as never, mockRegistry as never);
123
+ service = new HealthCheckService(mockDb as never, mockRegistry as never, {} as never);
124
124
 
125
125
  const result = await service.getSystemHealthOverview("system-1");
126
126
 
@@ -155,7 +155,7 @@ describe("HealthCheckService data ordering", () => {
155
155
  }));
156
156
 
157
157
  const mockDb = createMockDb();
158
- service = new HealthCheckService(mockDb as never, mockRegistry as never);
158
+ service = new HealthCheckService(mockDb as never, mockRegistry as never, {} as never);
159
159
 
160
160
  const result = await service.getSystemHealthOverview("system-1");
161
161
 
@@ -188,7 +188,7 @@ describe("HealthCheckService data ordering", () => {
188
188
  ];
189
189
 
190
190
  const mockDb = createMockDb();
191
- service = new HealthCheckService(mockDb as never, mockRegistry as never);
191
+ service = new HealthCheckService(mockDb as never, mockRegistry as never, {} as never);
192
192
 
193
193
  const result = await service.getHistory({
194
194
  systemId: "sys-1",
@@ -223,7 +223,7 @@ describe("HealthCheckService data ordering", () => {
223
223
  ];
224
224
 
225
225
  const mockDb = createMockDb();
226
- service = new HealthCheckService(mockDb as never, mockRegistry as never);
226
+ service = new HealthCheckService(mockDb as never, mockRegistry as never, {} as never);
227
227
 
228
228
  const result = await service.getHistory({
229
229
  systemId: "sys-1",
@@ -259,7 +259,7 @@ describe("HealthCheckService data ordering", () => {
259
259
  ];
260
260
 
261
261
  const mockDb = createMockDb();
262
- service = new HealthCheckService(mockDb as never, mockRegistry as never);
262
+ service = new HealthCheckService(mockDb as never, mockRegistry as never, {} as never);
263
263
 
264
264
  const result = await service.getDetailedHistory({
265
265
  systemId: "sys-1",
@@ -296,7 +296,7 @@ describe("HealthCheckService data ordering", () => {
296
296
  ];
297
297
 
298
298
  const mockDb = createMockDb();
299
- service = new HealthCheckService(mockDb as never, mockRegistry as never);
299
+ service = new HealthCheckService(mockDb as never, mockRegistry as never, {} as never);
300
300
 
301
301
  const result = await service.getDetailedHistory({
302
302
  systemId: "sys-1",
@@ -15,7 +15,7 @@ describe("HealthCheckService - pause/resume", () => {
15
15
  mockSet = mock(() => ({ where: mockWhere }));
16
16
  mockUpdate = mock(() => ({ set: mockSet }));
17
17
  (mockDb.update as any) = mockUpdate;
18
- service = new HealthCheckService(mockDb as any);
18
+ service = new HealthCheckService(mockDb as never, {} as never, {} as never);
19
19
  });
20
20
 
21
21
  describe("pauseConfiguration", () => {
package/src/service.ts CHANGED
@@ -54,8 +54,8 @@ interface SystemHealthStatusResponse {
54
54
  export class HealthCheckService {
55
55
  constructor(
56
56
  private db: Db,
57
- private registry?: HealthCheckRegistry,
58
- private collectorRegistry?: CollectorRegistry,
57
+ private registry: HealthCheckRegistry,
58
+ private collectorRegistry: CollectorRegistry,
59
59
  ) {}
60
60
 
61
61
  async createConfiguration(
@@ -751,13 +751,18 @@ export class HealthCheckService {
751
751
  dailyBuckets,
752
752
  });
753
753
 
754
- // Re-aggregate to target bucket interval
755
- const targetBuckets = reaggregateBuckets({
756
- sourceBuckets: mergedBuckets,
757
- targetIntervalMs: bucketIntervalMs,
758
- rangeStart: startDate,
759
- rangeEnd: endDate,
760
- });
754
+ // Re-aggregate to target bucket interval with automatic strategy and collector merging
755
+ const targetBuckets = config
756
+ ? reaggregateBuckets({
757
+ sourceBuckets: mergedBuckets,
758
+ targetIntervalMs: bucketIntervalMs,
759
+ rangeStart: startDate,
760
+ rangeEnd: endDate,
761
+ collectorRegistry: this.collectorRegistry,
762
+ registry: this.registry,
763
+ strategyId: config.strategyId,
764
+ })
765
+ : mergedBuckets;
761
766
 
762
767
  // Convert to output format
763
768
  const buckets = targetBuckets.map((bucket) => {