@checkstack/healthcheck-ssh-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,60 @@
1
1
  # @checkstack/healthcheck-ssh-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
+ - @checkstack/healthcheck-ssh-common@0.1.8
57
+
3
58
  ## 0.1.13
4
59
 
5
60
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-ssh-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,17 +9,17 @@
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",
15
- "@checkstack/healthcheck-ssh-common": "0.1.6",
12
+ "@checkstack/backend-api": "0.5.2",
13
+ "@checkstack/common": "0.6.1",
14
+ "@checkstack/healthcheck-common": "0.8.1",
15
+ "@checkstack/healthcheck-ssh-common": "0.1.7",
16
16
  "ssh2": "^1.15.0"
17
17
  },
18
18
  "devDependencies": {
19
19
  "@types/bun": "^1.0.0",
20
20
  "@types/ssh2": "^1.15.0",
21
21
  "typescript": "^5.0.0",
22
- "@checkstack/tsconfig": "0.0.2",
23
- "@checkstack/scripts": "0.1.0"
22
+ "@checkstack/tsconfig": "0.0.3",
23
+ "@checkstack/scripts": "0.1.1"
24
24
  }
25
25
  }
@@ -69,7 +69,7 @@ describe("CommandCollector", () => {
69
69
  });
70
70
  });
71
71
 
72
- describe("aggregateResult", () => {
72
+ describe("mergeResult", () => {
73
73
  it("should calculate average execution time and success rate", () => {
74
74
  const collector = new CommandCollector();
75
75
  const runs = [
@@ -101,10 +101,11 @@ describe("CommandCollector", () => {
101
101
  },
102
102
  ];
103
103
 
104
- const aggregated = collector.aggregateResult(runs);
104
+ let aggregated = collector.mergeResult(undefined, runs[0]);
105
+ aggregated = collector.mergeResult(aggregated, runs[1]);
105
106
 
106
- expect(aggregated.avgExecutionTimeMs).toBe(75);
107
- expect(aggregated.successRate).toBe(100);
107
+ expect(aggregated.avgExecutionTimeMs.avg).toBe(75);
108
+ expect(aggregated.successRate.rate).toBe(100);
108
109
  });
109
110
 
110
111
  it("should calculate success rate based on exit codes", () => {
@@ -138,9 +139,10 @@ describe("CommandCollector", () => {
138
139
  },
139
140
  ];
140
141
 
141
- const aggregated = collector.aggregateResult(runs);
142
+ let aggregated = collector.mergeResult(undefined, runs[0]);
143
+ aggregated = collector.mergeResult(aggregated, runs[1]);
142
144
 
143
- expect(aggregated.successRate).toBe(50);
145
+ expect(aggregated.successRate.rate).toBe(50);
144
146
  });
145
147
  });
146
148
 
@@ -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 commandResultSchema = healthResultSchema({
49
55
 
50
56
  export type CommandResult = z.infer<typeof commandResultSchema>;
51
57
 
52
- const commandAggregatedSchema = healthResultSchema({
53
- avgExecutionTimeMs: healthResultNumber({
58
+ // Aggregated result fields definition
59
+ const commandAggregatedFields = {
60
+ avgExecutionTimeMs: aggregatedAverage({
54
61
  "x-chart-type": "line",
55
62
  "x-chart-label": "Avg Execution Time",
56
63
  "x-chart-unit": "ms",
57
64
  }),
58
- successRate: healthResultNumber({
65
+ successRate: aggregatedRate({
59
66
  "x-chart-type": "gauge",
60
67
  "x-chart-label": "Success Rate",
61
68
  "x-chart-unit": "%",
62
69
  }),
63
- });
70
+ };
64
71
 
65
- export type CommandAggregatedResult = z.infer<typeof commandAggregatedSchema>;
72
+ // Type inferred from field definitions
73
+ export type CommandAggregatedResult = InferAggregatedResult<
74
+ typeof commandAggregatedFields
75
+ >;
66
76
 
67
77
  // ============================================================================
68
78
  // COMMAND COLLECTOR (PSEUDO-COLLECTOR)
@@ -73,15 +83,12 @@ export type CommandAggregatedResult = z.infer<typeof commandAggregatedSchema>;
73
83
  * Allows users to run arbitrary shell commands as check items.
74
84
  * This is the "basic mode" functionality exposed as a collector.
75
85
  */
76
- export class CommandCollector
77
- implements
78
- CollectorStrategy<
79
- SshTransportClient,
80
- CommandConfig,
81
- CommandResult,
82
- CommandAggregatedResult
83
- >
84
- {
86
+ export class CommandCollector implements CollectorStrategy<
87
+ SshTransportClient,
88
+ CommandConfig,
89
+ CommandResult,
90
+ CommandAggregatedResult
91
+ > {
85
92
  /**
86
93
  * ID for this collector.
87
94
  * Built-in collectors are identified by ownerPlugin matching the strategy's plugin.
@@ -98,9 +105,9 @@ export class CommandCollector
98
105
 
99
106
  config = new Versioned({ version: 1, schema: commandConfigSchema });
100
107
  result = new Versioned({ version: 1, schema: commandResultSchema });
101
- aggregatedResult = new Versioned({
108
+ aggregatedResult = new VersionedAggregated({
102
109
  version: 1,
103
- schema: commandAggregatedSchema,
110
+ fields: commandAggregatedFields,
104
111
  });
105
112
 
106
113
  async execute({
@@ -125,28 +132,19 @@ export class CommandCollector
125
132
  };
126
133
  }
127
134
 
128
- aggregateResult(
129
- runs: HealthCheckRunForAggregation<CommandResult>[]
135
+ mergeResult(
136
+ existing: CommandAggregatedResult | undefined,
137
+ run: HealthCheckRunForAggregation<CommandResult>,
130
138
  ): CommandAggregatedResult {
131
- const times = runs
132
- .map((r) => r.metadata?.executionTimeMs)
133
- .filter((v): v is number => typeof v === "number");
134
-
135
- const exitCodes = runs
136
- .map((r) => r.metadata?.exitCode)
137
- .filter((v): v is number => typeof v === "number");
138
-
139
- const successCount = exitCodes.filter((code) => code === 0).length;
139
+ const metadata = run.metadata;
140
140
 
141
+ // Success is exit code 0
141
142
  return {
142
- avgExecutionTimeMs:
143
- times.length > 0
144
- ? Math.round(times.reduce((a, b) => a + b, 0) / times.length)
145
- : 0,
146
- successRate:
147
- exitCodes.length > 0
148
- ? Math.round((successCount / exitCodes.length) * 100)
149
- : 0,
143
+ avgExecutionTimeMs: mergeAverage(
144
+ existing?.avgExecutionTimeMs,
145
+ metadata?.executionTimeMs,
146
+ ),
147
+ successRate: mergeRate(existing?.successRate, metadata?.exitCode === 0),
150
148
  };
151
149
  }
152
150
  }
@@ -10,7 +10,7 @@ describe("SshHealthCheckStrategy", () => {
10
10
  stderr?: string;
11
11
  execError?: Error;
12
12
  connectError?: Error;
13
- } = {}
13
+ } = {},
14
14
  ): SshClient => ({
15
15
  connect: mock(() =>
16
16
  config.connectError
@@ -23,10 +23,10 @@ describe("SshHealthCheckStrategy", () => {
23
23
  exitCode: config.exitCode ?? 0,
24
24
  stdout: config.stdout ?? "",
25
25
  stderr: config.stderr ?? "",
26
- })
26
+ }),
27
27
  ),
28
28
  end: mock(() => {}),
29
- })
29
+ }),
30
30
  ),
31
31
  });
32
32
 
@@ -51,7 +51,7 @@ describe("SshHealthCheckStrategy", () => {
51
51
 
52
52
  it("should throw for connection error", async () => {
53
53
  const strategy = new SshHealthCheckStrategy(
54
- createMockClient({ connectError: new Error("Connection refused") })
54
+ createMockClient({ connectError: new Error("Connection refused") }),
55
55
  );
56
56
 
57
57
  await expect(
@@ -61,7 +61,7 @@ describe("SshHealthCheckStrategy", () => {
61
61
  username: "user",
62
62
  password: "secret",
63
63
  timeout: 5000,
64
- })
64
+ }),
65
65
  ).rejects.toThrow("Connection refused");
66
66
  });
67
67
  });
@@ -69,7 +69,7 @@ describe("SshHealthCheckStrategy", () => {
69
69
  describe("client.exec", () => {
70
70
  it("should execute command successfully", async () => {
71
71
  const strategy = new SshHealthCheckStrategy(
72
- createMockClient({ exitCode: 0, stdout: "OK" })
72
+ createMockClient({ exitCode: 0, stdout: "OK" }),
73
73
  );
74
74
  const connectedClient = await strategy.createClient({
75
75
  host: "localhost",
@@ -90,7 +90,7 @@ describe("SshHealthCheckStrategy", () => {
90
90
 
91
91
  it("should return non-zero exit code for failed command", async () => {
92
92
  const strategy = new SshHealthCheckStrategy(
93
- createMockClient({ exitCode: 1, stderr: "Error" })
93
+ createMockClient({ exitCode: 1, stderr: "Error" }),
94
94
  );
95
95
  const connectedClient = await strategy.createClient({
96
96
  host: "localhost",
@@ -109,7 +109,7 @@ describe("SshHealthCheckStrategy", () => {
109
109
  });
110
110
  });
111
111
 
112
- describe("aggregateResult", () => {
112
+ describe("mergeResult", () => {
113
113
  it("should calculate averages correctly", () => {
114
114
  const strategy = new SshHealthCheckStrategy();
115
115
  const runs = [
@@ -139,34 +139,33 @@ describe("SshHealthCheckStrategy", () => {
139
139
  },
140
140
  ];
141
141
 
142
- const aggregated = strategy.aggregateResult(runs);
142
+ let aggregated = strategy.mergeResult(undefined, runs[0]);
143
+ aggregated = strategy.mergeResult(aggregated, runs[1]);
143
144
 
144
- expect(aggregated.avgConnectionTime).toBe(75);
145
- expect(aggregated.successRate).toBe(100);
146
- expect(aggregated.errorCount).toBe(0);
145
+ expect(aggregated.avgConnectionTime.avg).toBe(75);
146
+ expect(aggregated.successRate.rate).toBe(100);
147
+ expect(aggregated.errorCount.count).toBe(0);
147
148
  });
148
149
 
149
150
  it("should count errors", () => {
150
151
  const strategy = new SshHealthCheckStrategy();
151
- const runs = [
152
- {
153
- id: "1",
154
- status: "unhealthy" as const,
155
- latencyMs: 100,
156
- checkId: "c1",
157
- timestamp: new Date(),
158
- metadata: {
159
- connected: false,
160
- connectionTimeMs: 100,
161
- error: "Connection refused",
162
- },
152
+ const run = {
153
+ id: "1",
154
+ status: "unhealthy" as const,
155
+ latencyMs: 100,
156
+ checkId: "c1",
157
+ timestamp: new Date(),
158
+ metadata: {
159
+ connected: false,
160
+ connectionTimeMs: 100,
161
+ error: "Connection refused",
163
162
  },
164
- ];
163
+ };
165
164
 
166
- const aggregated = strategy.aggregateResult(runs);
165
+ const aggregated = strategy.mergeResult(undefined, run);
167
166
 
168
- expect(aggregated.errorCount).toBe(1);
169
- expect(aggregated.successRate).toBe(0);
167
+ expect(aggregated.errorCount.count).toBe(1);
168
+ expect(aggregated.successRate.rate).toBe(0);
170
169
  });
171
170
  });
172
171
  });
package/src/strategy.ts CHANGED
@@ -3,10 +3,20 @@ import {
3
3
  HealthCheckStrategy,
4
4
  HealthCheckRunForAggregation,
5
5
  Versioned,
6
+ VersionedAggregated,
7
+ aggregatedAverage,
8
+ aggregatedMinMax,
9
+ aggregatedRate,
10
+ aggregatedCounter,
11
+ mergeAverage,
12
+ mergeRate,
13
+ mergeCounter,
14
+ mergeMinMax,
6
15
  z,
7
16
  configString,
8
17
  configNumber,
9
18
  type ConnectedClient,
19
+ type InferAggregatedResult,
10
20
  } from "@checkstack/backend-api";
11
21
  import {
12
22
  healthResultBoolean,
@@ -66,32 +76,30 @@ const sshResultSchema = healthResultSchema({
66
76
 
67
77
  type SshResult = z.infer<typeof sshResultSchema>;
68
78
 
69
- /**
70
- * Aggregated metadata for buckets.
71
- */
72
- const sshAggregatedSchema = healthResultSchema({
73
- avgConnectionTime: healthResultNumber({
79
+ /** Aggregated field definitions for bucket merging */
80
+ const sshAggregatedFields = {
81
+ avgConnectionTime: aggregatedAverage({
74
82
  "x-chart-type": "line",
75
83
  "x-chart-label": "Avg Connection Time",
76
84
  "x-chart-unit": "ms",
77
85
  }),
78
- maxConnectionTime: healthResultNumber({
86
+ maxConnectionTime: aggregatedMinMax({
79
87
  "x-chart-type": "line",
80
88
  "x-chart-label": "Max Connection Time",
81
89
  "x-chart-unit": "ms",
82
90
  }),
83
- successRate: healthResultNumber({
91
+ successRate: aggregatedRate({
84
92
  "x-chart-type": "gauge",
85
93
  "x-chart-label": "Success Rate",
86
94
  "x-chart-unit": "%",
87
95
  }),
88
- errorCount: healthResultNumber({
96
+ errorCount: aggregatedCounter({
89
97
  "x-chart-type": "counter",
90
98
  "x-chart-label": "Errors",
91
99
  }),
92
- });
100
+ };
93
101
 
94
- type SshAggregatedResult = z.infer<typeof sshAggregatedSchema>;
102
+ type SshAggregatedResult = InferAggregatedResult<typeof sshAggregatedFields>;
95
103
 
96
104
  // ============================================================================
97
105
  // SSH CLIENT INTERFACE (for testability)
@@ -178,15 +186,12 @@ const defaultSshClient: SshClient = {
178
186
  // STRATEGY
179
187
  // ============================================================================
180
188
 
181
- export class SshHealthCheckStrategy
182
- implements
183
- HealthCheckStrategy<
184
- SshConfig,
185
- SshTransportClient,
186
- SshResult,
187
- SshAggregatedResult
188
- >
189
- {
189
+ export class SshHealthCheckStrategy implements HealthCheckStrategy<
190
+ SshConfig,
191
+ SshTransportClient,
192
+ SshResult,
193
+ typeof sshAggregatedFields
194
+ > {
190
195
  id = "ssh";
191
196
  displayName = "SSH Health Check";
192
197
  description = "SSH server connectivity and command execution health check";
@@ -207,61 +212,41 @@ export class SshHealthCheckStrategy
207
212
  schema: sshResultSchema,
208
213
  });
209
214
 
210
- aggregatedResult: Versioned<SshAggregatedResult> = new Versioned({
215
+ aggregatedResult = new VersionedAggregated({
211
216
  version: 1,
212
- schema: sshAggregatedSchema,
217
+ fields: sshAggregatedFields,
213
218
  });
214
219
 
215
- aggregateResult(
216
- runs: HealthCheckRunForAggregation<SshResult>[]
220
+ mergeResult(
221
+ existing: SshAggregatedResult | undefined,
222
+ run: HealthCheckRunForAggregation<SshResult>,
217
223
  ): SshAggregatedResult {
218
- const validRuns = runs.filter((r) => r.metadata);
219
-
220
- if (validRuns.length === 0) {
221
- return {
222
- avgConnectionTime: 0,
223
- maxConnectionTime: 0,
224
- successRate: 0,
225
- errorCount: 0,
226
- };
227
- }
228
-
229
- const connectionTimes = validRuns
230
- .map((r) => r.metadata?.connectionTimeMs)
231
- .filter((t): t is number => typeof t === "number");
232
-
233
- const avgConnectionTime =
234
- connectionTimes.length > 0
235
- ? Math.round(
236
- connectionTimes.reduce((a, b) => a + b, 0) / connectionTimes.length
237
- )
238
- : 0;
239
-
240
- const maxConnectionTime =
241
- connectionTimes.length > 0 ? Math.max(...connectionTimes) : 0;
242
-
243
- const successCount = validRuns.filter(
244
- (r) => r.metadata?.connected === true
245
- ).length;
246
- const successRate = Math.round((successCount / validRuns.length) * 100);
247
-
248
- const errorCount = validRuns.filter(
249
- (r) => r.metadata?.error !== undefined
250
- ).length;
224
+ const metadata = run.metadata;
251
225
 
252
- return {
253
- avgConnectionTime,
254
- maxConnectionTime,
255
- successRate,
256
- errorCount,
257
- };
226
+ const avgConnectionTime = mergeAverage(
227
+ existing?.avgConnectionTime,
228
+ metadata?.connectionTimeMs,
229
+ );
230
+
231
+ const maxConnectionTime = mergeMinMax(
232
+ existing?.maxConnectionTime,
233
+ metadata?.connectionTimeMs,
234
+ );
235
+
236
+ const isSuccess = metadata?.connected ?? false;
237
+ const successRate = mergeRate(existing?.successRate, isSuccess);
238
+
239
+ const hasError = metadata?.error !== undefined;
240
+ const errorCount = mergeCounter(existing?.errorCount, hasError);
241
+
242
+ return { avgConnectionTime, maxConnectionTime, successRate, errorCount };
258
243
  }
259
244
 
260
245
  /**
261
246
  * Create a connected SSH transport client.
262
247
  */
263
248
  async createClient(
264
- config: SshConfigInput
249
+ config: SshConfigInput,
265
250
  ): Promise<ConnectedClient<SshTransportClient>> {
266
251
  const validatedConfig = this.config.validate(config);
267
252