@checkstack/healthcheck-ssh-backend 0.1.13 → 0.1.14

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,36 @@
1
1
  # @checkstack/healthcheck-ssh-backend
2
2
 
3
+ ## 0.1.14
4
+
5
+ ### Patch Changes
6
+
7
+ - 48c2080: Migrate aggregation from batch to incremental (`mergeResult`)
8
+
9
+ ### Breaking Changes (Internal)
10
+
11
+ - Replaced `aggregateResult(runs[])` with `mergeResult(existing, run)` interface across all HealthCheckStrategy and CollectorStrategy implementations
12
+
13
+ ### New Features
14
+
15
+ - Added incremental aggregation utilities in `@checkstack/backend-api`:
16
+ - `mergeCounter()` - track occurrences
17
+ - `mergeAverage()` - track sum/count, compute avg
18
+ - `mergeRate()` - track success/total, compute %
19
+ - `mergeMinMax()` - track min/max values
20
+ - Exported Zod schemas for internal state: `averageStateSchema`, `rateStateSchema`, `minMaxStateSchema`, `counterStateSchema`
21
+
22
+ ### Improvements
23
+
24
+ - Enables O(1) storage overhead by maintaining incremental aggregation state
25
+ - Prepares for real-time hourly aggregation without batch accumulation
26
+
27
+ - Updated dependencies [f676e11]
28
+ - Updated dependencies [48c2080]
29
+ - @checkstack/common@0.6.2
30
+ - @checkstack/backend-api@0.6.0
31
+ - @checkstack/healthcheck-common@0.8.2
32
+ - @checkstack/healthcheck-ssh-common@0.1.8
33
+
3
34
  ## 0.1.13
4
35
 
5
36
  ### 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.1.14",
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,7 +101,8 @@ 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
107
  expect(aggregated.avgExecutionTimeMs).toBe(75);
107
108
  expect(aggregated.successRate).toBe(100);
@@ -138,7 +139,8 @@ 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
145
  expect(aggregated.successRate).toBe(50);
144
146
  });
@@ -4,6 +4,12 @@ import {
4
4
  type HealthCheckRunForAggregation,
5
5
  type CollectorResult,
6
6
  type CollectorStrategy,
7
+ mergeAverage,
8
+ averageStateSchema,
9
+ mergeRate,
10
+ rateStateSchema,
11
+ type AverageState,
12
+ type RateState,
7
13
  } from "@checkstack/backend-api";
8
14
  import {
9
15
  healthResultNumber,
@@ -49,7 +55,7 @@ const commandResultSchema = healthResultSchema({
49
55
 
50
56
  export type CommandResult = z.infer<typeof commandResultSchema>;
51
57
 
52
- const commandAggregatedSchema = healthResultSchema({
58
+ const commandAggregatedDisplaySchema = healthResultSchema({
53
59
  avgExecutionTimeMs: healthResultNumber({
54
60
  "x-chart-type": "line",
55
61
  "x-chart-label": "Avg Execution Time",
@@ -62,6 +68,17 @@ const commandAggregatedSchema = healthResultSchema({
62
68
  }),
63
69
  });
64
70
 
71
+ const commandAggregatedInternalSchema = z.object({
72
+ _executionTime: averageStateSchema
73
+ .optional(),
74
+ _success: rateStateSchema
75
+ .optional(),
76
+ });
77
+
78
+ const commandAggregatedSchema = commandAggregatedDisplaySchema.merge(
79
+ commandAggregatedInternalSchema,
80
+ );
81
+
65
82
  export type CommandAggregatedResult = z.infer<typeof commandAggregatedSchema>;
66
83
 
67
84
  // ============================================================================
@@ -73,15 +90,12 @@ export type CommandAggregatedResult = z.infer<typeof commandAggregatedSchema>;
73
90
  * Allows users to run arbitrary shell commands as check items.
74
91
  * This is the "basic mode" functionality exposed as a collector.
75
92
  */
76
- export class CommandCollector
77
- implements
78
- CollectorStrategy<
79
- SshTransportClient,
80
- CommandConfig,
81
- CommandResult,
82
- CommandAggregatedResult
83
- >
84
- {
93
+ export class CommandCollector implements CollectorStrategy<
94
+ SshTransportClient,
95
+ CommandConfig,
96
+ CommandResult,
97
+ CommandAggregatedResult
98
+ > {
85
99
  /**
86
100
  * ID for this collector.
87
101
  * Built-in collectors are identified by ownerPlugin matching the strategy's plugin.
@@ -125,28 +139,28 @@ export class CommandCollector
125
139
  };
126
140
  }
127
141
 
128
- aggregateResult(
129
- runs: HealthCheckRunForAggregation<CommandResult>[]
142
+ mergeResult(
143
+ existing: CommandAggregatedResult | undefined,
144
+ run: HealthCheckRunForAggregation<CommandResult>,
130
145
  ): CommandAggregatedResult {
131
- const times = runs
132
- .map((r) => r.metadata?.executionTimeMs)
133
- .filter((v): v is number => typeof v === "number");
146
+ const metadata = run.metadata;
134
147
 
135
- const exitCodes = runs
136
- .map((r) => r.metadata?.exitCode)
137
- .filter((v): v is number => typeof v === "number");
148
+ const executionTimeState = mergeAverage(
149
+ existing?._executionTime as AverageState | undefined,
150
+ metadata?.executionTimeMs,
151
+ );
138
152
 
139
- const successCount = exitCodes.filter((code) => code === 0).length;
153
+ // Success is exit code 0
154
+ const successState = mergeRate(
155
+ existing?._success as RateState | undefined,
156
+ metadata?.exitCode === 0,
157
+ );
140
158
 
141
159
  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,
160
+ avgExecutionTimeMs: executionTimeState.avg,
161
+ successRate: successState.rate,
162
+ _executionTime: executionTimeState,
163
+ _success: successState,
150
164
  };
151
165
  }
152
166
  }
@@ -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,7 +139,8 @@ 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
145
  expect(aggregated.avgConnectionTime).toBe(75);
145
146
  expect(aggregated.successRate).toBe(100);
@@ -148,22 +149,20 @@ describe("SshHealthCheckStrategy", () => {
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
167
  expect(aggregated.errorCount).toBe(1);
169
168
  expect(aggregated.successRate).toBe(0);
package/src/strategy.ts CHANGED
@@ -7,6 +7,18 @@ import {
7
7
  configString,
8
8
  configNumber,
9
9
  type ConnectedClient,
10
+ mergeAverage,
11
+ averageStateSchema,
12
+ mergeRate,
13
+ rateStateSchema,
14
+ mergeCounter,
15
+ counterStateSchema,
16
+ mergeMinMax,
17
+ minMaxStateSchema,
18
+ type AverageState,
19
+ type RateState,
20
+ type CounterState,
21
+ type MinMaxState,
10
22
  } from "@checkstack/backend-api";
11
23
  import {
12
24
  healthResultBoolean,
@@ -69,7 +81,7 @@ type SshResult = z.infer<typeof sshResultSchema>;
69
81
  /**
70
82
  * Aggregated metadata for buckets.
71
83
  */
72
- const sshAggregatedSchema = healthResultSchema({
84
+ const sshAggregatedDisplaySchema = healthResultSchema({
73
85
  avgConnectionTime: healthResultNumber({
74
86
  "x-chart-type": "line",
75
87
  "x-chart-label": "Avg Connection Time",
@@ -91,6 +103,19 @@ const sshAggregatedSchema = healthResultSchema({
91
103
  }),
92
104
  });
93
105
 
106
+ const sshAggregatedInternalSchema = z.object({
107
+ _connectionTime: averageStateSchema
108
+ .optional(),
109
+ _maxConnectionTime: minMaxStateSchema.optional(),
110
+ _success: rateStateSchema
111
+ .optional(),
112
+ _errors: counterStateSchema.optional(),
113
+ });
114
+
115
+ const sshAggregatedSchema = sshAggregatedDisplaySchema.merge(
116
+ sshAggregatedInternalSchema,
117
+ );
118
+
94
119
  type SshAggregatedResult = z.infer<typeof sshAggregatedSchema>;
95
120
 
96
121
  // ============================================================================
@@ -178,15 +203,12 @@ const defaultSshClient: SshClient = {
178
203
  // STRATEGY
179
204
  // ============================================================================
180
205
 
181
- export class SshHealthCheckStrategy
182
- implements
183
- HealthCheckStrategy<
184
- SshConfig,
185
- SshTransportClient,
186
- SshResult,
187
- SshAggregatedResult
188
- >
189
- {
206
+ export class SshHealthCheckStrategy implements HealthCheckStrategy<
207
+ SshConfig,
208
+ SshTransportClient,
209
+ SshResult,
210
+ SshAggregatedResult
211
+ > {
190
212
  id = "ssh";
191
213
  displayName = "SSH Health Check";
192
214
  description = "SSH server connectivity and command execution health check";
@@ -212,48 +234,41 @@ export class SshHealthCheckStrategy
212
234
  schema: sshAggregatedSchema,
213
235
  });
214
236
 
215
- aggregateResult(
216
- runs: HealthCheckRunForAggregation<SshResult>[]
237
+ mergeResult(
238
+ existing: SshAggregatedResult | undefined,
239
+ run: HealthCheckRunForAggregation<SshResult>,
217
240
  ): 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;
241
+ const metadata = run.metadata;
242
+
243
+ const connectionTimeState = mergeAverage(
244
+ existing?._connectionTime as AverageState | undefined,
245
+ metadata?.connectionTimeMs,
246
+ );
247
+
248
+ const maxConnectionTimeState = mergeMinMax(
249
+ existing?._maxConnectionTime as MinMaxState | undefined,
250
+ metadata?.connectionTimeMs,
251
+ );
252
+
253
+ const successState = mergeRate(
254
+ existing?._success as RateState | undefined,
255
+ metadata?.connected,
256
+ );
257
+
258
+ const errorState = mergeCounter(
259
+ existing?._errors as CounterState | undefined,
260
+ metadata?.error !== undefined,
261
+ );
251
262
 
252
263
  return {
253
- avgConnectionTime,
254
- maxConnectionTime,
255
- successRate,
256
- errorCount,
264
+ avgConnectionTime: connectionTimeState.avg,
265
+ maxConnectionTime: maxConnectionTimeState.max,
266
+ successRate: successState.rate,
267
+ errorCount: errorState.count,
268
+ _connectionTime: connectionTimeState,
269
+ _maxConnectionTime: maxConnectionTimeState,
270
+ _success: successState,
271
+ _errors: errorState,
257
272
  };
258
273
  }
259
274
 
@@ -261,7 +276,7 @@ export class SshHealthCheckStrategy
261
276
  * Create a connected SSH transport client.
262
277
  */
263
278
  async createClient(
264
- config: SshConfigInput
279
+ config: SshConfigInput,
265
280
  ): Promise<ConnectedClient<SshTransportClient>> {
266
281
  const validatedConfig = this.config.validate(config);
267
282