@checkstack/healthcheck-backend 0.8.3 → 0.9.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,33 @@
1
1
  # @checkstack/healthcheck-backend
2
2
 
3
+ ## 0.9.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 3dd1914: Migrate health check strategies to VersionedAggregated with \_type discriminator
8
+
9
+ All 13 health check strategies now use `VersionedAggregated` for their `aggregatedResult` property, enabling automatic bucket merging with 100% mathematical fidelity.
10
+
11
+ **Key changes:**
12
+
13
+ - **`_type` discriminator**: All aggregated state objects now include a required `_type` field (`"average"`, `"rate"`, `"counter"`, `"minmax"`) for reliable type detection
14
+ - The `HealthCheckStrategy` interface now requires `aggregatedResult` to be a `VersionedAggregated<AggregatedResultShape>`
15
+ - Strategy/collector `mergeResult` methods return state objects with `_type` (e.g., `{ _type: "average", _sum, _count, avg }`)
16
+ - `mergeAggregatedBucketResults`, `combineBuckets`, and `reaggregateBuckets` now require `registry` and `strategyId` parameters
17
+ - `HealthCheckService` constructor now requires both `registry` and `collectorRegistry` parameters
18
+ - Frontend `extractComputedValue` now uses `_type` discriminator for robust type detection
19
+
20
+ **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.
21
+
22
+ ### Patch Changes
23
+
24
+ - Updated dependencies [3dd1914]
25
+ - @checkstack/backend-api@0.7.0
26
+ - @checkstack/catalog-backend@0.2.12
27
+ - @checkstack/command-backend@0.1.10
28
+ - @checkstack/integration-backend@0.1.10
29
+ - @checkstack/queue-api@0.2.4
30
+
3
31
  ## 0.8.3
4
32
 
5
33
  ### 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.9.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "scripts": {
@@ -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";
@@ -32,9 +34,9 @@ const createMockRegistry = (): HealthCheckRegistry => ({
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: {
@@ -237,7 +237,7 @@ async function executeHealthCheckJob(props: {
237
237
  const { configId, systemId } = payload;
238
238
 
239
239
  // Create service for aggregated state evaluation
240
- const service = new HealthCheckService(db);
240
+ const service = new HealthCheckService(db, registry, collectorRegistry);
241
241
 
242
242
  // Capture aggregated state BEFORE this run for comparison
243
243
  const previousState = await service.getSystemHealthStatus(systemId);
@@ -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) => {