@checkstack/healthcheck-grpc-backend 0.1.13 → 0.2.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,59 @@
1
1
  # @checkstack/healthcheck-grpc-backend
2
2
 
3
+ ## 0.2.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
+
27
+ ## 0.1.14
28
+
29
+ ### Patch Changes
30
+
31
+ - 48c2080: Migrate aggregation from batch to incremental (`mergeResult`)
32
+
33
+ ### Breaking Changes (Internal)
34
+
35
+ - Replaced `aggregateResult(runs[])` with `mergeResult(existing, run)` interface across all HealthCheckStrategy and CollectorStrategy implementations
36
+
37
+ ### New Features
38
+
39
+ - Added incremental aggregation utilities in `@checkstack/backend-api`:
40
+ - `mergeCounter()` - track occurrences
41
+ - `mergeAverage()` - track sum/count, compute avg
42
+ - `mergeRate()` - track success/total, compute %
43
+ - `mergeMinMax()` - track min/max values
44
+ - Exported Zod schemas for internal state: `averageStateSchema`, `rateStateSchema`, `minMaxStateSchema`, `counterStateSchema`
45
+
46
+ ### Improvements
47
+
48
+ - Enables O(1) storage overhead by maintaining incremental aggregation state
49
+ - Prepares for real-time hourly aggregation without batch accumulation
50
+
51
+ - Updated dependencies [f676e11]
52
+ - Updated dependencies [48c2080]
53
+ - @checkstack/common@0.6.2
54
+ - @checkstack/backend-api@0.6.0
55
+ - @checkstack/healthcheck-common@0.8.2
56
+
3
57
  ## 0.1.13
4
58
 
5
59
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-grpc-backend",
3
- "version": "0.1.13",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "scripts": {
@@ -9,15 +9,15 @@
9
9
  "lint:code": "eslint . --max-warnings 0"
10
10
  },
11
11
  "dependencies": {
12
- "@checkstack/backend-api": "0.5.1",
13
- "@checkstack/common": "0.6.0",
14
- "@checkstack/healthcheck-common": "0.7.0",
12
+ "@checkstack/backend-api": "0.5.2",
13
+ "@checkstack/common": "0.6.1",
14
+ "@checkstack/healthcheck-common": "0.8.1",
15
15
  "@grpc/grpc-js": "^1.9.0"
16
16
  },
17
17
  "devDependencies": {
18
18
  "@types/bun": "^1.0.0",
19
19
  "typescript": "^5.0.0",
20
- "@checkstack/tsconfig": "0.0.2",
21
- "@checkstack/scripts": "0.1.0"
20
+ "@checkstack/tsconfig": "0.0.3",
21
+ "@checkstack/scripts": "0.1.1"
22
22
  }
23
23
  }
@@ -65,7 +65,7 @@ describe("HealthCollector", () => {
65
65
  });
66
66
  });
67
67
 
68
- describe("aggregateResult", () => {
68
+ describe("mergeResult", () => {
69
69
  it("should calculate average response time and serving rate", () => {
70
70
  const collector = new HealthCollector();
71
71
  const runs = [
@@ -87,10 +87,11 @@ describe("HealthCollector", () => {
87
87
  },
88
88
  ];
89
89
 
90
- const aggregated = collector.aggregateResult(runs);
90
+ let aggregated = collector.mergeResult(undefined, runs[0]);
91
+ aggregated = collector.mergeResult(aggregated, runs[1]);
91
92
 
92
- expect(aggregated.avgResponseTimeMs).toBe(75);
93
- expect(aggregated.servingRate).toBe(100);
93
+ expect(aggregated.avgResponseTimeMs.avg).toBe(75);
94
+ expect(aggregated.servingRate.rate).toBe(100);
94
95
  });
95
96
 
96
97
  it("should calculate serving rate correctly", () => {
@@ -118,9 +119,10 @@ describe("HealthCollector", () => {
118
119
  },
119
120
  ];
120
121
 
121
- const aggregated = collector.aggregateResult(runs);
122
+ let aggregated = collector.mergeResult(undefined, runs[0]);
123
+ aggregated = collector.mergeResult(aggregated, runs[1]);
122
124
 
123
- expect(aggregated.servingRate).toBe(50);
125
+ expect(aggregated.servingRate.rate).toBe(50);
124
126
  });
125
127
  });
126
128
 
@@ -4,6 +4,12 @@ import {
4
4
  type HealthCheckRunForAggregation,
5
5
  type CollectorResult,
6
6
  type CollectorStrategy,
7
+ mergeAverage,
8
+ mergeRate,
9
+ VersionedAggregated,
10
+ aggregatedAverage,
11
+ aggregatedRate,
12
+ type InferAggregatedResult,
7
13
  } from "@checkstack/backend-api";
8
14
  import {
9
15
  healthResultNumber,
@@ -49,20 +55,24 @@ const grpcHealthResultSchema = healthResultSchema({
49
55
 
50
56
  export type HealthResult = z.infer<typeof grpcHealthResultSchema>;
51
57
 
52
- const healthAggregatedSchema = healthResultSchema({
53
- avgResponseTimeMs: healthResultNumber({
58
+ // Aggregated result fields definition
59
+ const healthAggregatedFields = {
60
+ avgResponseTimeMs: aggregatedAverage({
54
61
  "x-chart-type": "line",
55
62
  "x-chart-label": "Avg Response Time",
56
63
  "x-chart-unit": "ms",
57
64
  }),
58
- servingRate: healthResultNumber({
65
+ servingRate: aggregatedRate({
59
66
  "x-chart-type": "gauge",
60
67
  "x-chart-label": "Serving Rate",
61
68
  "x-chart-unit": "%",
62
69
  }),
63
- });
70
+ };
64
71
 
65
- export type HealthAggregatedResult = z.infer<typeof healthAggregatedSchema>;
72
+ // Type inferred from field definitions
73
+ export type HealthAggregatedResult = InferAggregatedResult<
74
+ typeof healthAggregatedFields
75
+ >;
66
76
 
67
77
  // ============================================================================
68
78
  // HEALTH COLLECTOR
@@ -72,15 +82,12 @@ export type HealthAggregatedResult = z.infer<typeof healthAggregatedSchema>;
72
82
  * Built-in gRPC health collector.
73
83
  * Checks gRPC health status using the standard Health Checking Protocol.
74
84
  */
75
- export class HealthCollector
76
- implements
77
- CollectorStrategy<
78
- GrpcTransportClient,
79
- HealthConfig,
80
- HealthResult,
81
- HealthAggregatedResult
82
- >
83
- {
85
+ export class HealthCollector implements CollectorStrategy<
86
+ GrpcTransportClient,
87
+ HealthConfig,
88
+ HealthResult,
89
+ HealthAggregatedResult
90
+ > {
84
91
  id = "health";
85
92
  displayName = "gRPC Health Check";
86
93
  description = "Check gRPC service health status";
@@ -91,9 +98,9 @@ export class HealthCollector
91
98
 
92
99
  config = new Versioned({ version: 1, schema: healthConfigSchema });
93
100
  result = new Versioned({ version: 1, schema: grpcHealthResultSchema });
94
- aggregatedResult = new Versioned({
101
+ aggregatedResult = new VersionedAggregated({
95
102
  version: 1,
96
- schema: healthAggregatedSchema,
103
+ fields: healthAggregatedFields,
97
104
  });
98
105
 
99
106
  async execute({
@@ -124,28 +131,18 @@ export class HealthCollector
124
131
  };
125
132
  }
126
133
 
127
- aggregateResult(
128
- runs: HealthCheckRunForAggregation<HealthResult>[]
134
+ mergeResult(
135
+ existing: HealthAggregatedResult | undefined,
136
+ run: HealthCheckRunForAggregation<HealthResult>,
129
137
  ): HealthAggregatedResult {
130
- const times = runs
131
- .map((r) => r.metadata?.responseTimeMs)
132
- .filter((v): v is number => typeof v === "number");
133
-
134
- const servingResults = runs
135
- .map((r) => r.metadata?.serving)
136
- .filter((v): v is boolean => typeof v === "boolean");
137
-
138
- const servingCount = servingResults.filter(Boolean).length;
138
+ const metadata = run.metadata;
139
139
 
140
140
  return {
141
- avgResponseTimeMs:
142
- times.length > 0
143
- ? Math.round(times.reduce((a, b) => a + b, 0) / times.length)
144
- : 0,
145
- servingRate:
146
- servingResults.length > 0
147
- ? Math.round((servingCount / servingResults.length) * 100)
148
- : 0,
141
+ avgResponseTimeMs: mergeAverage(
142
+ existing?.avgResponseTimeMs,
143
+ metadata?.responseTimeMs,
144
+ ),
145
+ servingRate: mergeRate(existing?.servingRate, metadata?.serving),
149
146
  };
150
147
  }
151
148
  }
@@ -111,7 +111,7 @@ describe("GrpcHealthCheckStrategy", () => {
111
111
  });
112
112
  });
113
113
 
114
- describe("aggregateResult", () => {
114
+ describe("mergeResult", () => {
115
115
  it("should calculate averages correctly", () => {
116
116
  const strategy = new GrpcHealthCheckStrategy();
117
117
  const runs = [
@@ -141,12 +141,13 @@ describe("GrpcHealthCheckStrategy", () => {
141
141
  },
142
142
  ];
143
143
 
144
- const aggregated = strategy.aggregateResult(runs);
144
+ let aggregated = strategy.mergeResult(undefined, runs[0]);
145
+ aggregated = strategy.mergeResult(aggregated, runs[1]);
145
146
 
146
- expect(aggregated.avgResponseTime).toBe(10);
147
- expect(aggregated.successRate).toBe(100);
148
- expect(aggregated.servingCount).toBe(2);
149
- expect(aggregated.errorCount).toBe(0);
147
+ expect(aggregated.avgResponseTime.avg).toBe(10);
148
+ expect(aggregated.successRate.rate).toBe(100);
149
+ expect(aggregated.servingCount.count).toBe(2);
150
+ expect(aggregated.errorCount.count).toBe(0);
150
151
  });
151
152
 
152
153
  it("should count errors and non-serving", () => {
@@ -179,11 +180,12 @@ describe("GrpcHealthCheckStrategy", () => {
179
180
  },
180
181
  ];
181
182
 
182
- const aggregated = strategy.aggregateResult(runs);
183
+ let aggregated = strategy.mergeResult(undefined, runs[0]);
184
+ aggregated = strategy.mergeResult(aggregated, runs[1]);
183
185
 
184
- expect(aggregated.errorCount).toBe(1);
185
- expect(aggregated.servingCount).toBe(0);
186
- expect(aggregated.successRate).toBe(0);
186
+ expect(aggregated.errorCount.count).toBe(1);
187
+ expect(aggregated.servingCount.count).toBe(0);
188
+ expect(aggregated.successRate.rate).toBe(0);
187
189
  });
188
190
  });
189
191
  });
package/src/strategy.ts CHANGED
@@ -3,8 +3,16 @@ import {
3
3
  HealthCheckStrategy,
4
4
  HealthCheckRunForAggregation,
5
5
  Versioned,
6
+ VersionedAggregated,
7
+ aggregatedAverage,
8
+ aggregatedRate,
9
+ aggregatedCounter,
10
+ mergeAverage,
11
+ mergeRate,
12
+ mergeCounter,
6
13
  z,
7
14
  type ConnectedClient,
15
+ type InferAggregatedResult,
8
16
  } from "@checkstack/backend-api";
9
17
  import {
10
18
  healthResultBoolean,
@@ -80,31 +88,29 @@ const grpcResultSchema = healthResultSchema({
80
88
 
81
89
  type GrpcResult = z.infer<typeof grpcResultSchema>;
82
90
 
83
- /**
84
- * Aggregated metadata for buckets.
85
- */
86
- const grpcAggregatedSchema = healthResultSchema({
87
- avgResponseTime: healthResultNumber({
91
+ /** Aggregated field definitions for bucket merging */
92
+ const grpcAggregatedFields = {
93
+ avgResponseTime: aggregatedAverage({
88
94
  "x-chart-type": "line",
89
95
  "x-chart-label": "Avg Response Time",
90
96
  "x-chart-unit": "ms",
91
97
  }),
92
- successRate: healthResultNumber({
98
+ successRate: aggregatedRate({
93
99
  "x-chart-type": "gauge",
94
100
  "x-chart-label": "Success Rate",
95
101
  "x-chart-unit": "%",
96
102
  }),
97
- errorCount: healthResultNumber({
103
+ errorCount: aggregatedCounter({
98
104
  "x-chart-type": "counter",
99
105
  "x-chart-label": "Errors",
100
106
  }),
101
- servingCount: healthResultNumber({
107
+ servingCount: aggregatedCounter({
102
108
  "x-chart-type": "counter",
103
109
  "x-chart-label": "Serving",
104
110
  }),
105
- });
111
+ };
106
112
 
107
- type GrpcAggregatedResult = z.infer<typeof grpcAggregatedSchema>;
113
+ type GrpcAggregatedResult = InferAggregatedResult<typeof grpcAggregatedFields>;
108
114
 
109
115
  // ============================================================================
110
116
  // GRPC CLIENT INTERFACE (for testability)
@@ -176,7 +182,7 @@ const defaultGrpcClient: GrpcHealthClient = {
176
182
  resolve({
177
183
  status: statusMap[response?.status ?? 0] ?? "UNKNOWN",
178
184
  });
179
- }
185
+ },
180
186
  );
181
187
  });
182
188
  },
@@ -186,15 +192,12 @@ const defaultGrpcClient: GrpcHealthClient = {
186
192
  // STRATEGY
187
193
  // ============================================================================
188
194
 
189
- export class GrpcHealthCheckStrategy
190
- implements
191
- HealthCheckStrategy<
192
- GrpcConfig,
193
- GrpcTransportClient,
194
- GrpcResult,
195
- GrpcAggregatedResult
196
- >
197
- {
195
+ export class GrpcHealthCheckStrategy implements HealthCheckStrategy<
196
+ GrpcConfig,
197
+ GrpcTransportClient,
198
+ GrpcResult,
199
+ typeof grpcAggregatedFields
200
+ > {
198
201
  id = "grpc";
199
202
  displayName = "gRPC Health Check";
200
203
  description =
@@ -232,55 +235,35 @@ export class GrpcHealthCheckStrategy
232
235
  ],
233
236
  });
234
237
 
235
- aggregatedResult: Versioned<GrpcAggregatedResult> = new Versioned({
238
+ aggregatedResult = new VersionedAggregated({
236
239
  version: 1,
237
- schema: grpcAggregatedSchema,
240
+ fields: grpcAggregatedFields,
238
241
  });
239
242
 
240
- aggregateResult(
241
- runs: HealthCheckRunForAggregation<GrpcResult>[]
243
+ mergeResult(
244
+ existing: GrpcAggregatedResult | undefined,
245
+ run: HealthCheckRunForAggregation<GrpcResult>,
242
246
  ): GrpcAggregatedResult {
243
- const validRuns = runs.filter((r) => r.metadata);
244
-
245
- if (validRuns.length === 0) {
246
- return {
247
- avgResponseTime: 0,
248
- successRate: 0,
249
- errorCount: 0,
250
- servingCount: 0,
251
- };
252
- }
247
+ const metadata = run.metadata;
253
248
 
254
- const responseTimes = validRuns
255
- .map((r) => r.metadata?.responseTimeMs)
256
- .filter((t): t is number => typeof t === "number");
249
+ const avgResponseTime = mergeAverage(
250
+ existing?.avgResponseTime,
251
+ metadata?.responseTimeMs,
252
+ );
257
253
 
258
- const avgResponseTime =
259
- responseTimes.length > 0
260
- ? Math.round(
261
- responseTimes.reduce((a, b) => a + b, 0) / responseTimes.length
262
- )
263
- : 0;
254
+ const isSuccess = metadata?.status === "SERVING";
255
+ const successRate = mergeRate(existing?.successRate, isSuccess);
264
256
 
265
- const servingCount = validRuns.filter(
266
- (r) => r.metadata?.status === "SERVING"
267
- ).length;
268
- const successRate = Math.round((servingCount / validRuns.length) * 100);
257
+ const hasError = metadata?.error !== undefined;
258
+ const errorCount = mergeCounter(existing?.errorCount, hasError);
269
259
 
270
- const errorCount = validRuns.filter(
271
- (r) => r.metadata?.error !== undefined
272
- ).length;
260
+ const servingCount = mergeCounter(existing?.servingCount, isSuccess);
273
261
 
274
- return {
275
- avgResponseTime,
276
- successRate,
277
- errorCount,
278
- servingCount,
279
- };
262
+ return { avgResponseTime, successRate, errorCount, servingCount };
280
263
  }
281
264
 
282
265
  async createClient(
283
- config: GrpcConfigInput
266
+ config: GrpcConfigInput,
284
267
  ): Promise<ConnectedClient<GrpcTransportClient>> {
285
268
  const validatedConfig = this.config.validate(config);
286
269