@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 +43 -0
- package/package.json +1 -1
- package/src/index.ts +7 -5
- package/src/ping-collector.test.ts +134 -0
- package/src/ping-collector.ts +171 -0
- package/src/strategy.test.ts +51 -51
- package/src/strategy.ts +138 -155
- package/src/transport-client.ts +28 -0
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
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
|
+
}
|
package/src/strategy.test.ts
CHANGED
|
@@ -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("
|
|
43
|
-
it("should return
|
|
44
|
-
const
|
|
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.
|
|
51
|
-
expect(result.
|
|
52
|
-
expect(result.
|
|
53
|
-
expect(result.
|
|
54
|
-
|
|
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
|
|
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.
|
|
84
|
-
expect(result.
|
|
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
|
-
|
|
106
|
+
connectedClient.close();
|
|
97
107
|
});
|
|
98
108
|
|
|
99
|
-
it("should
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
113
|
-
const result = await
|
|
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.
|
|
121
|
+
expect(result.error).toBeDefined();
|
|
122
|
+
|
|
123
|
+
connectedClient.close();
|
|
121
124
|
});
|
|
122
125
|
|
|
123
|
-
it("should
|
|
124
|
-
|
|
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
|
-
|
|
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:
|
|
133
|
+
timeout: 30_000,
|
|
132
134
|
});
|
|
133
135
|
|
|
134
|
-
expect(result.
|
|
135
|
-
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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<
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
237
|
+
try {
|
|
238
|
+
const proc = Bun.spawn({
|
|
239
|
+
cmd: ["ping", ...args],
|
|
240
|
+
stdout: "pipe",
|
|
241
|
+
stderr: "pipe",
|
|
242
|
+
});
|
|
272
243
|
|
|
273
|
-
|
|
274
|
-
|
|
244
|
+
const output = await new Response(proc.stdout).text();
|
|
245
|
+
const exitCode = await proc.exited;
|
|
275
246
|
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
):
|
|
288
|
-
//
|
|
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
|
-
//
|
|
302
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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>;
|