@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 +28 -0
- package/package.json +1 -1
- package/src/aggregation-utils.test.ts +276 -1
- package/src/aggregation-utils.ts +196 -18
- package/src/aggregation.test.ts +7 -1
- package/src/availability.test.ts +1 -1
- package/src/index.ts +6 -1
- package/src/queue-executor.test.ts +4 -2
- package/src/queue-executor.ts +1 -1
- package/src/service-ordering.test.ts +6 -6
- package/src/service-pause.test.ts +1 -1
- package/src/service.ts +14 -9
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,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
|
});
|
package/src/aggregation-utils.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import type {
|
|
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 {
|
|
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
|
-
//
|
|
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
|
-
//
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
//
|
|
417
|
-
aggregatedResult:
|
|
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 {
|
|
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
|
}
|
package/src/aggregation.test.ts
CHANGED
|
@@ -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
|
-
|
|
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", () => {
|
package/src/availability.test.ts
CHANGED
|
@@ -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(
|
|
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
|
|
37
|
+
aggregatedResult: new VersionedAggregated({
|
|
36
38
|
version: 1,
|
|
37
|
-
|
|
39
|
+
fields: { count: aggregatedCounter({}) },
|
|
38
40
|
}),
|
|
39
41
|
createClient: mock(async () => ({
|
|
40
42
|
client: {
|
package/src/queue-executor.ts
CHANGED
|
@@ -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
|
|
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
|
|
58
|
-
private 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 =
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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) => {
|