@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 +37 -0
- package/package.json +22 -0
- package/src/index.ts +23 -0
- package/src/plugin-metadata.ts +9 -0
- package/src/strategy.test.ts +205 -0
- package/src/strategy.ts +323 -0
- package/tsconfig.json +6 -0
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";
|
package/src/strategy.ts
ADDED
|
@@ -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
|
+
}
|