@checkstack/healthcheck-grpc-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/health-collector.test.ts +137 -0
- package/src/health-collector.ts +150 -0
- package/src/index.ts +7 -5
- package/src/strategy.test.ts +37 -70
- package/src/strategy.ts +126 -157
- package/src/transport-client.ts +24 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,48 @@
|
|
|
1
1
|
# @checkstack/healthcheck-grpc-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
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, expect, it, mock } from "bun:test";
|
|
2
|
+
import { HealthCollector, type HealthConfig } from "./health-collector";
|
|
3
|
+
import type {
|
|
4
|
+
GrpcTransportClient,
|
|
5
|
+
GrpcHealthResponse,
|
|
6
|
+
} from "./transport-client";
|
|
7
|
+
|
|
8
|
+
describe("HealthCollector", () => {
|
|
9
|
+
const createMockClient = (
|
|
10
|
+
response: Partial<GrpcHealthResponse> = {}
|
|
11
|
+
): GrpcTransportClient => ({
|
|
12
|
+
exec: mock(() =>
|
|
13
|
+
Promise.resolve({
|
|
14
|
+
status: response.status ?? "SERVING",
|
|
15
|
+
error: response.error,
|
|
16
|
+
})
|
|
17
|
+
),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("execute", () => {
|
|
21
|
+
it("should check health status successfully", async () => {
|
|
22
|
+
const collector = new HealthCollector();
|
|
23
|
+
const client = createMockClient({ status: "SERVING" });
|
|
24
|
+
|
|
25
|
+
const result = await collector.execute({
|
|
26
|
+
config: { service: "" },
|
|
27
|
+
client,
|
|
28
|
+
pluginId: "test",
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
expect(result.result.status).toBe("SERVING");
|
|
32
|
+
expect(result.result.serving).toBe(true);
|
|
33
|
+
expect(result.result.responseTimeMs).toBeGreaterThanOrEqual(0);
|
|
34
|
+
expect(result.error).toBeUndefined();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("should return error for NOT_SERVING status", async () => {
|
|
38
|
+
const collector = new HealthCollector();
|
|
39
|
+
const client = createMockClient({ status: "NOT_SERVING" });
|
|
40
|
+
|
|
41
|
+
const result = await collector.execute({
|
|
42
|
+
config: { service: "myservice" },
|
|
43
|
+
client,
|
|
44
|
+
pluginId: "test",
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
expect(result.result.status).toBe("NOT_SERVING");
|
|
48
|
+
expect(result.result.serving).toBe(false);
|
|
49
|
+
expect(result.error).toContain("NOT_SERVING");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("should pass service name to client", async () => {
|
|
53
|
+
const collector = new HealthCollector();
|
|
54
|
+
const client = createMockClient();
|
|
55
|
+
|
|
56
|
+
await collector.execute({
|
|
57
|
+
config: { service: "my.grpc.Service" },
|
|
58
|
+
client,
|
|
59
|
+
pluginId: "test",
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
expect(client.exec).toHaveBeenCalledWith({
|
|
63
|
+
service: "my.grpc.Service",
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("aggregateResult", () => {
|
|
69
|
+
it("should calculate average response time and serving rate", () => {
|
|
70
|
+
const collector = new HealthCollector();
|
|
71
|
+
const runs = [
|
|
72
|
+
{
|
|
73
|
+
id: "1",
|
|
74
|
+
status: "healthy" as const,
|
|
75
|
+
latencyMs: 10,
|
|
76
|
+
checkId: "c1",
|
|
77
|
+
timestamp: new Date(),
|
|
78
|
+
metadata: { status: "SERVING", serving: true, responseTimeMs: 50 },
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: "2",
|
|
82
|
+
status: "healthy" as const,
|
|
83
|
+
latencyMs: 15,
|
|
84
|
+
checkId: "c1",
|
|
85
|
+
timestamp: new Date(),
|
|
86
|
+
metadata: { status: "SERVING", serving: true, responseTimeMs: 100 },
|
|
87
|
+
},
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
const aggregated = collector.aggregateResult(runs);
|
|
91
|
+
|
|
92
|
+
expect(aggregated.avgResponseTimeMs).toBe(75);
|
|
93
|
+
expect(aggregated.servingRate).toBe(100);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("should calculate serving rate correctly", () => {
|
|
97
|
+
const collector = new HealthCollector();
|
|
98
|
+
const runs = [
|
|
99
|
+
{
|
|
100
|
+
id: "1",
|
|
101
|
+
status: "healthy" as const,
|
|
102
|
+
latencyMs: 10,
|
|
103
|
+
checkId: "c1",
|
|
104
|
+
timestamp: new Date(),
|
|
105
|
+
metadata: { status: "SERVING", serving: true, responseTimeMs: 50 },
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
id: "2",
|
|
109
|
+
status: "unhealthy" as const,
|
|
110
|
+
latencyMs: 15,
|
|
111
|
+
checkId: "c1",
|
|
112
|
+
timestamp: new Date(),
|
|
113
|
+
metadata: {
|
|
114
|
+
status: "NOT_SERVING",
|
|
115
|
+
serving: false,
|
|
116
|
+
responseTimeMs: 100,
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
const aggregated = collector.aggregateResult(runs);
|
|
122
|
+
|
|
123
|
+
expect(aggregated.servingRate).toBe(50);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("metadata", () => {
|
|
128
|
+
it("should have correct static properties", () => {
|
|
129
|
+
const collector = new HealthCollector();
|
|
130
|
+
|
|
131
|
+
expect(collector.id).toBe("health");
|
|
132
|
+
expect(collector.displayName).toBe("gRPC Health Check");
|
|
133
|
+
expect(collector.allowMultiple).toBe(true);
|
|
134
|
+
expect(collector.supportedPlugins).toHaveLength(1);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Versioned,
|
|
3
|
+
z,
|
|
4
|
+
type HealthCheckRunForAggregation,
|
|
5
|
+
type CollectorResult,
|
|
6
|
+
type CollectorStrategy,
|
|
7
|
+
} from "@checkstack/backend-api";
|
|
8
|
+
import {
|
|
9
|
+
healthResultNumber,
|
|
10
|
+
healthResultString,
|
|
11
|
+
healthResultBoolean,
|
|
12
|
+
} from "@checkstack/healthcheck-common";
|
|
13
|
+
import { pluginMetadata } from "./plugin-metadata";
|
|
14
|
+
import type { GrpcTransportClient } from "./transport-client";
|
|
15
|
+
|
|
16
|
+
// ============================================================================
|
|
17
|
+
// CONFIGURATION SCHEMA
|
|
18
|
+
// ============================================================================
|
|
19
|
+
|
|
20
|
+
const healthConfigSchema = z.object({
|
|
21
|
+
service: z
|
|
22
|
+
.string()
|
|
23
|
+
.default("")
|
|
24
|
+
.describe("Service name to check (empty for overall)"),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export type HealthConfig = z.infer<typeof healthConfigSchema>;
|
|
28
|
+
|
|
29
|
+
// ============================================================================
|
|
30
|
+
// RESULT SCHEMAS
|
|
31
|
+
// ============================================================================
|
|
32
|
+
|
|
33
|
+
const healthResultSchema = z.object({
|
|
34
|
+
status: healthResultString({
|
|
35
|
+
"x-chart-type": "text",
|
|
36
|
+
"x-chart-label": "Status",
|
|
37
|
+
}),
|
|
38
|
+
serving: healthResultBoolean({
|
|
39
|
+
"x-chart-type": "boolean",
|
|
40
|
+
"x-chart-label": "Serving",
|
|
41
|
+
}),
|
|
42
|
+
responseTimeMs: healthResultNumber({
|
|
43
|
+
"x-chart-type": "line",
|
|
44
|
+
"x-chart-label": "Response Time",
|
|
45
|
+
"x-chart-unit": "ms",
|
|
46
|
+
}),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export type HealthResult = z.infer<typeof healthResultSchema>;
|
|
50
|
+
|
|
51
|
+
const healthAggregatedSchema = z.object({
|
|
52
|
+
avgResponseTimeMs: healthResultNumber({
|
|
53
|
+
"x-chart-type": "line",
|
|
54
|
+
"x-chart-label": "Avg Response Time",
|
|
55
|
+
"x-chart-unit": "ms",
|
|
56
|
+
}),
|
|
57
|
+
servingRate: healthResultNumber({
|
|
58
|
+
"x-chart-type": "gauge",
|
|
59
|
+
"x-chart-label": "Serving Rate",
|
|
60
|
+
"x-chart-unit": "%",
|
|
61
|
+
}),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
export type HealthAggregatedResult = z.infer<typeof healthAggregatedSchema>;
|
|
65
|
+
|
|
66
|
+
// ============================================================================
|
|
67
|
+
// HEALTH COLLECTOR
|
|
68
|
+
// ============================================================================
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Built-in gRPC health collector.
|
|
72
|
+
* Checks gRPC health status using the standard Health Checking Protocol.
|
|
73
|
+
*/
|
|
74
|
+
export class HealthCollector
|
|
75
|
+
implements
|
|
76
|
+
CollectorStrategy<
|
|
77
|
+
GrpcTransportClient,
|
|
78
|
+
HealthConfig,
|
|
79
|
+
HealthResult,
|
|
80
|
+
HealthAggregatedResult
|
|
81
|
+
>
|
|
82
|
+
{
|
|
83
|
+
id = "health";
|
|
84
|
+
displayName = "gRPC Health Check";
|
|
85
|
+
description = "Check gRPC service health status";
|
|
86
|
+
|
|
87
|
+
supportedPlugins = [pluginMetadata];
|
|
88
|
+
|
|
89
|
+
allowMultiple = true;
|
|
90
|
+
|
|
91
|
+
config = new Versioned({ version: 1, schema: healthConfigSchema });
|
|
92
|
+
result = new Versioned({ version: 1, schema: healthResultSchema });
|
|
93
|
+
aggregatedResult = new Versioned({
|
|
94
|
+
version: 1,
|
|
95
|
+
schema: healthAggregatedSchema,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
async execute({
|
|
99
|
+
config,
|
|
100
|
+
client,
|
|
101
|
+
}: {
|
|
102
|
+
config: HealthConfig;
|
|
103
|
+
client: GrpcTransportClient;
|
|
104
|
+
pluginId: string;
|
|
105
|
+
}): Promise<CollectorResult<HealthResult>> {
|
|
106
|
+
const startTime = Date.now();
|
|
107
|
+
|
|
108
|
+
const response = await client.exec({
|
|
109
|
+
service: config.service,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const responseTimeMs = Date.now() - startTime;
|
|
113
|
+
const serving = response.status === "SERVING";
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
result: {
|
|
117
|
+
status: response.status,
|
|
118
|
+
serving,
|
|
119
|
+
responseTimeMs,
|
|
120
|
+
},
|
|
121
|
+
error:
|
|
122
|
+
response.error ?? (serving ? undefined : `Status: ${response.status}`),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
aggregateResult(
|
|
127
|
+
runs: HealthCheckRunForAggregation<HealthResult>[]
|
|
128
|
+
): HealthAggregatedResult {
|
|
129
|
+
const times = runs
|
|
130
|
+
.map((r) => r.metadata?.responseTimeMs)
|
|
131
|
+
.filter((v): v is number => typeof v === "number");
|
|
132
|
+
|
|
133
|
+
const servingResults = runs
|
|
134
|
+
.map((r) => r.metadata?.serving)
|
|
135
|
+
.filter((v): v is boolean => typeof v === "boolean");
|
|
136
|
+
|
|
137
|
+
const servingCount = servingResults.filter(Boolean).length;
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
avgResponseTimeMs:
|
|
141
|
+
times.length > 0
|
|
142
|
+
? Math.round(times.reduce((a, b) => a + b, 0) / times.length)
|
|
143
|
+
: 0,
|
|
144
|
+
servingRate:
|
|
145
|
+
servingResults.length > 0
|
|
146
|
+
? Math.round((servingCount / servingResults.length) * 100)
|
|
147
|
+
: 0,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
}
|
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 { GrpcHealthCheckStrategy } from "./strategy";
|
|
6
3
|
import { pluginMetadata } from "./plugin-metadata";
|
|
4
|
+
import { HealthCollector } from "./health-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 gRPC Health Check Strategy...");
|
|
18
17
|
const strategy = new GrpcHealthCheckStrategy();
|
|
19
18
|
healthCheckRegistry.register(strategy);
|
|
19
|
+
collectorRegistry.register(new HealthCollector());
|
|
20
20
|
},
|
|
21
21
|
});
|
|
22
22
|
},
|
|
23
23
|
});
|
|
24
|
+
|
|
25
|
+
export { pluginMetadata } from "./plugin-metadata";
|
package/src/strategy.test.ts
CHANGED
|
@@ -2,14 +2,14 @@ import { describe, expect, it, mock } from "bun:test";
|
|
|
2
2
|
import {
|
|
3
3
|
GrpcHealthCheckStrategy,
|
|
4
4
|
GrpcHealthClient,
|
|
5
|
-
|
|
5
|
+
GrpcHealthStatusType,
|
|
6
6
|
} from "./strategy";
|
|
7
7
|
|
|
8
8
|
describe("GrpcHealthCheckStrategy", () => {
|
|
9
9
|
// Helper to create mock gRPC client
|
|
10
10
|
const createMockClient = (
|
|
11
11
|
config: {
|
|
12
|
-
status?:
|
|
12
|
+
status?: GrpcHealthStatusType;
|
|
13
13
|
error?: Error;
|
|
14
14
|
} = {}
|
|
15
15
|
): GrpcHealthClient => ({
|
|
@@ -20,127 +20,94 @@ describe("GrpcHealthCheckStrategy", () => {
|
|
|
20
20
|
),
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
describe("
|
|
24
|
-
it("should return
|
|
23
|
+
describe("createClient", () => {
|
|
24
|
+
it("should return a connected client", async () => {
|
|
25
25
|
const strategy = new GrpcHealthCheckStrategy(createMockClient());
|
|
26
26
|
|
|
27
|
-
const
|
|
27
|
+
const connectedClient = await strategy.createClient({
|
|
28
28
|
host: "localhost",
|
|
29
29
|
port: 50051,
|
|
30
30
|
timeout: 5000,
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
-
expect(
|
|
34
|
-
expect(
|
|
35
|
-
expect(
|
|
33
|
+
expect(connectedClient.client).toBeDefined();
|
|
34
|
+
expect(connectedClient.client.exec).toBeDefined();
|
|
35
|
+
expect(connectedClient.close).toBeDefined();
|
|
36
|
+
|
|
37
|
+
connectedClient.close();
|
|
36
38
|
});
|
|
39
|
+
});
|
|
37
40
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
);
|
|
41
|
+
describe("client.exec (health check action)", () => {
|
|
42
|
+
it("should return SERVING status for healthy service", async () => {
|
|
43
|
+
const strategy = new GrpcHealthCheckStrategy(createMockClient());
|
|
42
44
|
|
|
43
|
-
const
|
|
45
|
+
const connectedClient = await strategy.createClient({
|
|
44
46
|
host: "localhost",
|
|
45
47
|
port: 50051,
|
|
46
48
|
timeout: 5000,
|
|
47
49
|
});
|
|
48
50
|
|
|
49
|
-
|
|
50
|
-
expect(result.message).toContain("NOT_SERVING");
|
|
51
|
-
expect(result.metadata?.status).toBe("NOT_SERVING");
|
|
52
|
-
});
|
|
51
|
+
const result = await connectedClient.client.exec({ service: "" });
|
|
53
52
|
|
|
54
|
-
|
|
55
|
-
const strategy = new GrpcHealthCheckStrategy(
|
|
56
|
-
createMockClient({ status: "SERVICE_UNKNOWN" })
|
|
57
|
-
);
|
|
53
|
+
expect(result.status).toBe("SERVING");
|
|
58
54
|
|
|
59
|
-
|
|
60
|
-
host: "localhost",
|
|
61
|
-
port: 50051,
|
|
62
|
-
service: "my.service",
|
|
63
|
-
timeout: 5000,
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
expect(result.status).toBe("unhealthy");
|
|
67
|
-
expect(result.message).toContain("SERVICE_UNKNOWN");
|
|
55
|
+
connectedClient.close();
|
|
68
56
|
});
|
|
69
57
|
|
|
70
|
-
it("should return
|
|
58
|
+
it("should return NOT_SERVING status for unhealthy service", async () => {
|
|
71
59
|
const strategy = new GrpcHealthCheckStrategy(
|
|
72
|
-
createMockClient({
|
|
60
|
+
createMockClient({ status: "NOT_SERVING" })
|
|
73
61
|
);
|
|
74
62
|
|
|
75
|
-
const
|
|
76
|
-
host: "localhost",
|
|
77
|
-
port: 50051,
|
|
78
|
-
timeout: 5000,
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
expect(result.status).toBe("unhealthy");
|
|
82
|
-
expect(result.message).toContain("Connection refused");
|
|
83
|
-
expect(result.metadata?.connected).toBe(false);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it("should pass responseTime assertion when below threshold", async () => {
|
|
87
|
-
const strategy = new GrpcHealthCheckStrategy(createMockClient());
|
|
88
|
-
|
|
89
|
-
const result = await strategy.execute({
|
|
63
|
+
const connectedClient = await strategy.createClient({
|
|
90
64
|
host: "localhost",
|
|
91
65
|
port: 50051,
|
|
92
66
|
timeout: 5000,
|
|
93
|
-
assertions: [
|
|
94
|
-
{ field: "responseTime", operator: "lessThan", value: 5000 },
|
|
95
|
-
],
|
|
96
67
|
});
|
|
97
68
|
|
|
98
|
-
|
|
99
|
-
});
|
|
69
|
+
const result = await connectedClient.client.exec({ service: "" });
|
|
100
70
|
|
|
101
|
-
|
|
102
|
-
const strategy = new GrpcHealthCheckStrategy(createMockClient());
|
|
71
|
+
expect(result.status).toBe("NOT_SERVING");
|
|
103
72
|
|
|
104
|
-
|
|
105
|
-
host: "localhost",
|
|
106
|
-
port: 50051,
|
|
107
|
-
timeout: 5000,
|
|
108
|
-
assertions: [{ field: "status", operator: "equals", value: "SERVING" }],
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
expect(result.status).toBe("healthy");
|
|
73
|
+
connectedClient.close();
|
|
112
74
|
});
|
|
113
75
|
|
|
114
|
-
it("should
|
|
76
|
+
it("should return error for connection failure", async () => {
|
|
115
77
|
const strategy = new GrpcHealthCheckStrategy(
|
|
116
|
-
createMockClient({
|
|
78
|
+
createMockClient({ error: new Error("Connection refused") })
|
|
117
79
|
);
|
|
118
80
|
|
|
119
|
-
const
|
|
81
|
+
const connectedClient = await strategy.createClient({
|
|
120
82
|
host: "localhost",
|
|
121
83
|
port: 50051,
|
|
122
84
|
timeout: 5000,
|
|
123
|
-
assertions: [{ field: "status", operator: "equals", value: "SERVING" }],
|
|
124
85
|
});
|
|
125
86
|
|
|
126
|
-
|
|
127
|
-
|
|
87
|
+
const result = await connectedClient.client.exec({ service: "" });
|
|
88
|
+
|
|
89
|
+
expect(result.error).toContain("Connection refused");
|
|
90
|
+
|
|
91
|
+
connectedClient.close();
|
|
128
92
|
});
|
|
129
93
|
|
|
130
94
|
it("should check specific service", async () => {
|
|
131
95
|
const mockClient = createMockClient();
|
|
132
96
|
const strategy = new GrpcHealthCheckStrategy(mockClient);
|
|
133
97
|
|
|
134
|
-
await strategy.
|
|
98
|
+
const connectedClient = await strategy.createClient({
|
|
135
99
|
host: "localhost",
|
|
136
100
|
port: 50051,
|
|
137
|
-
service: "my.custom.Service",
|
|
138
101
|
timeout: 5000,
|
|
139
102
|
});
|
|
140
103
|
|
|
104
|
+
await connectedClient.client.exec({ service: "my.custom.Service" });
|
|
105
|
+
|
|
141
106
|
expect(mockClient.check).toHaveBeenCalledWith(
|
|
142
107
|
expect.objectContaining({ service: "my.custom.Service" })
|
|
143
108
|
);
|
|
109
|
+
|
|
110
|
+
connectedClient.close();
|
|
144
111
|
});
|
|
145
112
|
});
|
|
146
113
|
|
package/src/strategy.ts
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
import * as grpc from "@grpc/grpc-js";
|
|
2
2
|
import {
|
|
3
3
|
HealthCheckStrategy,
|
|
4
|
-
HealthCheckResult,
|
|
5
4
|
HealthCheckRunForAggregation,
|
|
6
5
|
Versioned,
|
|
7
6
|
z,
|
|
8
|
-
|
|
9
|
-
enumField,
|
|
10
|
-
evaluateAssertions,
|
|
7
|
+
type ConnectedClient,
|
|
11
8
|
} from "@checkstack/backend-api";
|
|
12
9
|
import {
|
|
13
10
|
healthResultBoolean,
|
|
14
11
|
healthResultNumber,
|
|
15
12
|
healthResultString,
|
|
16
13
|
} from "@checkstack/healthcheck-common";
|
|
14
|
+
import type {
|
|
15
|
+
GrpcTransportClient,
|
|
16
|
+
GrpcHealthRequest,
|
|
17
|
+
GrpcHealthResponse,
|
|
18
|
+
} from "./transport-client";
|
|
17
19
|
|
|
18
20
|
// ============================================================================
|
|
19
21
|
// SCHEMAS
|
|
@@ -21,26 +23,15 @@ import {
|
|
|
21
23
|
|
|
22
24
|
/**
|
|
23
25
|
* gRPC Health Checking Protocol status values
|
|
24
|
-
* https://github.com/grpc/grpc/blob/master/doc/health-checking.md
|
|
25
26
|
*/
|
|
26
|
-
const GrpcHealthStatus = z.enum([
|
|
27
|
+
export const GrpcHealthStatus = z.enum([
|
|
27
28
|
"UNKNOWN",
|
|
28
29
|
"SERVING",
|
|
29
30
|
"NOT_SERVING",
|
|
30
31
|
"SERVICE_UNKNOWN",
|
|
31
32
|
]);
|
|
32
|
-
export type GrpcHealthStatus = z.infer<typeof GrpcHealthStatus>;
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
* Assertion schema for gRPC health checks using shared factories.
|
|
36
|
-
* Uses enumField for status to render a dropdown with valid status values.
|
|
37
|
-
*/
|
|
38
|
-
const grpcAssertionSchema = z.discriminatedUnion("field", [
|
|
39
|
-
timeThresholdField("responseTime"),
|
|
40
|
-
enumField("status", GrpcHealthStatus.options),
|
|
41
|
-
]);
|
|
42
|
-
|
|
43
|
-
export type GrpcAssertion = z.infer<typeof grpcAssertionSchema>;
|
|
34
|
+
export type GrpcHealthStatusType = z.infer<typeof GrpcHealthStatus>;
|
|
44
35
|
|
|
45
36
|
/**
|
|
46
37
|
* Configuration schema for gRPC health checks.
|
|
@@ -51,17 +42,13 @@ export const grpcConfigSchema = z.object({
|
|
|
51
42
|
service: z
|
|
52
43
|
.string()
|
|
53
44
|
.default("")
|
|
54
|
-
.describe("Service name to check (empty for
|
|
55
|
-
useTls: z.boolean().default(false).describe("Use TLS
|
|
45
|
+
.describe("Service name to check (empty for server health)"),
|
|
46
|
+
useTls: z.boolean().default(false).describe("Use TLS connection"),
|
|
56
47
|
timeout: z
|
|
57
48
|
.number()
|
|
58
49
|
.min(100)
|
|
59
50
|
.default(5000)
|
|
60
51
|
.describe("Request timeout in milliseconds"),
|
|
61
|
-
assertions: z
|
|
62
|
-
.array(grpcAssertionSchema)
|
|
63
|
-
.optional()
|
|
64
|
-
.describe("Validation conditions"),
|
|
65
52
|
});
|
|
66
53
|
|
|
67
54
|
export type GrpcConfig = z.infer<typeof grpcConfigSchema>;
|
|
@@ -80,18 +67,17 @@ const grpcResultSchema = z.object({
|
|
|
80
67
|
"x-chart-label": "Response Time",
|
|
81
68
|
"x-chart-unit": "ms",
|
|
82
69
|
}),
|
|
83
|
-
status:
|
|
70
|
+
status: healthResultString({
|
|
84
71
|
"x-chart-type": "text",
|
|
85
72
|
"x-chart-label": "Status",
|
|
86
73
|
}),
|
|
87
|
-
failedAssertion: grpcAssertionSchema.optional(),
|
|
88
74
|
error: healthResultString({
|
|
89
75
|
"x-chart-type": "status",
|
|
90
76
|
"x-chart-label": "Error",
|
|
91
77
|
}).optional(),
|
|
92
78
|
});
|
|
93
79
|
|
|
94
|
-
|
|
80
|
+
type GrpcResult = z.infer<typeof grpcResultSchema>;
|
|
95
81
|
|
|
96
82
|
/**
|
|
97
83
|
* Aggregated metadata for buckets.
|
|
@@ -117,7 +103,7 @@ const grpcAggregatedSchema = z.object({
|
|
|
117
103
|
}),
|
|
118
104
|
});
|
|
119
105
|
|
|
120
|
-
|
|
106
|
+
type GrpcAggregatedResult = z.infer<typeof grpcAggregatedSchema>;
|
|
121
107
|
|
|
122
108
|
// ============================================================================
|
|
123
109
|
// GRPC CLIENT INTERFACE (for testability)
|
|
@@ -130,60 +116,56 @@ export interface GrpcHealthClient {
|
|
|
130
116
|
service: string;
|
|
131
117
|
useTls: boolean;
|
|
132
118
|
timeout: number;
|
|
133
|
-
}): Promise<{ status:
|
|
119
|
+
}): Promise<{ status: GrpcHealthStatusType }>;
|
|
134
120
|
}
|
|
135
121
|
|
|
136
122
|
// Default client using @grpc/grpc-js
|
|
137
123
|
const defaultGrpcClient: GrpcHealthClient = {
|
|
138
|
-
|
|
124
|
+
check(options) {
|
|
139
125
|
return new Promise((resolve, reject) => {
|
|
126
|
+
const address = `${options.host}:${options.port}`;
|
|
140
127
|
const credentials = options.useTls
|
|
141
128
|
? grpc.credentials.createSsl()
|
|
142
129
|
: grpc.credentials.createInsecure();
|
|
143
130
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const client = new HealthService(
|
|
165
|
-
`${options.host}:${options.port}`,
|
|
166
|
-
credentials
|
|
167
|
-
);
|
|
131
|
+
const client = new grpc.Client(address, credentials);
|
|
132
|
+
|
|
133
|
+
// Use the standard gRPC Health Checking Protocol
|
|
134
|
+
const healthCheckPath = "/grpc.health.v1.Health/Check";
|
|
135
|
+
|
|
136
|
+
const methodDefinition: grpc.MethodDefinition<
|
|
137
|
+
{ service: string },
|
|
138
|
+
{ status: number }
|
|
139
|
+
> = {
|
|
140
|
+
path: healthCheckPath,
|
|
141
|
+
requestStream: false,
|
|
142
|
+
responseStream: false,
|
|
143
|
+
requestSerialize: (message: { service: string }) =>
|
|
144
|
+
Buffer.from(JSON.stringify(message)),
|
|
145
|
+
requestDeserialize: (data: Buffer) => JSON.parse(data.toString()),
|
|
146
|
+
responseSerialize: (message: { status: number }) =>
|
|
147
|
+
Buffer.from(JSON.stringify(message)),
|
|
148
|
+
responseDeserialize: (data: Buffer) => JSON.parse(data.toString()),
|
|
149
|
+
};
|
|
168
150
|
|
|
169
151
|
const deadline = new Date(Date.now() + options.timeout);
|
|
170
152
|
|
|
171
|
-
client.
|
|
153
|
+
client.makeUnaryRequest(
|
|
154
|
+
methodDefinition.path,
|
|
155
|
+
methodDefinition.requestSerialize,
|
|
156
|
+
methodDefinition.responseDeserialize,
|
|
172
157
|
{ service: options.service },
|
|
173
158
|
{ deadline },
|
|
174
|
-
(
|
|
175
|
-
err: grpc.ServiceError | null,
|
|
176
|
-
response: { status: number } | undefined
|
|
177
|
-
) => {
|
|
159
|
+
(error, response) => {
|
|
178
160
|
client.close();
|
|
179
161
|
|
|
180
|
-
if (
|
|
181
|
-
reject(
|
|
162
|
+
if (error) {
|
|
163
|
+
reject(error);
|
|
182
164
|
return;
|
|
183
165
|
}
|
|
184
166
|
|
|
185
|
-
// Map status
|
|
186
|
-
const statusMap: Record<number,
|
|
167
|
+
// Map status codes to enum values
|
|
168
|
+
const statusMap: Record<number, GrpcHealthStatusType> = {
|
|
187
169
|
0: "UNKNOWN",
|
|
188
170
|
1: "SERVING",
|
|
189
171
|
2: "NOT_SERVING",
|
|
@@ -204,7 +186,13 @@ const defaultGrpcClient: GrpcHealthClient = {
|
|
|
204
186
|
// ============================================================================
|
|
205
187
|
|
|
206
188
|
export class GrpcHealthCheckStrategy
|
|
207
|
-
implements
|
|
189
|
+
implements
|
|
190
|
+
HealthCheckStrategy<
|
|
191
|
+
GrpcConfig,
|
|
192
|
+
GrpcTransportClient,
|
|
193
|
+
GrpcResult,
|
|
194
|
+
GrpcAggregatedResult
|
|
195
|
+
>
|
|
208
196
|
{
|
|
209
197
|
id = "grpc";
|
|
210
198
|
displayName = "gRPC Health Check";
|
|
@@ -218,13 +206,29 @@ export class GrpcHealthCheckStrategy
|
|
|
218
206
|
}
|
|
219
207
|
|
|
220
208
|
config: Versioned<GrpcConfig> = new Versioned({
|
|
221
|
-
version:
|
|
209
|
+
version: 2, // Bumped for createClient pattern
|
|
222
210
|
schema: grpcConfigSchema,
|
|
211
|
+
migrations: [
|
|
212
|
+
{
|
|
213
|
+
fromVersion: 1,
|
|
214
|
+
toVersion: 2,
|
|
215
|
+
description: "Migrate to createClient pattern (no config changes)",
|
|
216
|
+
migrate: (data: unknown) => data,
|
|
217
|
+
},
|
|
218
|
+
],
|
|
223
219
|
});
|
|
224
220
|
|
|
225
221
|
result: Versioned<GrpcResult> = new Versioned({
|
|
226
|
-
version:
|
|
222
|
+
version: 2,
|
|
227
223
|
schema: grpcResultSchema,
|
|
224
|
+
migrations: [
|
|
225
|
+
{
|
|
226
|
+
fromVersion: 1,
|
|
227
|
+
toVersion: 2,
|
|
228
|
+
description: "Migrate to createClient pattern (no result changes)",
|
|
229
|
+
migrate: (data: unknown) => data,
|
|
230
|
+
},
|
|
231
|
+
],
|
|
228
232
|
});
|
|
229
233
|
|
|
230
234
|
aggregatedResult: Versioned<GrpcAggregatedResult> = new Versioned({
|
|
@@ -235,109 +239,74 @@ export class GrpcHealthCheckStrategy
|
|
|
235
239
|
aggregateResult(
|
|
236
240
|
runs: HealthCheckRunForAggregation<GrpcResult>[]
|
|
237
241
|
): GrpcAggregatedResult {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
continue;
|
|
248
|
-
}
|
|
249
|
-
if (run.status === "healthy") {
|
|
250
|
-
successCount++;
|
|
251
|
-
}
|
|
252
|
-
if (run.metadata) {
|
|
253
|
-
totalResponseTime += run.metadata.responseTimeMs;
|
|
254
|
-
if (run.metadata.status === "SERVING") {
|
|
255
|
-
servingCount++;
|
|
256
|
-
}
|
|
257
|
-
validRuns++;
|
|
258
|
-
}
|
|
242
|
+
const validRuns = runs.filter((r) => r.metadata);
|
|
243
|
+
|
|
244
|
+
if (validRuns.length === 0) {
|
|
245
|
+
return {
|
|
246
|
+
avgResponseTime: 0,
|
|
247
|
+
successRate: 0,
|
|
248
|
+
errorCount: 0,
|
|
249
|
+
servingCount: 0,
|
|
250
|
+
};
|
|
259
251
|
}
|
|
260
252
|
|
|
253
|
+
const responseTimes = validRuns
|
|
254
|
+
.map((r) => r.metadata?.responseTimeMs)
|
|
255
|
+
.filter((t): t is number => typeof t === "number");
|
|
256
|
+
|
|
257
|
+
const avgResponseTime =
|
|
258
|
+
responseTimes.length > 0
|
|
259
|
+
? Math.round(
|
|
260
|
+
responseTimes.reduce((a, b) => a + b, 0) / responseTimes.length
|
|
261
|
+
)
|
|
262
|
+
: 0;
|
|
263
|
+
|
|
264
|
+
const servingCount = validRuns.filter(
|
|
265
|
+
(r) => r.metadata?.status === "SERVING"
|
|
266
|
+
).length;
|
|
267
|
+
const successRate = Math.round((servingCount / validRuns.length) * 100);
|
|
268
|
+
|
|
269
|
+
const errorCount = validRuns.filter(
|
|
270
|
+
(r) => r.metadata?.error !== undefined
|
|
271
|
+
).length;
|
|
272
|
+
|
|
261
273
|
return {
|
|
262
|
-
avgResponseTime
|
|
263
|
-
successRate
|
|
274
|
+
avgResponseTime,
|
|
275
|
+
successRate,
|
|
264
276
|
errorCount,
|
|
265
277
|
servingCount,
|
|
266
278
|
};
|
|
267
279
|
}
|
|
268
280
|
|
|
269
|
-
async
|
|
281
|
+
async createClient(
|
|
270
282
|
config: GrpcConfigInput
|
|
271
|
-
): Promise<
|
|
283
|
+
): Promise<ConnectedClient<GrpcTransportClient>> {
|
|
272
284
|
const validatedConfig = this.config.validate(config);
|
|
273
|
-
const start = performance.now();
|
|
274
|
-
|
|
275
|
-
try {
|
|
276
|
-
const response = await this.grpcClient.check({
|
|
277
|
-
host: validatedConfig.host,
|
|
278
|
-
port: validatedConfig.port,
|
|
279
|
-
service: validatedConfig.service,
|
|
280
|
-
useTls: validatedConfig.useTls,
|
|
281
|
-
timeout: validatedConfig.timeout,
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
const responseTimeMs = Math.round(performance.now() - start);
|
|
285
|
-
|
|
286
|
-
const result: Omit<GrpcResult, "failedAssertion" | "error"> = {
|
|
287
|
-
connected: true,
|
|
288
|
-
responseTimeMs,
|
|
289
|
-
status: response.status,
|
|
290
|
-
};
|
|
291
285
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
if (response.status !== "SERVING") {
|
|
311
|
-
return {
|
|
312
|
-
status: "unhealthy",
|
|
313
|
-
latencyMs: responseTimeMs,
|
|
314
|
-
message: `gRPC health status: ${response.status}`,
|
|
315
|
-
metadata: result,
|
|
316
|
-
};
|
|
317
|
-
}
|
|
286
|
+
const client: GrpcTransportClient = {
|
|
287
|
+
exec: async (request: GrpcHealthRequest): Promise<GrpcHealthResponse> => {
|
|
288
|
+
try {
|
|
289
|
+
const result = await this.grpcClient.check({
|
|
290
|
+
host: validatedConfig.host,
|
|
291
|
+
port: validatedConfig.port,
|
|
292
|
+
service: request.service,
|
|
293
|
+
useTls: validatedConfig.useTls,
|
|
294
|
+
timeout: validatedConfig.timeout,
|
|
295
|
+
});
|
|
296
|
+
return { status: result.status };
|
|
297
|
+
} catch (error_) {
|
|
298
|
+
const error =
|
|
299
|
+
error_ instanceof Error ? error_.message : String(error_);
|
|
300
|
+
return { status: "UNKNOWN", error };
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
};
|
|
318
304
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
metadata: result,
|
|
326
|
-
};
|
|
327
|
-
} catch (error: unknown) {
|
|
328
|
-
const end = performance.now();
|
|
329
|
-
const isError = error instanceof Error;
|
|
330
|
-
return {
|
|
331
|
-
status: "unhealthy",
|
|
332
|
-
latencyMs: Math.round(end - start),
|
|
333
|
-
message: isError ? error.message : "gRPC health check failed",
|
|
334
|
-
metadata: {
|
|
335
|
-
connected: false,
|
|
336
|
-
responseTimeMs: Math.round(end - start),
|
|
337
|
-
status: "UNKNOWN",
|
|
338
|
-
error: isError ? error.name : "UnknownError",
|
|
339
|
-
},
|
|
340
|
-
};
|
|
341
|
-
}
|
|
305
|
+
return {
|
|
306
|
+
client,
|
|
307
|
+
close: () => {
|
|
308
|
+
// gRPC client is per-request, nothing to close
|
|
309
|
+
},
|
|
310
|
+
};
|
|
342
311
|
}
|
|
343
312
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { TransportClient } from "@checkstack/common";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* gRPC health check request.
|
|
5
|
+
*/
|
|
6
|
+
export interface GrpcHealthRequest {
|
|
7
|
+
service: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* gRPC health check response.
|
|
12
|
+
*/
|
|
13
|
+
export interface GrpcHealthResponse {
|
|
14
|
+
status: "UNKNOWN" | "SERVING" | "NOT_SERVING" | "SERVICE_UNKNOWN";
|
|
15
|
+
error?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* gRPC transport client for health checks.
|
|
20
|
+
*/
|
|
21
|
+
export type GrpcTransportClient = TransportClient<
|
|
22
|
+
GrpcHealthRequest,
|
|
23
|
+
GrpcHealthResponse
|
|
24
|
+
>;
|