@checkstack/healthcheck-grpc-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 +23 -0
- package/src/index.ts +23 -0
- package/src/plugin-metadata.ts +8 -0
- package/src/strategy.test.ts +222 -0
- package/src/strategy.ts +343 -0
- package/tsconfig.json +6 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# @checkstack/healthcheck-grpc-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,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@checkstack/healthcheck-grpc-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
|
+
"@grpc/grpc-js": "^1.9.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/bun": "^1.0.0",
|
|
19
|
+
"typescript": "^5.0.0",
|
|
20
|
+
"@checkstack/tsconfig": "workspace:*",
|
|
21
|
+
"@checkstack/scripts": "workspace:*"
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createBackendPlugin,
|
|
3
|
+
coreServices,
|
|
4
|
+
} from "@checkstack/backend-api";
|
|
5
|
+
import { GrpcHealthCheckStrategy } 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 gRPC Health Check Strategy...");
|
|
18
|
+
const strategy = new GrpcHealthCheckStrategy();
|
|
19
|
+
healthCheckRegistry.register(strategy);
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
},
|
|
23
|
+
});
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { describe, expect, it, mock } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
GrpcHealthCheckStrategy,
|
|
4
|
+
GrpcHealthClient,
|
|
5
|
+
GrpcHealthStatus,
|
|
6
|
+
} from "./strategy";
|
|
7
|
+
|
|
8
|
+
describe("GrpcHealthCheckStrategy", () => {
|
|
9
|
+
// Helper to create mock gRPC client
|
|
10
|
+
const createMockClient = (
|
|
11
|
+
config: {
|
|
12
|
+
status?: GrpcHealthStatus;
|
|
13
|
+
error?: Error;
|
|
14
|
+
} = {}
|
|
15
|
+
): GrpcHealthClient => ({
|
|
16
|
+
check: mock(() =>
|
|
17
|
+
config.error
|
|
18
|
+
? Promise.reject(config.error)
|
|
19
|
+
: Promise.resolve({ status: config.status ?? "SERVING" })
|
|
20
|
+
),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("execute", () => {
|
|
24
|
+
it("should return healthy for SERVING status", async () => {
|
|
25
|
+
const strategy = new GrpcHealthCheckStrategy(createMockClient());
|
|
26
|
+
|
|
27
|
+
const result = await strategy.execute({
|
|
28
|
+
host: "localhost",
|
|
29
|
+
port: 50051,
|
|
30
|
+
timeout: 5000,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
expect(result.status).toBe("healthy");
|
|
34
|
+
expect(result.metadata?.connected).toBe(true);
|
|
35
|
+
expect(result.metadata?.status).toBe("SERVING");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("should return unhealthy for NOT_SERVING status", async () => {
|
|
39
|
+
const strategy = new GrpcHealthCheckStrategy(
|
|
40
|
+
createMockClient({ status: "NOT_SERVING" })
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const result = await strategy.execute({
|
|
44
|
+
host: "localhost",
|
|
45
|
+
port: 50051,
|
|
46
|
+
timeout: 5000,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(result.status).toBe("unhealthy");
|
|
50
|
+
expect(result.message).toContain("NOT_SERVING");
|
|
51
|
+
expect(result.metadata?.status).toBe("NOT_SERVING");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("should return unhealthy for SERVICE_UNKNOWN status", async () => {
|
|
55
|
+
const strategy = new GrpcHealthCheckStrategy(
|
|
56
|
+
createMockClient({ status: "SERVICE_UNKNOWN" })
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const result = await strategy.execute({
|
|
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");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("should return unhealthy for connection error", async () => {
|
|
71
|
+
const strategy = new GrpcHealthCheckStrategy(
|
|
72
|
+
createMockClient({ error: new Error("Connection refused") })
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const result = await strategy.execute({
|
|
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({
|
|
90
|
+
host: "localhost",
|
|
91
|
+
port: 50051,
|
|
92
|
+
timeout: 5000,
|
|
93
|
+
assertions: [
|
|
94
|
+
{ field: "responseTime", operator: "lessThan", value: 5000 },
|
|
95
|
+
],
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(result.status).toBe("healthy");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("should pass status assertion", async () => {
|
|
102
|
+
const strategy = new GrpcHealthCheckStrategy(createMockClient());
|
|
103
|
+
|
|
104
|
+
const result = await strategy.execute({
|
|
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");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("should fail status assertion when not matching", async () => {
|
|
115
|
+
const strategy = new GrpcHealthCheckStrategy(
|
|
116
|
+
createMockClient({ status: "NOT_SERVING" })
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const result = await strategy.execute({
|
|
120
|
+
host: "localhost",
|
|
121
|
+
port: 50051,
|
|
122
|
+
timeout: 5000,
|
|
123
|
+
assertions: [{ field: "status", operator: "equals", value: "SERVING" }],
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
expect(result.status).toBe("unhealthy");
|
|
127
|
+
expect(result.message).toContain("Assertion failed");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("should check specific service", async () => {
|
|
131
|
+
const mockClient = createMockClient();
|
|
132
|
+
const strategy = new GrpcHealthCheckStrategy(mockClient);
|
|
133
|
+
|
|
134
|
+
await strategy.execute({
|
|
135
|
+
host: "localhost",
|
|
136
|
+
port: 50051,
|
|
137
|
+
service: "my.custom.Service",
|
|
138
|
+
timeout: 5000,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(mockClient.check).toHaveBeenCalledWith(
|
|
142
|
+
expect.objectContaining({ service: "my.custom.Service" })
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("aggregateResult", () => {
|
|
148
|
+
it("should calculate averages correctly", () => {
|
|
149
|
+
const strategy = new GrpcHealthCheckStrategy();
|
|
150
|
+
const runs = [
|
|
151
|
+
{
|
|
152
|
+
id: "1",
|
|
153
|
+
status: "healthy" as const,
|
|
154
|
+
latencyMs: 10,
|
|
155
|
+
checkId: "c1",
|
|
156
|
+
timestamp: new Date(),
|
|
157
|
+
metadata: {
|
|
158
|
+
connected: true,
|
|
159
|
+
responseTimeMs: 5,
|
|
160
|
+
status: "SERVING" as const,
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
id: "2",
|
|
165
|
+
status: "healthy" as const,
|
|
166
|
+
latencyMs: 20,
|
|
167
|
+
checkId: "c1",
|
|
168
|
+
timestamp: new Date(),
|
|
169
|
+
metadata: {
|
|
170
|
+
connected: true,
|
|
171
|
+
responseTimeMs: 15,
|
|
172
|
+
status: "SERVING" as const,
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
];
|
|
176
|
+
|
|
177
|
+
const aggregated = strategy.aggregateResult(runs);
|
|
178
|
+
|
|
179
|
+
expect(aggregated.avgResponseTime).toBe(10);
|
|
180
|
+
expect(aggregated.successRate).toBe(100);
|
|
181
|
+
expect(aggregated.servingCount).toBe(2);
|
|
182
|
+
expect(aggregated.errorCount).toBe(0);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("should count errors and non-serving", () => {
|
|
186
|
+
const strategy = new GrpcHealthCheckStrategy();
|
|
187
|
+
const runs = [
|
|
188
|
+
{
|
|
189
|
+
id: "1",
|
|
190
|
+
status: "unhealthy" as const,
|
|
191
|
+
latencyMs: 10,
|
|
192
|
+
checkId: "c1",
|
|
193
|
+
timestamp: new Date(),
|
|
194
|
+
metadata: {
|
|
195
|
+
connected: true,
|
|
196
|
+
responseTimeMs: 5,
|
|
197
|
+
status: "NOT_SERVING" as const,
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
id: "2",
|
|
202
|
+
status: "unhealthy" as const,
|
|
203
|
+
latencyMs: 0,
|
|
204
|
+
checkId: "c1",
|
|
205
|
+
timestamp: new Date(),
|
|
206
|
+
metadata: {
|
|
207
|
+
connected: false,
|
|
208
|
+
responseTimeMs: 0,
|
|
209
|
+
status: "UNKNOWN" as const,
|
|
210
|
+
error: "Connection refused",
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
];
|
|
214
|
+
|
|
215
|
+
const aggregated = strategy.aggregateResult(runs);
|
|
216
|
+
|
|
217
|
+
expect(aggregated.errorCount).toBe(1);
|
|
218
|
+
expect(aggregated.servingCount).toBe(0);
|
|
219
|
+
expect(aggregated.successRate).toBe(0);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
});
|
package/src/strategy.ts
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import * as grpc from "@grpc/grpc-js";
|
|
2
|
+
import {
|
|
3
|
+
HealthCheckStrategy,
|
|
4
|
+
HealthCheckResult,
|
|
5
|
+
HealthCheckRunForAggregation,
|
|
6
|
+
Versioned,
|
|
7
|
+
z,
|
|
8
|
+
timeThresholdField,
|
|
9
|
+
enumField,
|
|
10
|
+
evaluateAssertions,
|
|
11
|
+
} from "@checkstack/backend-api";
|
|
12
|
+
import {
|
|
13
|
+
healthResultBoolean,
|
|
14
|
+
healthResultNumber,
|
|
15
|
+
healthResultString,
|
|
16
|
+
} from "@checkstack/healthcheck-common";
|
|
17
|
+
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// SCHEMAS
|
|
20
|
+
// ============================================================================
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* gRPC Health Checking Protocol status values
|
|
24
|
+
* https://github.com/grpc/grpc/blob/master/doc/health-checking.md
|
|
25
|
+
*/
|
|
26
|
+
const GrpcHealthStatus = z.enum([
|
|
27
|
+
"UNKNOWN",
|
|
28
|
+
"SERVING",
|
|
29
|
+
"NOT_SERVING",
|
|
30
|
+
"SERVICE_UNKNOWN",
|
|
31
|
+
]);
|
|
32
|
+
export type GrpcHealthStatus = z.infer<typeof GrpcHealthStatus>;
|
|
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>;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Configuration schema for gRPC health checks.
|
|
47
|
+
*/
|
|
48
|
+
export const grpcConfigSchema = z.object({
|
|
49
|
+
host: z.string().describe("gRPC server hostname"),
|
|
50
|
+
port: z.number().int().min(1).max(65_535).describe("gRPC port"),
|
|
51
|
+
service: z
|
|
52
|
+
.string()
|
|
53
|
+
.default("")
|
|
54
|
+
.describe("Service name to check (empty for overall server health)"),
|
|
55
|
+
useTls: z.boolean().default(false).describe("Use TLS/SSL connection"),
|
|
56
|
+
timeout: z
|
|
57
|
+
.number()
|
|
58
|
+
.min(100)
|
|
59
|
+
.default(5000)
|
|
60
|
+
.describe("Request timeout in milliseconds"),
|
|
61
|
+
assertions: z
|
|
62
|
+
.array(grpcAssertionSchema)
|
|
63
|
+
.optional()
|
|
64
|
+
.describe("Validation conditions"),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export type GrpcConfig = z.infer<typeof grpcConfigSchema>;
|
|
68
|
+
export type GrpcConfigInput = z.input<typeof grpcConfigSchema>;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Per-run result metadata.
|
|
72
|
+
*/
|
|
73
|
+
const grpcResultSchema = z.object({
|
|
74
|
+
connected: healthResultBoolean({
|
|
75
|
+
"x-chart-type": "boolean",
|
|
76
|
+
"x-chart-label": "Connected",
|
|
77
|
+
}),
|
|
78
|
+
responseTimeMs: healthResultNumber({
|
|
79
|
+
"x-chart-type": "line",
|
|
80
|
+
"x-chart-label": "Response Time",
|
|
81
|
+
"x-chart-unit": "ms",
|
|
82
|
+
}),
|
|
83
|
+
status: GrpcHealthStatus.meta({
|
|
84
|
+
"x-chart-type": "text",
|
|
85
|
+
"x-chart-label": "Status",
|
|
86
|
+
}),
|
|
87
|
+
failedAssertion: grpcAssertionSchema.optional(),
|
|
88
|
+
error: healthResultString({
|
|
89
|
+
"x-chart-type": "status",
|
|
90
|
+
"x-chart-label": "Error",
|
|
91
|
+
}).optional(),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
export type GrpcResult = z.infer<typeof grpcResultSchema>;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Aggregated metadata for buckets.
|
|
98
|
+
*/
|
|
99
|
+
const grpcAggregatedSchema = z.object({
|
|
100
|
+
avgResponseTime: healthResultNumber({
|
|
101
|
+
"x-chart-type": "line",
|
|
102
|
+
"x-chart-label": "Avg Response Time",
|
|
103
|
+
"x-chart-unit": "ms",
|
|
104
|
+
}),
|
|
105
|
+
successRate: healthResultNumber({
|
|
106
|
+
"x-chart-type": "gauge",
|
|
107
|
+
"x-chart-label": "Success Rate",
|
|
108
|
+
"x-chart-unit": "%",
|
|
109
|
+
}),
|
|
110
|
+
errorCount: healthResultNumber({
|
|
111
|
+
"x-chart-type": "counter",
|
|
112
|
+
"x-chart-label": "Errors",
|
|
113
|
+
}),
|
|
114
|
+
servingCount: healthResultNumber({
|
|
115
|
+
"x-chart-type": "counter",
|
|
116
|
+
"x-chart-label": "Serving",
|
|
117
|
+
}),
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
export type GrpcAggregatedResult = z.infer<typeof grpcAggregatedSchema>;
|
|
121
|
+
|
|
122
|
+
// ============================================================================
|
|
123
|
+
// GRPC CLIENT INTERFACE (for testability)
|
|
124
|
+
// ============================================================================
|
|
125
|
+
|
|
126
|
+
export interface GrpcHealthClient {
|
|
127
|
+
check(options: {
|
|
128
|
+
host: string;
|
|
129
|
+
port: number;
|
|
130
|
+
service: string;
|
|
131
|
+
useTls: boolean;
|
|
132
|
+
timeout: number;
|
|
133
|
+
}): Promise<{ status: GrpcHealthStatus }>;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Default client using @grpc/grpc-js
|
|
137
|
+
const defaultGrpcClient: GrpcHealthClient = {
|
|
138
|
+
async check(options) {
|
|
139
|
+
return new Promise((resolve, reject) => {
|
|
140
|
+
const credentials = options.useTls
|
|
141
|
+
? grpc.credentials.createSsl()
|
|
142
|
+
: grpc.credentials.createInsecure();
|
|
143
|
+
|
|
144
|
+
// Create health check client manually using makeGenericClientConstructor
|
|
145
|
+
const HealthService = grpc.makeGenericClientConstructor(
|
|
146
|
+
{
|
|
147
|
+
Check: {
|
|
148
|
+
path: "/grpc.health.v1.Health/Check",
|
|
149
|
+
requestStream: false,
|
|
150
|
+
responseStream: false,
|
|
151
|
+
requestSerialize: (message: { service: string }) =>
|
|
152
|
+
Buffer.from(JSON.stringify(message)),
|
|
153
|
+
requestDeserialize: (data: Buffer) =>
|
|
154
|
+
JSON.parse(data.toString()) as { service: string },
|
|
155
|
+
responseSerialize: (message: { status: number }) =>
|
|
156
|
+
Buffer.from(JSON.stringify(message)),
|
|
157
|
+
responseDeserialize: (data: Buffer) =>
|
|
158
|
+
JSON.parse(data.toString()) as { status: number },
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
"grpc.health.v1.Health"
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const client = new HealthService(
|
|
165
|
+
`${options.host}:${options.port}`,
|
|
166
|
+
credentials
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const deadline = new Date(Date.now() + options.timeout);
|
|
170
|
+
|
|
171
|
+
client.Check(
|
|
172
|
+
{ service: options.service },
|
|
173
|
+
{ deadline },
|
|
174
|
+
(
|
|
175
|
+
err: grpc.ServiceError | null,
|
|
176
|
+
response: { status: number } | undefined
|
|
177
|
+
) => {
|
|
178
|
+
client.close();
|
|
179
|
+
|
|
180
|
+
if (err) {
|
|
181
|
+
reject(err);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Map status number to enum
|
|
186
|
+
const statusMap: Record<number, GrpcHealthStatus> = {
|
|
187
|
+
0: "UNKNOWN",
|
|
188
|
+
1: "SERVING",
|
|
189
|
+
2: "NOT_SERVING",
|
|
190
|
+
3: "SERVICE_UNKNOWN",
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
resolve({
|
|
194
|
+
status: statusMap[response?.status ?? 0] ?? "UNKNOWN",
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// ============================================================================
|
|
203
|
+
// STRATEGY
|
|
204
|
+
// ============================================================================
|
|
205
|
+
|
|
206
|
+
export class GrpcHealthCheckStrategy
|
|
207
|
+
implements HealthCheckStrategy<GrpcConfig, GrpcResult, GrpcAggregatedResult>
|
|
208
|
+
{
|
|
209
|
+
id = "grpc";
|
|
210
|
+
displayName = "gRPC Health Check";
|
|
211
|
+
description =
|
|
212
|
+
"gRPC server health check using the standard Health Checking Protocol";
|
|
213
|
+
|
|
214
|
+
private grpcClient: GrpcHealthClient;
|
|
215
|
+
|
|
216
|
+
constructor(grpcClient: GrpcHealthClient = defaultGrpcClient) {
|
|
217
|
+
this.grpcClient = grpcClient;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
config: Versioned<GrpcConfig> = new Versioned({
|
|
221
|
+
version: 1,
|
|
222
|
+
schema: grpcConfigSchema,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
result: Versioned<GrpcResult> = new Versioned({
|
|
226
|
+
version: 1,
|
|
227
|
+
schema: grpcResultSchema,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
aggregatedResult: Versioned<GrpcAggregatedResult> = new Versioned({
|
|
231
|
+
version: 1,
|
|
232
|
+
schema: grpcAggregatedSchema,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
aggregateResult(
|
|
236
|
+
runs: HealthCheckRunForAggregation<GrpcResult>[]
|
|
237
|
+
): GrpcAggregatedResult {
|
|
238
|
+
let totalResponseTime = 0;
|
|
239
|
+
let successCount = 0;
|
|
240
|
+
let errorCount = 0;
|
|
241
|
+
let servingCount = 0;
|
|
242
|
+
let validRuns = 0;
|
|
243
|
+
|
|
244
|
+
for (const run of runs) {
|
|
245
|
+
if (run.metadata?.error) {
|
|
246
|
+
errorCount++;
|
|
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
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
avgResponseTime: validRuns > 0 ? totalResponseTime / validRuns : 0,
|
|
263
|
+
successRate: runs.length > 0 ? (successCount / runs.length) * 100 : 0,
|
|
264
|
+
errorCount,
|
|
265
|
+
servingCount,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async execute(
|
|
270
|
+
config: GrpcConfigInput
|
|
271
|
+
): Promise<HealthCheckResult<GrpcResult>> {
|
|
272
|
+
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
|
+
|
|
292
|
+
// Evaluate assertions using shared utility
|
|
293
|
+
const failedAssertion = evaluateAssertions(validatedConfig.assertions, {
|
|
294
|
+
responseTime: responseTimeMs,
|
|
295
|
+
status: response.status,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
if (failedAssertion) {
|
|
299
|
+
return {
|
|
300
|
+
status: "unhealthy",
|
|
301
|
+
latencyMs: responseTimeMs,
|
|
302
|
+
message: `Assertion failed: ${failedAssertion.field} ${
|
|
303
|
+
failedAssertion.operator
|
|
304
|
+
}${"value" in failedAssertion ? ` ${failedAssertion.value}` : ""}`,
|
|
305
|
+
metadata: { ...result, failedAssertion },
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Check if service is SERVING
|
|
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
|
+
}
|
|
318
|
+
|
|
319
|
+
return {
|
|
320
|
+
status: "healthy",
|
|
321
|
+
latencyMs: responseTimeMs,
|
|
322
|
+
message: `gRPC service ${
|
|
323
|
+
validatedConfig.service || "(root)"
|
|
324
|
+
} is SERVING`,
|
|
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
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|