@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-grpc-backend",
3
- "version": "0.0.2",
3
+ "version": "0.1.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "scripts": {
@@ -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";
@@ -2,14 +2,14 @@ import { describe, expect, it, mock } from "bun:test";
2
2
  import {
3
3
  GrpcHealthCheckStrategy,
4
4
  GrpcHealthClient,
5
- GrpcHealthStatus,
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?: GrpcHealthStatus;
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("execute", () => {
24
- it("should return healthy for SERVING status", async () => {
23
+ describe("createClient", () => {
24
+ it("should return a connected client", async () => {
25
25
  const strategy = new GrpcHealthCheckStrategy(createMockClient());
26
26
 
27
- const result = await strategy.execute({
27
+ const connectedClient = await strategy.createClient({
28
28
  host: "localhost",
29
29
  port: 50051,
30
30
  timeout: 5000,
31
31
  });
32
32
 
33
- expect(result.status).toBe("healthy");
34
- expect(result.metadata?.connected).toBe(true);
35
- expect(result.metadata?.status).toBe("SERVING");
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
- it("should return unhealthy for NOT_SERVING status", async () => {
39
- const strategy = new GrpcHealthCheckStrategy(
40
- createMockClient({ status: "NOT_SERVING" })
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 result = await strategy.execute({
45
+ const connectedClient = await strategy.createClient({
44
46
  host: "localhost",
45
47
  port: 50051,
46
48
  timeout: 5000,
47
49
  });
48
50
 
49
- expect(result.status).toBe("unhealthy");
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
- it("should return unhealthy for SERVICE_UNKNOWN status", async () => {
55
- const strategy = new GrpcHealthCheckStrategy(
56
- createMockClient({ status: "SERVICE_UNKNOWN" })
57
- );
53
+ expect(result.status).toBe("SERVING");
58
54
 
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");
55
+ connectedClient.close();
68
56
  });
69
57
 
70
- it("should return unhealthy for connection error", async () => {
58
+ it("should return NOT_SERVING status for unhealthy service", async () => {
71
59
  const strategy = new GrpcHealthCheckStrategy(
72
- createMockClient({ error: new Error("Connection refused") })
60
+ createMockClient({ status: "NOT_SERVING" })
73
61
  );
74
62
 
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({
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
- expect(result.status).toBe("healthy");
99
- });
69
+ const result = await connectedClient.client.exec({ service: "" });
100
70
 
101
- it("should pass status assertion", async () => {
102
- const strategy = new GrpcHealthCheckStrategy(createMockClient());
71
+ expect(result.status).toBe("NOT_SERVING");
103
72
 
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");
73
+ connectedClient.close();
112
74
  });
113
75
 
114
- it("should fail status assertion when not matching", async () => {
76
+ it("should return error for connection failure", async () => {
115
77
  const strategy = new GrpcHealthCheckStrategy(
116
- createMockClient({ status: "NOT_SERVING" })
78
+ createMockClient({ error: new Error("Connection refused") })
117
79
  );
118
80
 
119
- const result = await strategy.execute({
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
- expect(result.status).toBe("unhealthy");
127
- expect(result.message).toContain("Assertion failed");
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.execute({
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
- timeThresholdField,
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 overall server health)"),
55
- useTls: z.boolean().default(false).describe("Use TLS/SSL connection"),
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: GrpcHealthStatus.meta({
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
- export type GrpcResult = z.infer<typeof grpcResultSchema>;
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
- export type GrpcAggregatedResult = z.infer<typeof grpcAggregatedSchema>;
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: GrpcHealthStatus }>;
119
+ }): Promise<{ status: GrpcHealthStatusType }>;
134
120
  }
135
121
 
136
122
  // Default client using @grpc/grpc-js
137
123
  const defaultGrpcClient: GrpcHealthClient = {
138
- async check(options) {
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
- // 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
- );
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.Check(
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 (err) {
181
- reject(err);
162
+ if (error) {
163
+ reject(error);
182
164
  return;
183
165
  }
184
166
 
185
- // Map status number to enum
186
- const statusMap: Record<number, GrpcHealthStatus> = {
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 HealthCheckStrategy<GrpcConfig, GrpcResult, GrpcAggregatedResult>
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: 1,
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: 1,
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
- 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
- }
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: validRuns > 0 ? totalResponseTime / validRuns : 0,
263
- successRate: runs.length > 0 ? (successCount / runs.length) * 100 : 0,
274
+ avgResponseTime,
275
+ successRate,
264
276
  errorCount,
265
277
  servingCount,
266
278
  };
267
279
  }
268
280
 
269
- async execute(
281
+ async createClient(
270
282
  config: GrpcConfigInput
271
- ): Promise<HealthCheckResult<GrpcResult>> {
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
- // 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
- }
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
- 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
- }
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
+ >;