@checkstack/healthcheck-ping-backend 0.0.2

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 ADDED
@@ -0,0 +1,37 @@
1
+ # @checkstack/healthcheck-ping-backend
2
+
3
+ ## 0.0.2
4
+
5
+ ### Patch Changes
6
+
7
+ - d20d274: Initial release of all @checkstack packages. Rebranded from Checkmate to Checkstack with new npm organization @checkstack and domain checkstack.dev.
8
+ - Updated dependencies [d20d274]
9
+ - @checkstack/backend-api@0.0.2
10
+ - @checkstack/common@0.0.2
11
+ - @checkstack/healthcheck-common@0.0.2
12
+
13
+ ## 0.0.3
14
+
15
+ ### Patch Changes
16
+
17
+ - Updated dependencies [b4eb432]
18
+ - Updated dependencies [a65e002]
19
+ - @checkstack/backend-api@1.1.0
20
+ - @checkstack/common@0.2.0
21
+ - @checkstack/healthcheck-common@0.1.1
22
+
23
+ ## 0.0.2
24
+
25
+ ### Patch Changes
26
+
27
+ - Updated dependencies [ffc28f6]
28
+ - Updated dependencies [4dd644d]
29
+ - Updated dependencies [71275dd]
30
+ - Updated dependencies [ae19ff6]
31
+ - Updated dependencies [0babb9c]
32
+ - Updated dependencies [b55fae6]
33
+ - Updated dependencies [b354ab3]
34
+ - Updated dependencies [81f3f85]
35
+ - @checkstack/common@0.1.0
36
+ - @checkstack/backend-api@1.0.0
37
+ - @checkstack/healthcheck-common@0.1.0
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@checkstack/healthcheck-ping-backend",
3
+ "version": "0.0.2",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "scripts": {
7
+ "typecheck": "tsc --noEmit",
8
+ "lint": "bun run lint:code",
9
+ "lint:code": "eslint . --max-warnings 0"
10
+ },
11
+ "dependencies": {
12
+ "@checkstack/backend-api": "workspace:*",
13
+ "@checkstack/common": "workspace:*",
14
+ "@checkstack/healthcheck-common": "workspace:*"
15
+ },
16
+ "devDependencies": {
17
+ "@types/bun": "^1.0.0",
18
+ "typescript": "^5.0.0",
19
+ "@checkstack/tsconfig": "workspace:*",
20
+ "@checkstack/scripts": "workspace:*"
21
+ }
22
+ }
package/src/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ import {
2
+ createBackendPlugin,
3
+ coreServices,
4
+ } from "@checkstack/backend-api";
5
+ import { PingHealthCheckStrategy } from "./strategy";
6
+ import { pluginMetadata } from "./plugin-metadata";
7
+
8
+ export default createBackendPlugin({
9
+ metadata: pluginMetadata,
10
+ register(env) {
11
+ env.registerInit({
12
+ deps: {
13
+ healthCheckRegistry: coreServices.healthCheckRegistry,
14
+ logger: coreServices.logger,
15
+ },
16
+ init: async ({ healthCheckRegistry, logger }) => {
17
+ logger.debug("🔌 Registering Ping Health Check Strategy...");
18
+ const strategy = new PingHealthCheckStrategy();
19
+ healthCheckRegistry.register(strategy);
20
+ },
21
+ });
22
+ },
23
+ });
@@ -0,0 +1,9 @@
1
+ import { definePluginMetadata } from "@checkstack/common";
2
+
3
+ /**
4
+ * Plugin metadata for the Ping Health Check backend.
5
+ * This is the single source of truth for the plugin ID.
6
+ */
7
+ export const pluginMetadata = definePluginMetadata({
8
+ pluginId: "healthcheck-ping",
9
+ });
@@ -0,0 +1,205 @@
1
+ import { describe, expect, it, mock, beforeEach } from "bun:test";
2
+ import { PingHealthCheckStrategy } from "./strategy";
3
+
4
+ // Mock Bun.spawn for testing
5
+ const mockSpawn = mock(() => ({
6
+ stdout: new ReadableStream({
7
+ start(controller) {
8
+ controller.enqueue(
9
+ new TextEncoder().encode(
10
+ `PING 8.8.8.8 (8.8.8.8): 56 data bytes
11
+ 64 bytes from 8.8.8.8: icmp_seq=0 ttl=118 time=10.123 ms
12
+ 64 bytes from 8.8.8.8: icmp_seq=1 ttl=118 time=12.456 ms
13
+ 64 bytes from 8.8.8.8: icmp_seq=2 ttl=118 time=11.789 ms
14
+
15
+ --- 8.8.8.8 ping statistics ---
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
+ )
19
+ );
20
+ controller.close();
21
+ },
22
+ }),
23
+ stderr: new ReadableStream(),
24
+ exited: Promise.resolve(0),
25
+ }));
26
+
27
+ describe("PingHealthCheckStrategy", () => {
28
+ let strategy: PingHealthCheckStrategy;
29
+ const originalSpawn = Bun.spawn;
30
+
31
+ beforeEach(() => {
32
+ strategy = new PingHealthCheckStrategy();
33
+ mockSpawn.mockClear();
34
+ // @ts-expect-error - mocking global
35
+ Bun.spawn = mockSpawn;
36
+ });
37
+
38
+ afterEach(() => {
39
+ Bun.spawn = originalSpawn;
40
+ });
41
+
42
+ describe("execute", () => {
43
+ it("should return healthy for successful ping", async () => {
44
+ const result = await strategy.execute({
45
+ host: "8.8.8.8",
46
+ count: 3,
47
+ timeout: 5000,
48
+ });
49
+
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);
55
+ });
56
+
57
+ it("should return unhealthy for 100% packet loss", async () => {
58
+ // @ts-expect-error - mocking global
59
+ Bun.spawn = mock(() => ({
60
+ stdout: new ReadableStream({
61
+ start(controller) {
62
+ controller.enqueue(
63
+ new TextEncoder().encode(
64
+ `PING 10.0.0.1 (10.0.0.1): 56 data bytes
65
+
66
+ --- 10.0.0.1 ping statistics ---
67
+ 3 packets transmitted, 0 packets received, 100.0% packet loss`
68
+ )
69
+ );
70
+ controller.close();
71
+ },
72
+ }),
73
+ stderr: new ReadableStream(),
74
+ exited: Promise.resolve(1),
75
+ }));
76
+
77
+ const result = await strategy.execute({
78
+ host: "10.0.0.1",
79
+ count: 3,
80
+ timeout: 5000,
81
+ });
82
+
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
+ });
95
+
96
+ expect(result.status).toBe("healthy");
97
+ });
98
+
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
+ });
111
+
112
+ it("should pass packet loss assertion", async () => {
113
+ const result = await strategy.execute({
114
+ host: "8.8.8.8",
115
+ count: 3,
116
+ timeout: 5000,
117
+ assertions: [{ field: "packetLoss", operator: "equals", value: 0 }],
118
+ });
119
+
120
+ expect(result.status).toBe("healthy");
121
+ });
122
+
123
+ it("should handle spawn errors gracefully", async () => {
124
+ Bun.spawn = mock(() => {
125
+ throw new Error("Command not found");
126
+ }) as typeof Bun.spawn;
127
+
128
+ const result = await strategy.execute({
129
+ host: "8.8.8.8",
130
+ count: 3,
131
+ timeout: 5000,
132
+ });
133
+
134
+ expect(result.status).toBe("unhealthy");
135
+ expect(result.metadata?.error).toBeDefined();
136
+ });
137
+ });
138
+
139
+ describe("aggregateResult", () => {
140
+ it("should calculate averages correctly", () => {
141
+ const runs = [
142
+ {
143
+ id: "1",
144
+ status: "healthy" as const,
145
+ latencyMs: 10,
146
+ checkId: "c1",
147
+ timestamp: new Date(),
148
+ metadata: {
149
+ packetsSent: 3,
150
+ packetsReceived: 3,
151
+ packetLoss: 0,
152
+ avgLatency: 10,
153
+ maxLatency: 15,
154
+ },
155
+ },
156
+ {
157
+ id: "2",
158
+ status: "healthy" as const,
159
+ latencyMs: 20,
160
+ checkId: "c1",
161
+ timestamp: new Date(),
162
+ metadata: {
163
+ packetsSent: 3,
164
+ packetsReceived: 2,
165
+ packetLoss: 33,
166
+ avgLatency: 20,
167
+ maxLatency: 25,
168
+ },
169
+ },
170
+ ];
171
+
172
+ const aggregated = strategy.aggregateResult(runs);
173
+
174
+ expect(aggregated.avgPacketLoss).toBeCloseTo(16.5, 1);
175
+ expect(aggregated.avgLatency).toBeCloseTo(15, 1);
176
+ expect(aggregated.maxLatency).toBe(25);
177
+ expect(aggregated.errorCount).toBe(0);
178
+ });
179
+
180
+ it("should count errors", () => {
181
+ const runs = [
182
+ {
183
+ id: "1",
184
+ status: "unhealthy" as const,
185
+ latencyMs: 0,
186
+ checkId: "c1",
187
+ timestamp: new Date(),
188
+ metadata: {
189
+ packetsSent: 3,
190
+ packetsReceived: 0,
191
+ packetLoss: 100,
192
+ error: "Timeout",
193
+ },
194
+ },
195
+ ];
196
+
197
+ const aggregated = strategy.aggregateResult(runs);
198
+
199
+ expect(aggregated.errorCount).toBe(1);
200
+ });
201
+ });
202
+ });
203
+
204
+ // Import afterEach
205
+ import { afterEach } from "bun:test";
@@ -0,0 +1,323 @@
1
+ import {
2
+ HealthCheckStrategy,
3
+ HealthCheckResult,
4
+ HealthCheckRunForAggregation,
5
+ Versioned,
6
+ z,
7
+ numericField,
8
+ timeThresholdField,
9
+ evaluateAssertions,
10
+ } from "@checkstack/backend-api";
11
+ import {
12
+ healthResultNumber,
13
+ healthResultString,
14
+ } from "@checkstack/healthcheck-common";
15
+
16
+ // ============================================================================
17
+ // SCHEMAS
18
+ // ============================================================================
19
+
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
+ /**
33
+ * Configuration schema for Ping health checks.
34
+ */
35
+ 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
+ timeout: z
45
+ .number()
46
+ .min(100)
47
+ .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"),
53
+ });
54
+
55
+ export type PingConfig = z.infer<typeof pingConfigSchema>;
56
+
57
+ /**
58
+ * Per-run result metadata.
59
+ */
60
+ const pingResultSchema = z.object({
61
+ packetsSent: healthResultNumber({
62
+ "x-chart-type": "counter",
63
+ "x-chart-label": "Packets Sent",
64
+ }),
65
+ packetsReceived: healthResultNumber({
66
+ "x-chart-type": "counter",
67
+ "x-chart-label": "Packets Received",
68
+ }),
69
+ packetLoss: healthResultNumber({
70
+ "x-chart-type": "gauge",
71
+ "x-chart-label": "Packet Loss",
72
+ "x-chart-unit": "%",
73
+ }),
74
+ minLatency: healthResultNumber({
75
+ "x-chart-type": "line",
76
+ "x-chart-label": "Min Latency",
77
+ "x-chart-unit": "ms",
78
+ }).optional(),
79
+ avgLatency: healthResultNumber({
80
+ "x-chart-type": "line",
81
+ "x-chart-label": "Avg Latency",
82
+ "x-chart-unit": "ms",
83
+ }).optional(),
84
+ maxLatency: healthResultNumber({
85
+ "x-chart-type": "line",
86
+ "x-chart-label": "Max Latency",
87
+ "x-chart-unit": "ms",
88
+ }).optional(),
89
+ failedAssertion: pingAssertionSchema.optional(),
90
+ error: healthResultString({
91
+ "x-chart-type": "status",
92
+ "x-chart-label": "Error",
93
+ }).optional(),
94
+ });
95
+
96
+ export type PingResult = z.infer<typeof pingResultSchema>;
97
+
98
+ /**
99
+ * Aggregated metadata for buckets.
100
+ */
101
+ const pingAggregatedSchema = z.object({
102
+ avgPacketLoss: healthResultNumber({
103
+ "x-chart-type": "gauge",
104
+ "x-chart-label": "Avg Packet Loss",
105
+ "x-chart-unit": "%",
106
+ }),
107
+ avgLatency: healthResultNumber({
108
+ "x-chart-type": "line",
109
+ "x-chart-label": "Avg Latency",
110
+ "x-chart-unit": "ms",
111
+ }),
112
+ maxLatency: healthResultNumber({
113
+ "x-chart-type": "line",
114
+ "x-chart-label": "Max Latency",
115
+ "x-chart-unit": "ms",
116
+ }),
117
+ errorCount: healthResultNumber({
118
+ "x-chart-type": "counter",
119
+ "x-chart-label": "Errors",
120
+ }),
121
+ });
122
+
123
+ export type PingAggregatedResult = z.infer<typeof pingAggregatedSchema>;
124
+
125
+ // ============================================================================
126
+ // STRATEGY
127
+ // ============================================================================
128
+
129
+ export class PingHealthCheckStrategy
130
+ implements HealthCheckStrategy<PingConfig, PingResult, PingAggregatedResult>
131
+ {
132
+ id = "ping";
133
+ displayName = "Ping Health Check";
134
+ description = "ICMP ping check for network reachability and latency";
135
+
136
+ config: Versioned<PingConfig> = new Versioned({
137
+ version: 1,
138
+ schema: pingConfigSchema,
139
+ });
140
+
141
+ result: Versioned<PingResult> = new Versioned({
142
+ version: 1,
143
+ schema: pingResultSchema,
144
+ });
145
+
146
+ aggregatedResult: Versioned<PingAggregatedResult> = new Versioned({
147
+ version: 1,
148
+ schema: pingAggregatedSchema,
149
+ });
150
+
151
+ aggregateResult(
152
+ runs: HealthCheckRunForAggregation<PingResult>[]
153
+ ): 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
+ }
178
+ }
179
+
180
+ return {
181
+ avgPacketLoss: runs.length > 0 ? totalPacketLoss / runs.length : 0,
182
+ avgLatency: validRuns > 0 ? totalLatency / validRuns : 0,
183
+ maxLatency,
184
+ errorCount,
185
+ };
186
+ }
187
+
188
+ async execute(config: PingConfig): Promise<HealthCheckResult<PingResult>> {
189
+ const validatedConfig = this.config.validate(config);
190
+ const start = performance.now();
191
+
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
+ }
228
+
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
+ }
252
+ }
253
+
254
+ /**
255
+ * Execute ping using Bun subprocess.
256
+ * Uses system ping command for cross-platform compatibility.
257
+ */
258
+ private async runPing(
259
+ host: string,
260
+ count: number,
261
+ timeout: number
262
+ ): Promise<Omit<PingResult, "failedAssertion" | "error">> {
263
+ const isMac = process.platform === "darwin";
264
+ const args = isMac
265
+ ? ["-c", String(count), "-W", String(Math.ceil(timeout / 1000)), host]
266
+ : ["-c", String(count), "-W", String(Math.ceil(timeout / 1000)), host];
267
+
268
+ const proc = Bun.spawn(["ping", ...args], {
269
+ stdout: "pipe",
270
+ stderr: "pipe",
271
+ });
272
+
273
+ const output = await new Response(proc.stdout).text();
274
+ const exitCode = await proc.exited;
275
+
276
+ // Parse ping output
277
+ return this.parsePingOutput(output, count, exitCode);
278
+ }
279
+
280
+ /**
281
+ * Parse ping command output to extract statistics.
282
+ */
283
+ private parsePingOutput(
284
+ output: string,
285
+ expectedCount: number,
286
+ _exitCode: number
287
+ ): Omit<PingResult, "failedAssertion" | "error"> {
288
+ // Match statistics line: "X packets transmitted, Y received"
289
+ const statsMatch = output.match(
290
+ /(\d+) packets transmitted, (\d+) (?:packets )?received/
291
+ );
292
+ const packetsSent = statsMatch
293
+ ? Number.parseInt(statsMatch[1], 10)
294
+ : expectedCount;
295
+ const packetsReceived = statsMatch ? Number.parseInt(statsMatch[2], 10) : 0;
296
+ const packetLoss =
297
+ packetsSent > 0
298
+ ? Math.round(((packetsSent - packetsReceived) / packetsSent) * 100)
299
+ : 100;
300
+
301
+ // Match latency line: "min/avg/max" or "min/avg/max/mdev"
302
+ const latencyMatch = output.match(/= ([\d.]+)\/([\d.]+)\/([\d.]+)/);
303
+
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;
313
+
314
+ return {
315
+ packetsSent,
316
+ packetsReceived,
317
+ packetLoss,
318
+ minLatency,
319
+ avgLatency,
320
+ maxLatency,
321
+ };
322
+ }
323
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/backend.json",
3
+ "include": [
4
+ "src"
5
+ ]
6
+ }