@checkstack/healthcheck-ping-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-ping-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-ping-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,14 +9,14 @@
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
  },
16
16
  "devDependencies": {
17
17
  "@types/bun": "^1.0.0",
18
18
  "typescript": "^5.0.0",
19
- "@checkstack/tsconfig": "0.0.2",
20
- "@checkstack/scripts": "0.1.0"
19
+ "@checkstack/tsconfig": "0.0.3",
20
+ "@checkstack/scripts": "0.1.1"
21
21
  }
22
22
  }
@@ -82,7 +82,7 @@ describe("PingCollector", () => {
82
82
  });
83
83
  });
84
84
 
85
- describe("aggregateResult", () => {
85
+ describe("mergeResult", () => {
86
86
  it("should calculate average packet loss and latency", () => {
87
87
  const collector = new PingCollector();
88
88
  const runs = [
@@ -114,10 +114,11 @@ describe("PingCollector", () => {
114
114
  },
115
115
  ];
116
116
 
117
- const aggregated = collector.aggregateResult(runs);
117
+ let aggregated = collector.mergeResult(undefined, runs[0]);
118
+ aggregated = collector.mergeResult(aggregated, runs[1]);
118
119
 
119
- expect(aggregated.avgPacketLoss).toBe(5);
120
- expect(aggregated.avgLatency).toBe(15);
120
+ expect(aggregated.avgPacketLoss.avg).toBe(5);
121
+ expect(aggregated.avgLatency.avg).toBe(15);
121
122
  });
122
123
  });
123
124
 
@@ -4,6 +4,10 @@ import {
4
4
  type HealthCheckRunForAggregation,
5
5
  type CollectorResult,
6
6
  type CollectorStrategy,
7
+ mergeAverage,
8
+ VersionedAggregated,
9
+ aggregatedAverage,
10
+ type InferAggregatedResult,
7
11
  } from "@checkstack/backend-api";
8
12
  import {
9
13
  healthResultNumber,
@@ -71,20 +75,24 @@ const pingResultSchema = healthResultSchema({
71
75
 
72
76
  export type PingResult = z.infer<typeof pingResultSchema>;
73
77
 
74
- const pingAggregatedSchema = healthResultSchema({
75
- avgPacketLoss: healthResultNumber({
78
+ // Aggregated result fields definition
79
+ const pingAggregatedFields = {
80
+ avgPacketLoss: aggregatedAverage({
76
81
  "x-chart-type": "gauge",
77
82
  "x-chart-label": "Avg Packet Loss",
78
83
  "x-chart-unit": "%",
79
84
  }),
80
- avgLatency: healthResultNumber({
85
+ avgLatency: aggregatedAverage({
81
86
  "x-chart-type": "line",
82
87
  "x-chart-label": "Avg Latency",
83
88
  "x-chart-unit": "ms",
84
89
  }),
85
- });
90
+ };
86
91
 
87
- export type PingAggregatedResult = z.infer<typeof pingAggregatedSchema>;
92
+ // Type inferred from field definitions
93
+ export type PingAggregatedResult = InferAggregatedResult<
94
+ typeof pingAggregatedFields
95
+ >;
88
96
 
89
97
  // ============================================================================
90
98
  // PING COLLECTOR
@@ -94,15 +102,12 @@ export type PingAggregatedResult = z.infer<typeof pingAggregatedSchema>;
94
102
  * Built-in Ping collector.
95
103
  * Performs ICMP ping and checks latency.
96
104
  */
97
- export class PingCollector
98
- implements
99
- CollectorStrategy<
100
- PingTransportClient,
101
- PingConfig,
102
- PingResult,
103
- PingAggregatedResult
104
- >
105
- {
105
+ export class PingCollector implements CollectorStrategy<
106
+ PingTransportClient,
107
+ PingConfig,
108
+ PingResult,
109
+ PingAggregatedResult
110
+ > {
106
111
  id = "ping";
107
112
  displayName = "ICMP Ping";
108
113
  description = "Ping a host and check latency";
@@ -113,9 +118,9 @@ export class PingCollector
113
118
 
114
119
  config = new Versioned({ version: 1, schema: pingConfigSchema });
115
120
  result = new Versioned({ version: 1, schema: pingResultSchema });
116
- aggregatedResult = new Versioned({
121
+ aggregatedResult = new VersionedAggregated({
117
122
  version: 1,
118
- schema: pingAggregatedSchema,
123
+ fields: pingAggregatedFields,
119
124
  });
120
125
 
121
126
  async execute({
@@ -145,30 +150,18 @@ export class PingCollector
145
150
  };
146
151
  }
147
152
 
148
- aggregateResult(
149
- runs: HealthCheckRunForAggregation<PingResult>[]
153
+ mergeResult(
154
+ existing: PingAggregatedResult | undefined,
155
+ run: HealthCheckRunForAggregation<PingResult>,
150
156
  ): PingAggregatedResult {
151
- const losses = runs
152
- .map((r) => r.metadata?.packetLoss)
153
- .filter((v): v is number => typeof v === "number");
154
-
155
- const latencies = runs
156
- .map((r) => r.metadata?.avgLatency)
157
- .filter((v): v is number => typeof v === "number");
157
+ const metadata = run.metadata;
158
158
 
159
159
  return {
160
- avgPacketLoss:
161
- losses.length > 0
162
- ? Math.round(
163
- (losses.reduce((a, b) => a + b, 0) / losses.length) * 10
164
- ) / 10
165
- : 0,
166
- avgLatency:
167
- latencies.length > 0
168
- ? Math.round(
169
- (latencies.reduce((a, b) => a + b, 0) / latencies.length) * 10
170
- ) / 10
171
- : 0,
160
+ avgPacketLoss: mergeAverage(
161
+ existing?.avgPacketLoss,
162
+ metadata?.packetLoss,
163
+ ),
164
+ avgLatency: mergeAverage(existing?.avgLatency, metadata?.avgLatency),
172
165
  };
173
166
  }
174
167
  }
@@ -14,8 +14,8 @@ const mockSpawn = mock(() => ({
14
14
 
15
15
  --- 8.8.8.8 ping statistics ---
16
16
  3 packets transmitted, 3 packets received, 0.0% packet loss
17
- round-trip min/avg/max/stddev = 10.123/11.456/12.456/0.957 ms`
18
- )
17
+ round-trip min/avg/max/stddev = 10.123/11.456/12.456/0.957 ms`,
18
+ ),
19
19
  );
20
20
  controller.close();
21
21
  },
@@ -83,8 +83,8 @@ describe("PingHealthCheckStrategy", () => {
83
83
  `PING 10.0.0.1 (10.0.0.1): 56 data bytes
84
84
 
85
85
  --- 10.0.0.1 ping statistics ---
86
- 3 packets transmitted, 0 packets received, 100.0% packet loss`
87
- )
86
+ 3 packets transmitted, 0 packets received, 100.0% packet loss`,
87
+ ),
88
88
  );
89
89
  controller.close();
90
90
  },
@@ -139,7 +139,7 @@ describe("PingHealthCheckStrategy", () => {
139
139
  });
140
140
  });
141
141
 
142
- describe("aggregateResult", () => {
142
+ describe("mergeResult", () => {
143
143
  it("should calculate averages correctly", () => {
144
144
  const runs = [
145
145
  {
@@ -172,34 +172,34 @@ describe("PingHealthCheckStrategy", () => {
172
172
  },
173
173
  ];
174
174
 
175
- const aggregated = strategy.aggregateResult(runs);
175
+ let aggregated = strategy.mergeResult(undefined, runs[0]);
176
+ aggregated = strategy.mergeResult(aggregated, runs[1]);
176
177
 
177
- expect(aggregated.avgPacketLoss).toBeCloseTo(16.5, 1);
178
- expect(aggregated.avgLatency).toBeCloseTo(15, 1);
179
- expect(aggregated.maxLatency).toBe(25);
180
- expect(aggregated.errorCount).toBe(0);
178
+ // (0 + 33) / 2 = 16.5
179
+ expect(aggregated.avgPacketLoss.avg).toBeCloseTo(16.5, 1);
180
+ expect(aggregated.avgLatency.avg).toBeCloseTo(15, 1);
181
+ expect(aggregated.maxLatency.max).toBe(25);
182
+ expect(aggregated.errorCount.count).toBe(0);
181
183
  });
182
184
 
183
185
  it("should count errors", () => {
184
- const runs = [
185
- {
186
- id: "1",
187
- status: "unhealthy" as const,
188
- latencyMs: 0,
189
- checkId: "c1",
190
- timestamp: new Date(),
191
- metadata: {
192
- packetsSent: 3,
193
- packetsReceived: 0,
194
- packetLoss: 100,
195
- error: "Timeout",
196
- },
186
+ const run = {
187
+ id: "1",
188
+ status: "unhealthy" as const,
189
+ latencyMs: 0,
190
+ checkId: "c1",
191
+ timestamp: new Date(),
192
+ metadata: {
193
+ packetsSent: 3,
194
+ packetsReceived: 0,
195
+ packetLoss: 100,
196
+ error: "Timeout",
197
197
  },
198
- ];
198
+ };
199
199
 
200
- const aggregated = strategy.aggregateResult(runs);
200
+ const aggregated = strategy.mergeResult(undefined, run);
201
201
 
202
- expect(aggregated.errorCount).toBe(1);
202
+ expect(aggregated.errorCount.count).toBe(1);
203
203
  });
204
204
  });
205
205
  });
package/src/strategy.ts CHANGED
@@ -2,8 +2,16 @@ import {
2
2
  HealthCheckStrategy,
3
3
  HealthCheckRunForAggregation,
4
4
  Versioned,
5
+ VersionedAggregated,
6
+ aggregatedAverage,
7
+ aggregatedMinMax,
8
+ aggregatedCounter,
9
+ mergeAverage,
10
+ mergeCounter,
11
+ mergeMinMax,
5
12
  z,
6
13
  type ConnectedClient,
14
+ type InferAggregatedResult,
7
15
  } from "@checkstack/backend-api";
8
16
  import {
9
17
  healthResultNumber,
@@ -81,46 +89,41 @@ const pingResultSchema = healthResultSchema({
81
89
 
82
90
  type PingResult = z.infer<typeof pingResultSchema>;
83
91
 
84
- /**
85
- * Aggregated metadata for buckets.
86
- */
87
- const pingAggregatedSchema = healthResultSchema({
88
- avgPacketLoss: healthResultNumber({
92
+ /** Aggregated field definitions for bucket merging */
93
+ const pingAggregatedFields = {
94
+ avgPacketLoss: aggregatedAverage({
89
95
  "x-chart-type": "gauge",
90
96
  "x-chart-label": "Avg Packet Loss",
91
97
  "x-chart-unit": "%",
92
98
  }),
93
- avgLatency: healthResultNumber({
99
+ avgLatency: aggregatedAverage({
94
100
  "x-chart-type": "line",
95
101
  "x-chart-label": "Avg Latency",
96
102
  "x-chart-unit": "ms",
97
103
  }),
98
- maxLatency: healthResultNumber({
104
+ maxLatency: aggregatedMinMax({
99
105
  "x-chart-type": "line",
100
106
  "x-chart-label": "Max Latency",
101
107
  "x-chart-unit": "ms",
102
108
  }),
103
- errorCount: healthResultNumber({
109
+ errorCount: aggregatedCounter({
104
110
  "x-chart-type": "counter",
105
111
  "x-chart-label": "Errors",
106
112
  }),
107
- });
113
+ };
108
114
 
109
- type PingAggregatedResult = z.infer<typeof pingAggregatedSchema>;
115
+ type PingAggregatedResult = InferAggregatedResult<typeof pingAggregatedFields>;
110
116
 
111
117
  // ============================================================================
112
118
  // STRATEGY
113
119
  // ============================================================================
114
120
 
115
- export class PingHealthCheckStrategy
116
- implements
117
- HealthCheckStrategy<
118
- PingConfig,
119
- PingTransportClient,
120
- PingResult,
121
- PingAggregatedResult
122
- >
123
- {
121
+ export class PingHealthCheckStrategy implements HealthCheckStrategy<
122
+ PingConfig,
123
+ PingTransportClient,
124
+ PingResult,
125
+ typeof pingAggregatedFields
126
+ > {
124
127
  id = "ping";
125
128
  displayName = "Ping Health Check";
126
129
  description = "ICMP ping check for network reachability and latency";
@@ -153,57 +156,34 @@ export class PingHealthCheckStrategy
153
156
  ],
154
157
  });
155
158
 
156
- aggregatedResult: Versioned<PingAggregatedResult> = new Versioned({
159
+ aggregatedResult = new VersionedAggregated({
157
160
  version: 1,
158
- schema: pingAggregatedSchema,
161
+ fields: pingAggregatedFields,
159
162
  });
160
163
 
161
- aggregateResult(
162
- runs: HealthCheckRunForAggregation<PingResult>[]
164
+ mergeResult(
165
+ existing: PingAggregatedResult | undefined,
166
+ run: HealthCheckRunForAggregation<PingResult>,
163
167
  ): PingAggregatedResult {
164
- const validRuns = runs.filter((r) => r.metadata);
168
+ const metadata = run.metadata;
165
169
 
166
- if (validRuns.length === 0) {
167
- return { avgPacketLoss: 0, avgLatency: 0, maxLatency: 0, errorCount: 0 };
168
- }
169
-
170
- const packetLosses = validRuns
171
- .map((r) => r.metadata?.packetLoss)
172
- .filter((l): l is number => typeof l === "number");
173
-
174
- const avgPacketLoss =
175
- packetLosses.length > 0
176
- ? Math.round(
177
- (packetLosses.reduce((a, b) => a + b, 0) / packetLosses.length) * 10
178
- ) / 10
179
- : 0;
180
-
181
- const latencies = validRuns
182
- .map((r) => r.metadata?.avgLatency)
183
- .filter((l): l is number => typeof l === "number");
184
-
185
- const avgLatency =
186
- latencies.length > 0
187
- ? Math.round(
188
- (latencies.reduce((a, b) => a + b, 0) / latencies.length) * 10
189
- ) / 10
190
- : 0;
170
+ const avgPacketLoss = mergeAverage(
171
+ existing?.avgPacketLoss,
172
+ metadata?.packetLoss,
173
+ );
191
174
 
192
- const maxLatencies = validRuns
193
- .map((r) => r.metadata?.maxLatency)
194
- .filter((l): l is number => typeof l === "number");
175
+ const avgLatency = mergeAverage(existing?.avgLatency, metadata?.avgLatency);
195
176
 
196
- const maxLatency = maxLatencies.length > 0 ? Math.max(...maxLatencies) : 0;
177
+ const maxLatency = mergeMinMax(existing?.maxLatency, metadata?.maxLatency);
197
178
 
198
- const errorCount = validRuns.filter(
199
- (r) => r.metadata?.error !== undefined
200
- ).length;
179
+ const hasError = metadata?.error !== undefined;
180
+ const errorCount = mergeCounter(existing?.errorCount, hasError);
201
181
 
202
182
  return { avgPacketLoss, avgLatency, maxLatency, errorCount };
203
183
  }
204
184
 
205
185
  async createClient(
206
- config: PingConfig
186
+ config: PingConfig,
207
187
  ): Promise<ConnectedClient<PingTransportClient>> {
208
188
  const validatedConfig = this.config.validate(config);
209
189
 
@@ -212,7 +192,7 @@ export class PingHealthCheckStrategy
212
192
  return this.runPing(
213
193
  request.host,
214
194
  request.count,
215
- request.timeout ?? validatedConfig.timeout
195
+ request.timeout ?? validatedConfig.timeout,
216
196
  );
217
197
  },
218
198
  };
@@ -228,7 +208,7 @@ export class PingHealthCheckStrategy
228
208
  private async runPing(
229
209
  host: string,
230
210
  count: number,
231
- timeout: number
211
+ timeout: number,
232
212
  ): Promise<PingResultType> {
233
213
  const isMac = process.platform === "darwin";
234
214
  const args = isMac
@@ -260,11 +240,11 @@ export class PingHealthCheckStrategy
260
240
  private parsePingOutput(
261
241
  output: string,
262
242
  expectedCount: number,
263
- _exitCode: number
243
+ _exitCode: number,
264
244
  ): PingResultType {
265
245
  // Parse packet statistics
266
246
  const statsMatch = output.match(
267
- /(\d+) packets transmitted, (\d+) (?:packets )?received/
247
+ /(\d+) packets transmitted, (\d+) (?:packets )?received/,
268
248
  );
269
249
  const packetsSent = statsMatch
270
250
  ? Number.parseInt(statsMatch[1], 10)
@@ -279,7 +259,7 @@ export class PingHealthCheckStrategy
279
259
  // macOS: round-trip min/avg/max/stddev = 0.043/0.059/0.082/0.016 ms
280
260
  // Linux: rtt min/avg/max/mdev = 0.039/0.049/0.064/0.009 ms
281
261
  const latencyMatch = output.match(
282
- /(?:round-trip|rtt) min\/avg\/max\/(?:stddev|mdev) = ([\d.]+)\/([\d.]+)\/([\d.]+)/
262
+ /(?:round-trip|rtt) min\/avg\/max\/(?:stddev|mdev) = ([\d.]+)\/([\d.]+)\/([\d.]+)/,
283
263
  );
284
264
 
285
265
  let minLatency: number | undefined;