@checkstack/healthcheck-ping-backend 0.0.2 → 0.1.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,48 @@
1
1
  # @checkstack/healthcheck-ping-backend
2
2
 
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - f5b1f49: Refactored health check strategies to use `createClient()` pattern with built-in collectors.
8
+
9
+ **Strategy Changes:**
10
+
11
+ - Replaced `execute()` with `createClient()` that returns a transport client
12
+ - Strategy configs now only contain connection parameters
13
+ - Collector configs handle what to do with the connection
14
+
15
+ **Built-in Collectors Added:**
16
+
17
+ - DNS: `LookupCollector` for hostname resolution
18
+ - gRPC: `HealthCollector` for gRPC health protocol
19
+ - HTTP: `RequestCollector` for HTTP requests
20
+ - MySQL: `QueryCollector` for database queries
21
+ - Ping: `PingCollector` for ICMP ping
22
+ - Postgres: `QueryCollector` for database queries
23
+ - Redis: `CommandCollector` for Redis commands
24
+ - Script: `ExecuteCollector` for script execution
25
+ - SSH: `CommandCollector` for SSH commands
26
+ - TCP: `BannerCollector` for TCP banner grabbing
27
+ - TLS: `CertificateCollector` for certificate inspection
28
+
29
+ ### Patch Changes
30
+
31
+ - Updated dependencies [f5b1f49]
32
+ - Updated dependencies [f5b1f49]
33
+ - Updated dependencies [f5b1f49]
34
+ - Updated dependencies [f5b1f49]
35
+ - @checkstack/backend-api@0.1.0
36
+ - @checkstack/healthcheck-common@0.1.0
37
+ - @checkstack/common@0.0.3
38
+
39
+ ## 0.0.3
40
+
41
+ ### Patch Changes
42
+
43
+ - Updated dependencies [cb82e4d]
44
+ - @checkstack/healthcheck-common@0.0.3
45
+
3
46
  ## 0.0.2
4
47
 
5
48
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-ping-backend",
3
- "version": "0.0.2",
3
+ "version": "0.1.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "scripts": {
package/src/index.ts CHANGED
@@ -1,9 +1,7 @@
1
- import {
2
- createBackendPlugin,
3
- coreServices,
4
- } from "@checkstack/backend-api";
1
+ import { createBackendPlugin, coreServices } from "@checkstack/backend-api";
5
2
  import { PingHealthCheckStrategy } from "./strategy";
6
3
  import { pluginMetadata } from "./plugin-metadata";
4
+ import { PingCollector } from "./ping-collector";
7
5
 
8
6
  export default createBackendPlugin({
9
7
  metadata: pluginMetadata,
@@ -11,13 +9,17 @@ export default createBackendPlugin({
11
9
  env.registerInit({
12
10
  deps: {
13
11
  healthCheckRegistry: coreServices.healthCheckRegistry,
12
+ collectorRegistry: coreServices.collectorRegistry,
14
13
  logger: coreServices.logger,
15
14
  },
16
- init: async ({ healthCheckRegistry, logger }) => {
15
+ init: async ({ healthCheckRegistry, collectorRegistry, logger }) => {
17
16
  logger.debug("🔌 Registering Ping Health Check Strategy...");
18
17
  const strategy = new PingHealthCheckStrategy();
19
18
  healthCheckRegistry.register(strategy);
19
+ collectorRegistry.register(new PingCollector());
20
20
  },
21
21
  });
22
22
  },
23
23
  });
24
+
25
+ export { pluginMetadata } from "./plugin-metadata";
@@ -0,0 +1,134 @@
1
+ import { describe, expect, it, mock } from "bun:test";
2
+ import { PingCollector, type PingConfig } from "./ping-collector";
3
+ import type { PingTransportClient } from "./transport-client";
4
+
5
+ describe("PingCollector", () => {
6
+ const createMockClient = (
7
+ response: {
8
+ packetsSent?: number;
9
+ packetsReceived?: number;
10
+ packetLoss?: number;
11
+ minLatency?: number;
12
+ avgLatency?: number;
13
+ maxLatency?: number;
14
+ error?: string;
15
+ } = {}
16
+ ): PingTransportClient => ({
17
+ exec: mock(() =>
18
+ Promise.resolve({
19
+ packetsSent: response.packetsSent ?? 3,
20
+ packetsReceived: response.packetsReceived ?? 3,
21
+ packetLoss: response.packetLoss ?? 0,
22
+ minLatency: response.minLatency ?? 10,
23
+ avgLatency: response.avgLatency ?? 15,
24
+ maxLatency: response.maxLatency ?? 20,
25
+ error: response.error,
26
+ })
27
+ ),
28
+ });
29
+
30
+ describe("execute", () => {
31
+ it("should execute ping successfully", async () => {
32
+ const collector = new PingCollector();
33
+ const client = createMockClient();
34
+
35
+ const result = await collector.execute({
36
+ config: { host: "192.168.1.1", count: 3, timeout: 5000 },
37
+ client,
38
+ pluginId: "test",
39
+ });
40
+
41
+ expect(result.result.packetsSent).toBe(3);
42
+ expect(result.result.packetsReceived).toBe(3);
43
+ expect(result.result.packetLoss).toBe(0);
44
+ expect(result.result.avgLatency).toBe(15);
45
+ expect(result.error).toBeUndefined();
46
+ });
47
+
48
+ it("should return error for failed ping", async () => {
49
+ const collector = new PingCollector();
50
+ const client = createMockClient({
51
+ packetsSent: 3,
52
+ packetsReceived: 0,
53
+ packetLoss: 100,
54
+ error: "Host unreachable",
55
+ });
56
+
57
+ const result = await collector.execute({
58
+ config: { host: "10.255.255.1", count: 3, timeout: 5000 },
59
+ client,
60
+ pluginId: "test",
61
+ });
62
+
63
+ expect(result.result.packetLoss).toBe(100);
64
+ expect(result.error).toBe("Host unreachable");
65
+ });
66
+
67
+ it("should pass correct parameters to client", async () => {
68
+ const collector = new PingCollector();
69
+ const client = createMockClient();
70
+
71
+ await collector.execute({
72
+ config: { host: "8.8.8.8", count: 5, timeout: 3000 },
73
+ client,
74
+ pluginId: "test",
75
+ });
76
+
77
+ expect(client.exec).toHaveBeenCalledWith({
78
+ host: "8.8.8.8",
79
+ count: 5,
80
+ timeout: 3000,
81
+ });
82
+ });
83
+ });
84
+
85
+ describe("aggregateResult", () => {
86
+ it("should calculate average packet loss and latency", () => {
87
+ const collector = new PingCollector();
88
+ const runs = [
89
+ {
90
+ id: "1",
91
+ status: "healthy" as const,
92
+ latencyMs: 10,
93
+ checkId: "c1",
94
+ timestamp: new Date(),
95
+ metadata: {
96
+ packetsSent: 3,
97
+ packetsReceived: 3,
98
+ packetLoss: 0,
99
+ avgLatency: 10,
100
+ },
101
+ },
102
+ {
103
+ id: "2",
104
+ status: "healthy" as const,
105
+ latencyMs: 15,
106
+ checkId: "c1",
107
+ timestamp: new Date(),
108
+ metadata: {
109
+ packetsSent: 3,
110
+ packetsReceived: 3,
111
+ packetLoss: 10,
112
+ avgLatency: 20,
113
+ },
114
+ },
115
+ ];
116
+
117
+ const aggregated = collector.aggregateResult(runs);
118
+
119
+ expect(aggregated.avgPacketLoss).toBe(5);
120
+ expect(aggregated.avgLatency).toBe(15);
121
+ });
122
+ });
123
+
124
+ describe("metadata", () => {
125
+ it("should have correct static properties", () => {
126
+ const collector = new PingCollector();
127
+
128
+ expect(collector.id).toBe("ping");
129
+ expect(collector.displayName).toBe("ICMP Ping");
130
+ expect(collector.allowMultiple).toBe(true);
131
+ expect(collector.supportedPlugins).toHaveLength(1);
132
+ });
133
+ });
134
+ });
@@ -0,0 +1,171 @@
1
+ import {
2
+ Versioned,
3
+ z,
4
+ type HealthCheckRunForAggregation,
5
+ type CollectorResult,
6
+ type CollectorStrategy,
7
+ } from "@checkstack/backend-api";
8
+ import { healthResultNumber } from "@checkstack/healthcheck-common";
9
+ import { pluginMetadata } from "./plugin-metadata";
10
+ import type { PingTransportClient } from "./transport-client";
11
+
12
+ // ============================================================================
13
+ // CONFIGURATION SCHEMA
14
+ // ============================================================================
15
+
16
+ const pingConfigSchema = z.object({
17
+ host: z.string().min(1).describe("Hostname or IP address to ping"),
18
+ count: z
19
+ .number()
20
+ .int()
21
+ .min(1)
22
+ .max(10)
23
+ .default(3)
24
+ .describe("Number of ping packets"),
25
+ timeout: z
26
+ .number()
27
+ .min(100)
28
+ .default(5000)
29
+ .describe("Timeout in milliseconds"),
30
+ });
31
+
32
+ export type PingConfig = z.infer<typeof pingConfigSchema>;
33
+
34
+ // ============================================================================
35
+ // RESULT SCHEMAS
36
+ // ============================================================================
37
+
38
+ const pingResultSchema = z.object({
39
+ packetsSent: healthResultNumber({
40
+ "x-chart-type": "counter",
41
+ "x-chart-label": "Packets Sent",
42
+ }),
43
+ packetsReceived: healthResultNumber({
44
+ "x-chart-type": "counter",
45
+ "x-chart-label": "Packets Received",
46
+ }),
47
+ packetLoss: healthResultNumber({
48
+ "x-chart-type": "gauge",
49
+ "x-chart-label": "Packet Loss",
50
+ "x-chart-unit": "%",
51
+ }),
52
+ minLatency: healthResultNumber({
53
+ "x-chart-type": "line",
54
+ "x-chart-label": "Min Latency",
55
+ "x-chart-unit": "ms",
56
+ }).optional(),
57
+ avgLatency: healthResultNumber({
58
+ "x-chart-type": "line",
59
+ "x-chart-label": "Avg Latency",
60
+ "x-chart-unit": "ms",
61
+ }).optional(),
62
+ maxLatency: healthResultNumber({
63
+ "x-chart-type": "line",
64
+ "x-chart-label": "Max Latency",
65
+ "x-chart-unit": "ms",
66
+ }).optional(),
67
+ });
68
+
69
+ export type PingResult = z.infer<typeof pingResultSchema>;
70
+
71
+ const pingAggregatedSchema = z.object({
72
+ avgPacketLoss: healthResultNumber({
73
+ "x-chart-type": "gauge",
74
+ "x-chart-label": "Avg Packet Loss",
75
+ "x-chart-unit": "%",
76
+ }),
77
+ avgLatency: healthResultNumber({
78
+ "x-chart-type": "line",
79
+ "x-chart-label": "Avg Latency",
80
+ "x-chart-unit": "ms",
81
+ }),
82
+ });
83
+
84
+ export type PingAggregatedResult = z.infer<typeof pingAggregatedSchema>;
85
+
86
+ // ============================================================================
87
+ // PING COLLECTOR
88
+ // ============================================================================
89
+
90
+ /**
91
+ * Built-in Ping collector.
92
+ * Performs ICMP ping and checks latency.
93
+ */
94
+ export class PingCollector
95
+ implements
96
+ CollectorStrategy<
97
+ PingTransportClient,
98
+ PingConfig,
99
+ PingResult,
100
+ PingAggregatedResult
101
+ >
102
+ {
103
+ id = "ping";
104
+ displayName = "ICMP Ping";
105
+ description = "Ping a host and check latency";
106
+
107
+ supportedPlugins = [pluginMetadata];
108
+
109
+ allowMultiple = true;
110
+
111
+ config = new Versioned({ version: 1, schema: pingConfigSchema });
112
+ result = new Versioned({ version: 1, schema: pingResultSchema });
113
+ aggregatedResult = new Versioned({
114
+ version: 1,
115
+ schema: pingAggregatedSchema,
116
+ });
117
+
118
+ async execute({
119
+ config,
120
+ client,
121
+ }: {
122
+ config: PingConfig;
123
+ client: PingTransportClient;
124
+ pluginId: string;
125
+ }): Promise<CollectorResult<PingResult>> {
126
+ const response = await client.exec({
127
+ host: config.host,
128
+ count: config.count,
129
+ timeout: config.timeout,
130
+ });
131
+
132
+ return {
133
+ result: {
134
+ packetsSent: response.packetsSent,
135
+ packetsReceived: response.packetsReceived,
136
+ packetLoss: response.packetLoss,
137
+ minLatency: response.minLatency,
138
+ avgLatency: response.avgLatency,
139
+ maxLatency: response.maxLatency,
140
+ },
141
+ error: response.error,
142
+ };
143
+ }
144
+
145
+ aggregateResult(
146
+ runs: HealthCheckRunForAggregation<PingResult>[]
147
+ ): PingAggregatedResult {
148
+ const losses = runs
149
+ .map((r) => r.metadata?.packetLoss)
150
+ .filter((v): v is number => typeof v === "number");
151
+
152
+ const latencies = runs
153
+ .map((r) => r.metadata?.avgLatency)
154
+ .filter((v): v is number => typeof v === "number");
155
+
156
+ return {
157
+ avgPacketLoss:
158
+ losses.length > 0
159
+ ? Math.round(
160
+ (losses.reduce((a, b) => a + b, 0) / losses.length) * 10
161
+ ) / 10
162
+ : 0,
163
+ avgLatency:
164
+ latencies.length > 0
165
+ ? Math.round(
166
+ (latencies.reduce((a, b) => a + b, 0) / latencies.length) * 10
167
+ ) / 10
168
+ : 0,
169
+ };
170
+ }
171
+ }
@@ -1,4 +1,4 @@
1
- import { describe, expect, it, mock, beforeEach } from "bun:test";
1
+ import { describe, expect, it, mock, beforeEach, afterEach } from "bun:test";
2
2
  import { PingHealthCheckStrategy } from "./strategy";
3
3
 
4
4
  // Mock Bun.spawn for testing
@@ -39,22 +39,41 @@ describe("PingHealthCheckStrategy", () => {
39
39
  Bun.spawn = originalSpawn;
40
40
  });
41
41
 
42
- describe("execute", () => {
43
- it("should return healthy for successful ping", async () => {
44
- const result = await strategy.execute({
42
+ describe("createClient", () => {
43
+ it("should return a connected client", async () => {
44
+ const connectedClient = await strategy.createClient({ timeout: 5000 });
45
+
46
+ expect(connectedClient.client).toBeDefined();
47
+ expect(connectedClient.client.exec).toBeDefined();
48
+ expect(connectedClient.close).toBeDefined();
49
+ });
50
+
51
+ it("should allow closing the client", async () => {
52
+ const connectedClient = await strategy.createClient({ timeout: 5000 });
53
+
54
+ // Close should not throw
55
+ expect(() => connectedClient.close()).not.toThrow();
56
+ });
57
+ });
58
+
59
+ describe("client.exec", () => {
60
+ it("should return healthy result for successful ping", async () => {
61
+ const connectedClient = await strategy.createClient({ timeout: 5000 });
62
+ const result = await connectedClient.client.exec({
45
63
  host: "8.8.8.8",
46
64
  count: 3,
47
65
  timeout: 5000,
48
66
  });
49
67
 
50
- expect(result.status).toBe("healthy");
51
- expect(result.metadata?.packetsSent).toBe(3);
52
- expect(result.metadata?.packetsReceived).toBe(3);
53
- expect(result.metadata?.packetLoss).toBe(0);
54
- expect(result.metadata?.avgLatency).toBeCloseTo(11.456, 2);
68
+ expect(result.packetsSent).toBe(3);
69
+ expect(result.packetsReceived).toBe(3);
70
+ expect(result.packetLoss).toBe(0);
71
+ expect(result.avgLatency).toBeCloseTo(11.456, 2);
72
+
73
+ connectedClient.close();
55
74
  });
56
75
 
57
- it("should return unhealthy for 100% packet loss", async () => {
76
+ it("should return unhealthy result for 100% packet loss", async () => {
58
77
  // @ts-expect-error - mocking global
59
78
  Bun.spawn = mock(() => ({
60
79
  stdout: new ReadableStream({
@@ -74,65 +93,49 @@ describe("PingHealthCheckStrategy", () => {
74
93
  exited: Promise.resolve(1),
75
94
  }));
76
95
 
77
- const result = await strategy.execute({
96
+ const connectedClient = await strategy.createClient({ timeout: 5000 });
97
+ const result = await connectedClient.client.exec({
78
98
  host: "10.0.0.1",
79
99
  count: 3,
80
100
  timeout: 5000,
81
101
  });
82
102
 
83
- expect(result.status).toBe("unhealthy");
84
- expect(result.metadata?.packetLoss).toBe(100);
85
- expect(result.message).toContain("unreachable");
86
- });
87
-
88
- it("should pass latency assertion when below threshold", async () => {
89
- const result = await strategy.execute({
90
- host: "8.8.8.8",
91
- count: 3,
92
- timeout: 5000,
93
- assertions: [{ field: "avgLatency", operator: "lessThan", value: 50 }],
94
- });
103
+ expect(result.packetLoss).toBe(100);
104
+ expect(result.error).toContain("unreachable");
95
105
 
96
- expect(result.status).toBe("healthy");
106
+ connectedClient.close();
97
107
  });
98
108
 
99
- it("should fail latency assertion when above threshold", async () => {
100
- const result = await strategy.execute({
101
- host: "8.8.8.8",
102
- count: 3,
103
- timeout: 5000,
104
- assertions: [{ field: "avgLatency", operator: "lessThan", value: 5 }],
105
- });
106
-
107
- expect(result.status).toBe("unhealthy");
108
- expect(result.message).toContain("Assertion failed");
109
- expect(result.metadata?.failedAssertion).toBeDefined();
110
- });
109
+ it("should handle spawn errors gracefully", async () => {
110
+ Bun.spawn = mock(() => {
111
+ throw new Error("Command not found");
112
+ }) as typeof Bun.spawn;
111
113
 
112
- it("should pass packet loss assertion", async () => {
113
- const result = await strategy.execute({
114
+ const connectedClient = await strategy.createClient({ timeout: 5000 });
115
+ const result = await connectedClient.client.exec({
114
116
  host: "8.8.8.8",
115
117
  count: 3,
116
118
  timeout: 5000,
117
- assertions: [{ field: "packetLoss", operator: "equals", value: 0 }],
118
119
  });
119
120
 
120
- expect(result.status).toBe("healthy");
121
+ expect(result.error).toBeDefined();
122
+
123
+ connectedClient.close();
121
124
  });
122
125
 
123
- it("should handle spawn errors gracefully", async () => {
124
- Bun.spawn = mock(() => {
125
- throw new Error("Command not found");
126
- }) as typeof Bun.spawn;
126
+ it("should use strategy timeout as fallback", async () => {
127
+ const connectedClient = await strategy.createClient({ timeout: 5000 });
127
128
 
128
- const result = await strategy.execute({
129
+ // The exec should work without timeout specified in request
130
+ const result = await connectedClient.client.exec({
129
131
  host: "8.8.8.8",
130
132
  count: 3,
131
- timeout: 5000,
133
+ timeout: 30_000,
132
134
  });
133
135
 
134
- expect(result.status).toBe("unhealthy");
135
- expect(result.metadata?.error).toBeDefined();
136
+ expect(result.packetsSent).toBe(3);
137
+
138
+ connectedClient.close();
136
139
  });
137
140
  });
138
141
 
@@ -200,6 +203,3 @@ describe("PingHealthCheckStrategy", () => {
200
203
  });
201
204
  });
202
205
  });
203
-
204
- // Import afterEach
205
- import { afterEach } from "bun:test";
package/src/strategy.ts CHANGED
@@ -1,59 +1,45 @@
1
1
  import {
2
2
  HealthCheckStrategy,
3
- HealthCheckResult,
4
3
  HealthCheckRunForAggregation,
5
4
  Versioned,
6
5
  z,
7
- numericField,
8
- timeThresholdField,
9
- evaluateAssertions,
6
+ type ConnectedClient,
10
7
  } from "@checkstack/backend-api";
11
8
  import {
12
9
  healthResultNumber,
13
10
  healthResultString,
14
11
  } from "@checkstack/healthcheck-common";
12
+ import type {
13
+ PingTransportClient,
14
+ PingRequest,
15
+ PingResult as PingResultType,
16
+ } from "./transport-client";
15
17
 
16
18
  // ============================================================================
17
19
  // SCHEMAS
18
20
  // ============================================================================
19
21
 
20
- /**
21
- * Assertion schema for Ping health checks using shared factories.
22
- */
23
- const pingAssertionSchema = z.discriminatedUnion("field", [
24
- numericField("packetLoss", { min: 0, max: 100 }),
25
- timeThresholdField("avgLatency"),
26
- timeThresholdField("maxLatency"),
27
- timeThresholdField("minLatency"),
28
- ]);
29
-
30
- export type PingAssertion = z.infer<typeof pingAssertionSchema>;
31
-
32
22
  /**
33
23
  * Configuration schema for Ping health checks.
24
+ * Global defaults only - action params moved to PingCollector.
34
25
  */
35
26
  export const pingConfigSchema = z.object({
36
- host: z.string().describe("Hostname or IP address to ping"),
37
- count: z
38
- .number()
39
- .int()
40
- .min(1)
41
- .max(10)
42
- .default(3)
43
- .describe("Number of ping packets to send"),
44
27
  timeout: z
45
28
  .number()
46
29
  .min(100)
47
30
  .default(5000)
48
- .describe("Timeout in milliseconds"),
49
- assertions: z
50
- .array(pingAssertionSchema)
51
- .optional()
52
- .describe("Conditions that must pass for a healthy result"),
31
+ .describe("Default timeout in milliseconds"),
53
32
  });
54
33
 
55
34
  export type PingConfig = z.infer<typeof pingConfigSchema>;
56
35
 
36
+ // Legacy config type for migrations
37
+ interface PingConfigV1 {
38
+ host: string;
39
+ count: number;
40
+ timeout: number;
41
+ }
42
+
57
43
  /**
58
44
  * Per-run result metadata.
59
45
  */
@@ -86,14 +72,13 @@ const pingResultSchema = z.object({
86
72
  "x-chart-label": "Max Latency",
87
73
  "x-chart-unit": "ms",
88
74
  }).optional(),
89
- failedAssertion: pingAssertionSchema.optional(),
90
75
  error: healthResultString({
91
76
  "x-chart-type": "status",
92
77
  "x-chart-label": "Error",
93
78
  }).optional(),
94
79
  });
95
80
 
96
- export type PingResult = z.infer<typeof pingResultSchema>;
81
+ type PingResult = z.infer<typeof pingResultSchema>;
97
82
 
98
83
  /**
99
84
  * Aggregated metadata for buckets.
@@ -120,27 +105,51 @@ const pingAggregatedSchema = z.object({
120
105
  }),
121
106
  });
122
107
 
123
- export type PingAggregatedResult = z.infer<typeof pingAggregatedSchema>;
108
+ type PingAggregatedResult = z.infer<typeof pingAggregatedSchema>;
124
109
 
125
110
  // ============================================================================
126
111
  // STRATEGY
127
112
  // ============================================================================
128
113
 
129
114
  export class PingHealthCheckStrategy
130
- implements HealthCheckStrategy<PingConfig, PingResult, PingAggregatedResult>
115
+ implements
116
+ HealthCheckStrategy<
117
+ PingConfig,
118
+ PingTransportClient,
119
+ PingResult,
120
+ PingAggregatedResult
121
+ >
131
122
  {
132
123
  id = "ping";
133
124
  displayName = "Ping Health Check";
134
125
  description = "ICMP ping check for network reachability and latency";
135
126
 
136
127
  config: Versioned<PingConfig> = new Versioned({
137
- version: 1,
128
+ version: 2,
138
129
  schema: pingConfigSchema,
130
+ migrations: [
131
+ {
132
+ fromVersion: 1,
133
+ toVersion: 2,
134
+ description: "Remove host/count (moved to PingCollector)",
135
+ migrate: (data: PingConfigV1): PingConfig => ({
136
+ timeout: data.timeout,
137
+ }),
138
+ },
139
+ ],
139
140
  });
140
141
 
141
142
  result: Versioned<PingResult> = new Versioned({
142
- version: 1,
143
+ version: 2,
143
144
  schema: pingResultSchema,
145
+ migrations: [
146
+ {
147
+ fromVersion: 1,
148
+ toVersion: 2,
149
+ description: "Migrate to createClient pattern (no result changes)",
150
+ migrate: (data: unknown) => data,
151
+ },
152
+ ],
144
153
  });
145
154
 
146
155
  aggregatedResult: Versioned<PingAggregatedResult> = new Versioned({
@@ -151,141 +160,108 @@ export class PingHealthCheckStrategy
151
160
  aggregateResult(
152
161
  runs: HealthCheckRunForAggregation<PingResult>[]
153
162
  ): PingAggregatedResult {
154
- let totalPacketLoss = 0;
155
- let totalLatency = 0;
156
- let maxLatency = 0;
157
- let errorCount = 0;
158
- let validRuns = 0;
159
-
160
- for (const run of runs) {
161
- if (run.metadata?.error) {
162
- errorCount++;
163
- continue;
164
- }
165
- if (run.metadata) {
166
- totalPacketLoss += run.metadata.packetLoss ?? 0;
167
- if (run.metadata.avgLatency !== undefined) {
168
- totalLatency += run.metadata.avgLatency;
169
- validRuns++;
170
- }
171
- if (
172
- run.metadata.maxLatency !== undefined &&
173
- run.metadata.maxLatency > maxLatency
174
- ) {
175
- maxLatency = run.metadata.maxLatency;
176
- }
177
- }
163
+ const validRuns = runs.filter((r) => r.metadata);
164
+
165
+ if (validRuns.length === 0) {
166
+ return { avgPacketLoss: 0, avgLatency: 0, maxLatency: 0, errorCount: 0 };
178
167
  }
179
168
 
180
- return {
181
- avgPacketLoss: runs.length > 0 ? totalPacketLoss / runs.length : 0,
182
- avgLatency: validRuns > 0 ? totalLatency / validRuns : 0,
183
- maxLatency,
184
- errorCount,
185
- };
169
+ const packetLosses = validRuns
170
+ .map((r) => r.metadata?.packetLoss)
171
+ .filter((l): l is number => typeof l === "number");
172
+
173
+ const avgPacketLoss =
174
+ packetLosses.length > 0
175
+ ? Math.round(
176
+ (packetLosses.reduce((a, b) => a + b, 0) / packetLosses.length) * 10
177
+ ) / 10
178
+ : 0;
179
+
180
+ const latencies = validRuns
181
+ .map((r) => r.metadata?.avgLatency)
182
+ .filter((l): l is number => typeof l === "number");
183
+
184
+ const avgLatency =
185
+ latencies.length > 0
186
+ ? Math.round(
187
+ (latencies.reduce((a, b) => a + b, 0) / latencies.length) * 10
188
+ ) / 10
189
+ : 0;
190
+
191
+ const maxLatencies = validRuns
192
+ .map((r) => r.metadata?.maxLatency)
193
+ .filter((l): l is number => typeof l === "number");
194
+
195
+ const maxLatency = maxLatencies.length > 0 ? Math.max(...maxLatencies) : 0;
196
+
197
+ const errorCount = validRuns.filter(
198
+ (r) => r.metadata?.error !== undefined
199
+ ).length;
200
+
201
+ return { avgPacketLoss, avgLatency, maxLatency, errorCount };
186
202
  }
187
203
 
188
- async execute(config: PingConfig): Promise<HealthCheckResult<PingResult>> {
204
+ async createClient(
205
+ config: PingConfig
206
+ ): Promise<ConnectedClient<PingTransportClient>> {
189
207
  const validatedConfig = this.config.validate(config);
190
- const start = performance.now();
191
208
 
192
- try {
193
- const result = await this.runPing(
194
- validatedConfig.host,
195
- validatedConfig.count,
196
- validatedConfig.timeout
197
- );
198
-
199
- const latencyMs =
200
- result.avgLatency ?? Math.round(performance.now() - start);
201
-
202
- // Evaluate assertions using shared utility
203
- const failedAssertion = evaluateAssertions(validatedConfig.assertions, {
204
- packetLoss: result.packetLoss,
205
- avgLatency: result.avgLatency,
206
- maxLatency: result.maxLatency,
207
- minLatency: result.minLatency,
208
- });
209
-
210
- if (failedAssertion) {
211
- return {
212
- status: "unhealthy",
213
- latencyMs,
214
- message: `Assertion failed: ${failedAssertion.field} ${failedAssertion.operator} ${failedAssertion.value}`,
215
- metadata: { ...result, failedAssertion },
216
- };
217
- }
218
-
219
- // Check for packet loss without explicit assertion
220
- if (result.packetLoss === 100) {
221
- return {
222
- status: "unhealthy",
223
- latencyMs,
224
- message: `Host ${validatedConfig.host} is unreachable (100% packet loss)`,
225
- metadata: result,
226
- };
227
- }
209
+ const client: PingTransportClient = {
210
+ exec: async (request: PingRequest): Promise<PingResultType> => {
211
+ return this.runPing(
212
+ request.host,
213
+ request.count,
214
+ request.timeout ?? validatedConfig.timeout
215
+ );
216
+ },
217
+ };
228
218
 
229
- return {
230
- status: "healthy",
231
- latencyMs,
232
- message: `Ping to ${validatedConfig.host}: ${result.packetsReceived}/${
233
- result.packetsSent
234
- } packets, avg ${result.avgLatency?.toFixed(1)}ms`,
235
- metadata: result,
236
- };
237
- } catch (error: unknown) {
238
- const end = performance.now();
239
- const isError = error instanceof Error;
240
- return {
241
- status: "unhealthy",
242
- latencyMs: Math.round(end - start),
243
- message: isError ? error.message : "Ping failed",
244
- metadata: {
245
- packetsSent: validatedConfig.count,
246
- packetsReceived: 0,
247
- packetLoss: 100,
248
- error: isError ? error.name : "UnknownError",
249
- },
250
- };
251
- }
219
+ return {
220
+ client,
221
+ close: () => {
222
+ // Ping is stateless, nothing to close
223
+ },
224
+ };
252
225
  }
253
226
 
254
- /**
255
- * Execute ping using Bun subprocess.
256
- * Uses system ping command for cross-platform compatibility.
257
- */
258
227
  private async runPing(
259
228
  host: string,
260
229
  count: number,
261
230
  timeout: number
262
- ): Promise<Omit<PingResult, "failedAssertion" | "error">> {
231
+ ): Promise<PingResultType> {
263
232
  const isMac = process.platform === "darwin";
264
233
  const args = isMac
265
234
  ? ["-c", String(count), "-W", String(Math.ceil(timeout / 1000)), host]
266
235
  : ["-c", String(count), "-W", String(Math.ceil(timeout / 1000)), host];
267
236
 
268
- const proc = Bun.spawn(["ping", ...args], {
269
- stdout: "pipe",
270
- stderr: "pipe",
271
- });
237
+ try {
238
+ const proc = Bun.spawn({
239
+ cmd: ["ping", ...args],
240
+ stdout: "pipe",
241
+ stderr: "pipe",
242
+ });
272
243
 
273
- const output = await new Response(proc.stdout).text();
274
- const exitCode = await proc.exited;
244
+ const output = await new Response(proc.stdout).text();
245
+ const exitCode = await proc.exited;
275
246
 
276
- // Parse ping output
277
- return this.parsePingOutput(output, count, exitCode);
247
+ return this.parsePingOutput(output, count, exitCode);
248
+ } catch (error_) {
249
+ const error = error_ instanceof Error ? error_.message : String(error_);
250
+ return {
251
+ packetsSent: count,
252
+ packetsReceived: 0,
253
+ packetLoss: 100,
254
+ error,
255
+ };
256
+ }
278
257
  }
279
258
 
280
- /**
281
- * Parse ping command output to extract statistics.
282
- */
283
259
  private parsePingOutput(
284
260
  output: string,
285
261
  expectedCount: number,
286
262
  _exitCode: number
287
- ): Omit<PingResult, "failedAssertion" | "error"> {
288
- // Match statistics line: "X packets transmitted, Y received"
263
+ ): PingResultType {
264
+ // Parse packet statistics
289
265
  const statsMatch = output.match(
290
266
  /(\d+) packets transmitted, (\d+) (?:packets )?received/
291
267
  );
@@ -298,18 +274,22 @@ export class PingHealthCheckStrategy
298
274
  ? Math.round(((packetsSent - packetsReceived) / packetsSent) * 100)
299
275
  : 100;
300
276
 
301
- // Match latency line: "min/avg/max" or "min/avg/max/mdev"
302
- const latencyMatch = output.match(/= ([\d.]+)\/([\d.]+)\/([\d.]+)/);
277
+ // Parse latency statistics (format varies by OS)
278
+ // macOS: round-trip min/avg/max/stddev = 0.043/0.059/0.082/0.016 ms
279
+ // Linux: rtt min/avg/max/mdev = 0.039/0.049/0.064/0.009 ms
280
+ const latencyMatch = output.match(
281
+ /(?:round-trip|rtt) min\/avg\/max\/(?:stddev|mdev) = ([\d.]+)\/([\d.]+)\/([\d.]+)/
282
+ );
283
+
284
+ let minLatency: number | undefined;
285
+ let avgLatency: number | undefined;
286
+ let maxLatency: number | undefined;
303
287
 
304
- const minLatency = latencyMatch
305
- ? Number.parseFloat(latencyMatch[1])
306
- : undefined;
307
- const avgLatency = latencyMatch
308
- ? Number.parseFloat(latencyMatch[2])
309
- : undefined;
310
- const maxLatency = latencyMatch
311
- ? Number.parseFloat(latencyMatch[3])
312
- : undefined;
288
+ if (latencyMatch) {
289
+ minLatency = Number.parseFloat(latencyMatch[1]);
290
+ avgLatency = Number.parseFloat(latencyMatch[2]);
291
+ maxLatency = Number.parseFloat(latencyMatch[3]);
292
+ }
313
293
 
314
294
  return {
315
295
  packetsSent,
@@ -318,6 +298,9 @@ export class PingHealthCheckStrategy
318
298
  minLatency,
319
299
  avgLatency,
320
300
  maxLatency,
301
+ ...(packetLoss === 100 && {
302
+ error: "Host unreachable or 100% packet loss",
303
+ }),
321
304
  };
322
305
  }
323
306
  }
@@ -0,0 +1,28 @@
1
+ import type { TransportClient } from "@checkstack/common";
2
+
3
+ /**
4
+ * Ping request.
5
+ */
6
+ export interface PingRequest {
7
+ host: string;
8
+ count: number;
9
+ timeout: number;
10
+ }
11
+
12
+ /**
13
+ * Ping result.
14
+ */
15
+ export interface PingResult {
16
+ packetsSent: number;
17
+ packetsReceived: number;
18
+ packetLoss: number;
19
+ minLatency?: number;
20
+ avgLatency?: number;
21
+ maxLatency?: number;
22
+ error?: string;
23
+ }
24
+
25
+ /**
26
+ * Ping transport client for ICMP checks.
27
+ */
28
+ export type PingTransportClient = TransportClient<PingRequest, PingResult>;