@checkstack/healthcheck-redis-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-redis-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-redis-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,147 @@
1
+ import { describe, expect, it, mock } from "bun:test";
2
+ import { CommandCollector, type CommandConfig } from "./command-collector";
3
+ import type { RedisTransportClient } from "./transport-client";
4
+
5
+ describe("CommandCollector", () => {
6
+ const createMockClient = (
7
+ response: {
8
+ value?: string;
9
+ error?: string;
10
+ } = {}
11
+ ): RedisTransportClient => ({
12
+ exec: mock(() =>
13
+ Promise.resolve({
14
+ value: response.value ?? "PONG",
15
+ error: response.error,
16
+ })
17
+ ),
18
+ });
19
+
20
+ describe("execute", () => {
21
+ it("should execute PING successfully", async () => {
22
+ const collector = new CommandCollector();
23
+ const client = createMockClient({ value: "PONG" });
24
+
25
+ const result = await collector.execute({
26
+ config: { command: "PING" },
27
+ client,
28
+ pluginId: "test",
29
+ });
30
+
31
+ expect(result.result.response).toBe("PONG");
32
+ expect(result.result.success).toBe(true);
33
+ expect(result.result.responseTimeMs).toBeGreaterThanOrEqual(0);
34
+ expect(result.error).toBeUndefined();
35
+ });
36
+
37
+ it("should execute INFO with section", async () => {
38
+ const collector = new CommandCollector();
39
+ const client = createMockClient({ value: "redis_version:7.0.0" });
40
+
41
+ const result = await collector.execute({
42
+ config: { command: "INFO", args: "server" },
43
+ client,
44
+ pluginId: "test",
45
+ });
46
+
47
+ expect(result.result.response).toContain("redis_version");
48
+ expect(result.result.success).toBe(true);
49
+ });
50
+
51
+ it("should return error for failed command", async () => {
52
+ const collector = new CommandCollector();
53
+ const client = createMockClient({ error: "NOAUTH" });
54
+
55
+ const result = await collector.execute({
56
+ config: { command: "PING" },
57
+ client,
58
+ pluginId: "test",
59
+ });
60
+
61
+ expect(result.result.success).toBe(false);
62
+ expect(result.error).toBe("NOAUTH");
63
+ });
64
+
65
+ it("should pass correct parameters to client", async () => {
66
+ const collector = new CommandCollector();
67
+ const client = createMockClient();
68
+
69
+ await collector.execute({
70
+ config: { command: "GET", args: "mykey" },
71
+ client,
72
+ pluginId: "test",
73
+ });
74
+
75
+ expect(client.exec).toHaveBeenCalledWith({
76
+ cmd: "GET",
77
+ args: ["mykey"],
78
+ });
79
+ });
80
+ });
81
+
82
+ describe("aggregateResult", () => {
83
+ it("should calculate average response time and success rate", () => {
84
+ const collector = new CommandCollector();
85
+ const runs = [
86
+ {
87
+ id: "1",
88
+ status: "healthy" as const,
89
+ latencyMs: 10,
90
+ checkId: "c1",
91
+ timestamp: new Date(),
92
+ metadata: { responseTimeMs: 5, success: true },
93
+ },
94
+ {
95
+ id: "2",
96
+ status: "healthy" as const,
97
+ latencyMs: 15,
98
+ checkId: "c1",
99
+ timestamp: new Date(),
100
+ metadata: { responseTimeMs: 15, success: true },
101
+ },
102
+ ];
103
+
104
+ const aggregated = collector.aggregateResult(runs);
105
+
106
+ expect(aggregated.avgResponseTimeMs).toBe(10);
107
+ expect(aggregated.successRate).toBe(100);
108
+ });
109
+
110
+ it("should calculate success rate correctly", () => {
111
+ const collector = new CommandCollector();
112
+ const runs = [
113
+ {
114
+ id: "1",
115
+ status: "healthy" as const,
116
+ latencyMs: 10,
117
+ checkId: "c1",
118
+ timestamp: new Date(),
119
+ metadata: { responseTimeMs: 5, success: true },
120
+ },
121
+ {
122
+ id: "2",
123
+ status: "unhealthy" as const,
124
+ latencyMs: 15,
125
+ checkId: "c1",
126
+ timestamp: new Date(),
127
+ metadata: { responseTimeMs: 15, success: false },
128
+ },
129
+ ];
130
+
131
+ const aggregated = collector.aggregateResult(runs);
132
+
133
+ expect(aggregated.successRate).toBe(50);
134
+ });
135
+ });
136
+
137
+ describe("metadata", () => {
138
+ it("should have correct static properties", () => {
139
+ const collector = new CommandCollector();
140
+
141
+ expect(collector.id).toBe("command");
142
+ expect(collector.displayName).toBe("Redis Command");
143
+ expect(collector.allowMultiple).toBe(true);
144
+ expect(collector.supportedPlugins).toHaveLength(1);
145
+ });
146
+ });
147
+ });
@@ -0,0 +1,153 @@
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 { RedisTransportClient } from "./transport-client";
15
+
16
+ // ============================================================================
17
+ // CONFIGURATION SCHEMA
18
+ // ============================================================================
19
+
20
+ const commandConfigSchema = z.object({
21
+ command: z
22
+ .enum(["PING", "INFO", "GET"])
23
+ .default("PING")
24
+ .describe("Redis command to execute"),
25
+ args: z
26
+ .string()
27
+ .optional()
28
+ .describe("Command argument (section for INFO, key for GET)"),
29
+ });
30
+
31
+ export type CommandConfig = z.infer<typeof commandConfigSchema>;
32
+
33
+ // ============================================================================
34
+ // RESULT SCHEMAS
35
+ // ============================================================================
36
+
37
+ const commandResultSchema = z.object({
38
+ response: healthResultString({
39
+ "x-chart-type": "text",
40
+ "x-chart-label": "Response",
41
+ }).optional(),
42
+ responseTimeMs: healthResultNumber({
43
+ "x-chart-type": "line",
44
+ "x-chart-label": "Response Time",
45
+ "x-chart-unit": "ms",
46
+ }),
47
+ success: healthResultBoolean({
48
+ "x-chart-type": "boolean",
49
+ "x-chart-label": "Success",
50
+ }),
51
+ });
52
+
53
+ export type CommandResult = z.infer<typeof commandResultSchema>;
54
+
55
+ const commandAggregatedSchema = z.object({
56
+ avgResponseTimeMs: healthResultNumber({
57
+ "x-chart-type": "line",
58
+ "x-chart-label": "Avg Response Time",
59
+ "x-chart-unit": "ms",
60
+ }),
61
+ successRate: healthResultNumber({
62
+ "x-chart-type": "gauge",
63
+ "x-chart-label": "Success Rate",
64
+ "x-chart-unit": "%",
65
+ }),
66
+ });
67
+
68
+ export type CommandAggregatedResult = z.infer<typeof commandAggregatedSchema>;
69
+
70
+ // ============================================================================
71
+ // COMMAND COLLECTOR
72
+ // ============================================================================
73
+
74
+ /**
75
+ * Built-in Redis command collector.
76
+ * Executes Redis commands and checks results.
77
+ */
78
+ export class CommandCollector
79
+ implements
80
+ CollectorStrategy<
81
+ RedisTransportClient,
82
+ CommandConfig,
83
+ CommandResult,
84
+ CommandAggregatedResult
85
+ >
86
+ {
87
+ id = "command";
88
+ displayName = "Redis Command";
89
+ description = "Execute a Redis command and check the result";
90
+
91
+ supportedPlugins = [pluginMetadata];
92
+
93
+ allowMultiple = true;
94
+
95
+ config = new Versioned({ version: 1, schema: commandConfigSchema });
96
+ result = new Versioned({ version: 1, schema: commandResultSchema });
97
+ aggregatedResult = new Versioned({
98
+ version: 1,
99
+ schema: commandAggregatedSchema,
100
+ });
101
+
102
+ async execute({
103
+ config,
104
+ client,
105
+ }: {
106
+ config: CommandConfig;
107
+ client: RedisTransportClient;
108
+ pluginId: string;
109
+ }): Promise<CollectorResult<CommandResult>> {
110
+ const startTime = Date.now();
111
+
112
+ const response = await client.exec({
113
+ cmd: config.command,
114
+ args: config.args ? [config.args] : undefined,
115
+ });
116
+
117
+ const responseTimeMs = Date.now() - startTime;
118
+
119
+ return {
120
+ result: {
121
+ response: response.value,
122
+ responseTimeMs,
123
+ success: !response.error,
124
+ },
125
+ error: response.error,
126
+ };
127
+ }
128
+
129
+ aggregateResult(
130
+ runs: HealthCheckRunForAggregation<CommandResult>[]
131
+ ): CommandAggregatedResult {
132
+ const times = runs
133
+ .map((r) => r.metadata?.responseTimeMs)
134
+ .filter((v): v is number => typeof v === "number");
135
+
136
+ const successes = runs
137
+ .map((r) => r.metadata?.success)
138
+ .filter((v): v is boolean => typeof v === "boolean");
139
+
140
+ const successCount = successes.filter(Boolean).length;
141
+
142
+ return {
143
+ avgResponseTimeMs:
144
+ times.length > 0
145
+ ? Math.round(times.reduce((a, b) => a + b, 0) / times.length)
146
+ : 0,
147
+ successRate:
148
+ successes.length > 0
149
+ ? Math.round((successCount / successes.length) * 100)
150
+ : 0,
151
+ };
152
+ }
153
+ }
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 { RedisHealthCheckStrategy } from "./strategy";
6
3
  import { pluginMetadata } from "./plugin-metadata";
4
+ import { CommandCollector } from "./command-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 Redis Health Check Strategy...");
18
17
  const strategy = new RedisHealthCheckStrategy();
19
18
  healthCheckRegistry.register(strategy);
19
+ collectorRegistry.register(new CommandCollector());
20
20
  },
21
21
  });
22
22
  },
23
23
  });
24
+
25
+ export { pluginMetadata } from "./plugin-metadata";
@@ -1,7 +1,33 @@
1
1
  import { describe, expect, it, mock } from "bun:test";
2
- import { RedisHealthCheckStrategy, RedisClient } from "./strategy";
2
+ import {
3
+ RedisHealthCheckStrategy,
4
+ RedisClient,
5
+ RedisConnection,
6
+ } from "./strategy";
3
7
 
4
8
  describe("RedisHealthCheckStrategy", () => {
9
+ // Helper to create mock Redis connection
10
+ const createMockConnection = (
11
+ config: {
12
+ pingResponse?: string;
13
+ infoResponse?: string;
14
+ pingError?: Error;
15
+ } = {}
16
+ ): RedisConnection => ({
17
+ ping: mock(() =>
18
+ config.pingError
19
+ ? Promise.reject(config.pingError)
20
+ : Promise.resolve(config.pingResponse ?? "PONG")
21
+ ),
22
+ info: mock(() =>
23
+ Promise.resolve(
24
+ config.infoResponse ?? "redis_version:7.0.0\r\nrole:master\r\n"
25
+ )
26
+ ),
27
+ get: mock(() => Promise.resolve(undefined)),
28
+ quit: mock(() => Promise.resolve("OK")),
29
+ });
30
+
5
31
  // Helper to create mock Redis client
6
32
  const createMockClient = (
7
33
  config: {
@@ -14,127 +40,91 @@ describe("RedisHealthCheckStrategy", () => {
14
40
  connect: mock(() =>
15
41
  config.connectError
16
42
  ? Promise.reject(config.connectError)
17
- : Promise.resolve({
18
- ping: mock(() =>
19
- config.pingError
20
- ? Promise.reject(config.pingError)
21
- : Promise.resolve(config.pingResponse ?? "PONG")
22
- ),
23
- info: mock(() =>
24
- Promise.resolve(
25
- config.infoResponse ?? "redis_version:7.0.0\r\nrole:master\r\n"
26
- )
27
- ),
28
- quit: mock(() => Promise.resolve("OK")),
29
- })
43
+ : Promise.resolve(createMockConnection(config))
30
44
  ),
31
45
  });
32
46
 
33
- describe("execute", () => {
34
- it("should return healthy for successful connection", async () => {
47
+ describe("createClient", () => {
48
+ it("should return a connected client for successful connection", async () => {
35
49
  const strategy = new RedisHealthCheckStrategy(createMockClient());
36
50
 
37
- const result = await strategy.execute({
51
+ const connectedClient = await strategy.createClient({
38
52
  host: "localhost",
39
53
  port: 6379,
40
54
  timeout: 5000,
41
55
  });
42
56
 
43
- expect(result.status).toBe("healthy");
44
- expect(result.metadata?.connected).toBe(true);
45
- expect(result.metadata?.pingSuccess).toBe(true);
46
- expect(result.metadata?.redisVersion).toBe("7.0.0");
47
- expect(result.metadata?.role).toBe("master");
57
+ expect(connectedClient.client).toBeDefined();
58
+ expect(connectedClient.client.exec).toBeDefined();
59
+ expect(connectedClient.close).toBeDefined();
60
+
61
+ connectedClient.close();
48
62
  });
49
63
 
50
- it("should return unhealthy for connection error", async () => {
64
+ it("should throw for connection error", async () => {
51
65
  const strategy = new RedisHealthCheckStrategy(
52
66
  createMockClient({ connectError: new Error("Connection refused") })
53
67
  );
54
68
 
55
- const result = await strategy.execute({
69
+ await expect(
70
+ strategy.createClient({
71
+ host: "localhost",
72
+ port: 6379,
73
+ timeout: 5000,
74
+ })
75
+ ).rejects.toThrow("Connection refused");
76
+ });
77
+ });
78
+
79
+ describe("client.exec", () => {
80
+ it("should execute PING successfully", async () => {
81
+ const strategy = new RedisHealthCheckStrategy(createMockClient());
82
+ const connectedClient = await strategy.createClient({
56
83
  host: "localhost",
57
84
  port: 6379,
58
85
  timeout: 5000,
59
86
  });
60
87
 
61
- expect(result.status).toBe("unhealthy");
62
- expect(result.message).toContain("Connection refused");
63
- expect(result.metadata?.connected).toBe(false);
88
+ const result = await connectedClient.client.exec({ cmd: "PING" });
89
+
90
+ expect(result.value).toBe("PONG");
91
+
92
+ connectedClient.close();
64
93
  });
65
94
 
66
- it("should return unhealthy for PING failure", async () => {
95
+ it("should return error for ping failure", async () => {
67
96
  const strategy = new RedisHealthCheckStrategy(
68
97
  createMockClient({ pingError: new Error("NOAUTH") })
69
98
  );
70
-
71
- const result = await strategy.execute({
99
+ const connectedClient = await strategy.createClient({
72
100
  host: "localhost",
73
101
  port: 6379,
74
102
  timeout: 5000,
75
103
  });
76
104
 
77
- expect(result.status).toBe("unhealthy");
78
- expect(result.metadata?.pingSuccess).toBe(false);
79
- });
105
+ const result = await connectedClient.client.exec({ cmd: "PING" });
80
106
 
81
- it("should pass connectionTime assertion when below threshold", async () => {
82
- const strategy = new RedisHealthCheckStrategy(createMockClient());
107
+ expect(result.error).toContain("NOAUTH");
83
108
 
84
- const result = await strategy.execute({
85
- host: "localhost",
86
- port: 6379,
87
- timeout: 5000,
88
- assertions: [
89
- { field: "connectionTime", operator: "lessThan", value: 5000 },
90
- ],
91
- });
92
-
93
- expect(result.status).toBe("healthy");
109
+ connectedClient.close();
94
110
  });
95
111
 
96
- it("should pass role assertion", async () => {
112
+ it("should return server info", async () => {
97
113
  const strategy = new RedisHealthCheckStrategy(createMockClient());
98
-
99
- const result = await strategy.execute({
114
+ const connectedClient = await strategy.createClient({
100
115
  host: "localhost",
101
116
  port: 6379,
102
117
  timeout: 5000,
103
- assertions: [{ field: "role", operator: "equals", value: "master" }],
104
118
  });
105
119
 
106
- expect(result.status).toBe("healthy");
107
- });
108
-
109
- it("should fail role assertion when replica", async () => {
110
- const strategy = new RedisHealthCheckStrategy(
111
- createMockClient({
112
- infoResponse: "redis_version:7.0.0\r\nrole:slave\r\n",
113
- })
114
- );
115
-
116
- const result = await strategy.execute({
117
- host: "localhost",
118
- port: 6379,
119
- timeout: 5000,
120
- assertions: [{ field: "role", operator: "equals", value: "master" }],
120
+ const result = await connectedClient.client.exec({
121
+ cmd: "INFO",
122
+ args: ["server"],
121
123
  });
122
124
 
123
- expect(result.status).toBe("unhealthy");
124
- expect(result.message).toContain("Assertion failed");
125
- });
126
-
127
- it("should pass pingSuccess assertion", async () => {
128
- const strategy = new RedisHealthCheckStrategy(createMockClient());
129
-
130
- const result = await strategy.execute({
131
- host: "localhost",
132
- port: 6379,
133
- timeout: 5000,
134
- assertions: [{ field: "pingSuccess", operator: "isTrue" }],
135
- });
125
+ expect(result.value).toContain("redis_version");
136
126
 
137
- expect(result.status).toBe("healthy");
127
+ connectedClient.close();
138
128
  });
139
129
  });
140
130
 
@@ -151,7 +141,6 @@ describe("RedisHealthCheckStrategy", () => {
151
141
  metadata: {
152
142
  connected: true,
153
143
  connectionTimeMs: 5,
154
- pingTimeMs: 1,
155
144
  pingSuccess: true,
156
145
  role: "master",
157
146
  },
@@ -165,7 +154,6 @@ describe("RedisHealthCheckStrategy", () => {
165
154
  metadata: {
166
155
  connected: true,
167
156
  connectionTimeMs: 15,
168
- pingTimeMs: 3,
169
157
  pingSuccess: true,
170
158
  role: "master",
171
159
  },
@@ -175,7 +163,6 @@ describe("RedisHealthCheckStrategy", () => {
175
163
  const aggregated = strategy.aggregateResult(runs);
176
164
 
177
165
  expect(aggregated.avgConnectionTime).toBe(10);
178
- expect(aggregated.avgPingTime).toBe(2);
179
166
  expect(aggregated.successRate).toBe(100);
180
167
  expect(aggregated.errorCount).toBe(0);
181
168
  });
package/src/strategy.ts CHANGED
@@ -1,46 +1,29 @@
1
1
  import Redis from "ioredis";
2
2
  import {
3
3
  HealthCheckStrategy,
4
- HealthCheckResult,
5
4
  HealthCheckRunForAggregation,
6
5
  Versioned,
7
6
  z,
8
- timeThresholdField,
9
- booleanField,
10
- enumField,
11
7
  configString,
12
8
  configNumber,
13
9
  configBoolean,
14
- evaluateAssertions,
10
+ type ConnectedClient,
15
11
  } from "@checkstack/backend-api";
16
12
  import {
17
13
  healthResultBoolean,
18
14
  healthResultNumber,
19
15
  healthResultString,
20
16
  } from "@checkstack/healthcheck-common";
17
+ import type {
18
+ RedisTransportClient,
19
+ RedisCommand,
20
+ RedisCommandResult,
21
+ } from "./transport-client";
21
22
 
22
23
  // ============================================================================
23
24
  // SCHEMAS
24
25
  // ============================================================================
25
26
 
26
- /**
27
- * Valid Redis server roles from the INFO replication command.
28
- */
29
- const RedisRole = z.enum(["master", "slave", "sentinel"]);
30
- export type RedisRole = z.infer<typeof RedisRole>;
31
-
32
- /**
33
- * Assertion schema for Redis health checks using shared factories.
34
- */
35
- const redisAssertionSchema = z.discriminatedUnion("field", [
36
- timeThresholdField("connectionTime"),
37
- timeThresholdField("pingTime"),
38
- booleanField("pingSuccess"),
39
- enumField("role", RedisRole.options),
40
- ]);
41
-
42
- export type RedisAssertion = z.infer<typeof redisAssertionSchema>;
43
-
44
27
  /**
45
28
  * Configuration schema for Redis health checks.
46
29
  */
@@ -53,18 +36,18 @@ export const redisConfigSchema = z.object({
53
36
  .default(6379)
54
37
  .describe("Redis port"),
55
38
  password: configString({ "x-secret": true })
56
- .describe("Password for authentication")
57
- .optional(),
58
- database: configNumber({}).int().min(0).default(0).describe("Database index"),
39
+ .optional()
40
+ .describe("Redis password"),
41
+ database: configNumber({})
42
+ .int()
43
+ .min(0)
44
+ .default(0)
45
+ .describe("Redis database number"),
59
46
  tls: configBoolean({}).default(false).describe("Use TLS connection"),
60
47
  timeout: configNumber({})
61
48
  .min(100)
62
49
  .default(5000)
63
50
  .describe("Connection timeout in milliseconds"),
64
- assertions: z
65
- .array(redisAssertionSchema)
66
- .optional()
67
- .describe("Validation conditions"),
68
51
  });
69
52
 
70
53
  export type RedisConfig = z.infer<typeof redisConfigSchema>;
@@ -83,31 +66,13 @@ const redisResultSchema = z.object({
83
66
  "x-chart-label": "Connection Time",
84
67
  "x-chart-unit": "ms",
85
68
  }),
86
- pingTimeMs: healthResultNumber({
87
- "x-chart-type": "line",
88
- "x-chart-label": "Ping Time",
89
- "x-chart-unit": "ms",
90
- }).optional(),
91
- pingSuccess: healthResultBoolean({
92
- "x-chart-type": "boolean",
93
- "x-chart-label": "Ping Success",
94
- }),
95
- role: healthResultString({
96
- "x-chart-type": "text",
97
- "x-chart-label": "Role",
98
- }).optional(),
99
- redisVersion: healthResultString({
100
- "x-chart-type": "text",
101
- "x-chart-label": "Redis Version",
102
- }).optional(),
103
- failedAssertion: redisAssertionSchema.optional(),
104
69
  error: healthResultString({
105
70
  "x-chart-type": "status",
106
71
  "x-chart-label": "Error",
107
72
  }).optional(),
108
73
  });
109
74
 
110
- export type RedisResult = z.infer<typeof redisResultSchema>;
75
+ type RedisResult = z.infer<typeof redisResultSchema>;
111
76
 
112
77
  /**
113
78
  * Aggregated metadata for buckets.
@@ -118,9 +83,9 @@ const redisAggregatedSchema = z.object({
118
83
  "x-chart-label": "Avg Connection Time",
119
84
  "x-chart-unit": "ms",
120
85
  }),
121
- avgPingTime: healthResultNumber({
86
+ maxConnectionTime: healthResultNumber({
122
87
  "x-chart-type": "line",
123
- "x-chart-label": "Avg Ping Time",
88
+ "x-chart-label": "Max Connection Time",
124
89
  "x-chart-unit": "ms",
125
90
  }),
126
91
  successRate: healthResultNumber({
@@ -134,7 +99,7 @@ const redisAggregatedSchema = z.object({
134
99
  }),
135
100
  });
136
101
 
137
- export type RedisAggregatedResult = z.infer<typeof redisAggregatedSchema>;
102
+ type RedisAggregatedResult = z.infer<typeof redisAggregatedSchema>;
138
103
 
139
104
  // ============================================================================
140
105
  // REDIS CLIENT INTERFACE (for testability)
@@ -143,6 +108,7 @@ export type RedisAggregatedResult = z.infer<typeof redisAggregatedSchema>;
143
108
  export interface RedisConnection {
144
109
  ping(): Promise<string>;
145
110
  info(section: string): Promise<string>;
111
+ get(key: string): Promise<string | undefined>;
146
112
  quit(): Promise<string>;
147
113
  }
148
114
 
@@ -159,24 +125,33 @@ export interface RedisClient {
159
125
 
160
126
  // Default client using ioredis
161
127
  const defaultRedisClient: RedisClient = {
162
- async connect(config) {
163
- const redis = new Redis({
164
- host: config.host,
165
- port: config.port,
166
- password: config.password,
167
- db: config.db,
168
- tls: config.tls ? {} : undefined,
169
- connectTimeout: config.connectTimeout,
170
- lazyConnect: true,
171
- });
172
-
173
- await redis.connect();
128
+ connect(config) {
129
+ return new Promise((resolve, reject) => {
130
+ const redis = new Redis({
131
+ host: config.host,
132
+ port: config.port,
133
+ password: config.password,
134
+ db: config.db,
135
+ tls: config.tls ? {} : undefined,
136
+ connectTimeout: config.connectTimeout,
137
+ lazyConnect: true,
138
+ maxRetriesPerRequest: 0,
139
+ });
174
140
 
175
- return {
176
- ping: () => redis.ping(),
177
- info: (section: string) => redis.info(section),
178
- quit: () => redis.quit(),
179
- };
141
+ redis.on("error", reject);
142
+
143
+ redis
144
+ .connect()
145
+ .then(() => {
146
+ resolve({
147
+ ping: () => redis.ping(),
148
+ info: (section: string) => redis.info(section),
149
+ get: (key: string) => redis.get(key).then((v) => v ?? undefined),
150
+ quit: () => redis.quit(),
151
+ });
152
+ })
153
+ .catch(reject);
154
+ });
180
155
  },
181
156
  };
182
157
 
@@ -186,7 +161,12 @@ const defaultRedisClient: RedisClient = {
186
161
 
187
162
  export class RedisHealthCheckStrategy
188
163
  implements
189
- HealthCheckStrategy<RedisConfig, RedisResult, RedisAggregatedResult>
164
+ HealthCheckStrategy<
165
+ RedisConfig,
166
+ RedisTransportClient,
167
+ RedisResult,
168
+ RedisAggregatedResult
169
+ >
190
170
  {
191
171
  id = "redis";
192
172
  displayName = "Redis Health Check";
@@ -199,13 +179,29 @@ export class RedisHealthCheckStrategy
199
179
  }
200
180
 
201
181
  config: Versioned<RedisConfig> = new Versioned({
202
- version: 1,
182
+ version: 2, // Bumped for createClient pattern
203
183
  schema: redisConfigSchema,
184
+ migrations: [
185
+ {
186
+ fromVersion: 1,
187
+ toVersion: 2,
188
+ description: "Migrate to createClient pattern (no config changes)",
189
+ migrate: (data: unknown) => data,
190
+ },
191
+ ],
204
192
  });
205
193
 
206
194
  result: Versioned<RedisResult> = new Versioned({
207
- version: 1,
195
+ version: 2,
208
196
  schema: redisResultSchema,
197
+ migrations: [
198
+ {
199
+ fromVersion: 1,
200
+ toVersion: 2,
201
+ description: "Migrate to createClient pattern (no result changes)",
202
+ migrate: (data: unknown) => data,
203
+ },
204
+ ],
209
205
  });
210
206
 
211
207
  aggregatedResult: Versioned<RedisAggregatedResult> = new Versioned({
@@ -216,147 +212,103 @@ export class RedisHealthCheckStrategy
216
212
  aggregateResult(
217
213
  runs: HealthCheckRunForAggregation<RedisResult>[]
218
214
  ): RedisAggregatedResult {
219
- let totalConnectionTime = 0;
220
- let totalPingTime = 0;
221
- let successCount = 0;
222
- let errorCount = 0;
223
- let validRuns = 0;
224
- let pingRuns = 0;
225
-
226
- for (const run of runs) {
227
- if (run.metadata?.error) {
228
- errorCount++;
229
- continue;
230
- }
231
- if (run.status === "healthy") {
232
- successCount++;
233
- }
234
- if (run.metadata) {
235
- totalConnectionTime += run.metadata.connectionTimeMs;
236
- if (run.metadata.pingTimeMs !== undefined) {
237
- totalPingTime += run.metadata.pingTimeMs;
238
- pingRuns++;
239
- }
240
- validRuns++;
241
- }
215
+ const validRuns = runs.filter((r) => r.metadata);
216
+
217
+ if (validRuns.length === 0) {
218
+ return {
219
+ avgConnectionTime: 0,
220
+ maxConnectionTime: 0,
221
+ successRate: 0,
222
+ errorCount: 0,
223
+ };
242
224
  }
243
225
 
226
+ const connectionTimes = validRuns
227
+ .map((r) => r.metadata?.connectionTimeMs)
228
+ .filter((t): t is number => typeof t === "number");
229
+
230
+ const avgConnectionTime =
231
+ connectionTimes.length > 0
232
+ ? Math.round(
233
+ connectionTimes.reduce((a, b) => a + b, 0) / connectionTimes.length
234
+ )
235
+ : 0;
236
+
237
+ const maxConnectionTime =
238
+ connectionTimes.length > 0 ? Math.max(...connectionTimes) : 0;
239
+
240
+ const successCount = validRuns.filter(
241
+ (r) => r.metadata?.connected === true
242
+ ).length;
243
+ const successRate = Math.round((successCount / validRuns.length) * 100);
244
+
245
+ const errorCount = validRuns.filter(
246
+ (r) => r.metadata?.error !== undefined
247
+ ).length;
248
+
244
249
  return {
245
- avgConnectionTime: validRuns > 0 ? totalConnectionTime / validRuns : 0,
246
- avgPingTime: pingRuns > 0 ? totalPingTime / pingRuns : 0,
247
- successRate: runs.length > 0 ? (successCount / runs.length) * 100 : 0,
250
+ avgConnectionTime,
251
+ maxConnectionTime,
252
+ successRate,
248
253
  errorCount,
249
254
  };
250
255
  }
251
256
 
252
- async execute(
257
+ async createClient(
253
258
  config: RedisConfigInput
254
- ): Promise<HealthCheckResult<RedisResult>> {
259
+ ): Promise<ConnectedClient<RedisTransportClient>> {
255
260
  const validatedConfig = this.config.validate(config);
256
- const start = performance.now();
257
-
258
- try {
259
- // Connect to Redis
260
- const connection = await this.redisClient.connect({
261
- host: validatedConfig.host,
262
- port: validatedConfig.port,
263
- password: validatedConfig.password,
264
- db: validatedConfig.database,
265
- tls: validatedConfig.tls,
266
- connectTimeout: validatedConfig.timeout,
267
- });
268
-
269
- const connectionTimeMs = Math.round(performance.now() - start);
270
-
271
- // Execute PING command
272
- const pingStart = performance.now();
273
- let pingSuccess = false;
274
- let pingTimeMs: number | undefined;
275
-
276
- try {
277
- const pong = await connection.ping();
278
- pingSuccess = pong === "PONG";
279
- pingTimeMs = Math.round(performance.now() - pingStart);
280
- } catch {
281
- pingSuccess = false;
282
- pingTimeMs = Math.round(performance.now() - pingStart);
283
- }
284
-
285
- // Get server info
286
- let role: string | undefined;
287
- let redisVersion: string | undefined;
288
-
289
- try {
290
- const info = await connection.info("server");
291
- const roleMatch = /role:(\w+)/i.exec(info);
292
- const versionMatch = /redis_version:([^\r\n]+)/i.exec(info);
293
- role = roleMatch?.[1];
294
- redisVersion = versionMatch?.[1];
295
- } catch {
296
- // Info command failed, continue without it
297
- }
298
-
299
- await connection.quit();
300
-
301
- const result: Omit<RedisResult, "failedAssertion" | "error"> = {
302
- connected: true,
303
- connectionTimeMs,
304
- pingTimeMs,
305
- pingSuccess,
306
- role,
307
- redisVersion,
308
- };
309
261
 
310
- // Evaluate assertions using shared utility
311
- const failedAssertion = evaluateAssertions(validatedConfig.assertions, {
312
- connectionTime: connectionTimeMs,
313
- pingTime: pingTimeMs ?? 0,
314
- pingSuccess,
315
- role: role ?? "",
316
- });
262
+ const connection = await this.redisClient.connect({
263
+ host: validatedConfig.host,
264
+ port: validatedConfig.port,
265
+ password: validatedConfig.password,
266
+ db: validatedConfig.database,
267
+ tls: validatedConfig.tls,
268
+ connectTimeout: validatedConfig.timeout,
269
+ });
317
270
 
318
- if (failedAssertion) {
319
- return {
320
- status: "unhealthy",
321
- latencyMs: connectionTimeMs + (pingTimeMs ?? 0),
322
- message: `Assertion failed: ${failedAssertion.field} ${
323
- failedAssertion.operator
324
- }${"value" in failedAssertion ? ` ${failedAssertion.value}` : ""}`,
325
- metadata: { ...result, failedAssertion },
326
- };
327
- }
328
-
329
- if (!pingSuccess) {
330
- return {
331
- status: "unhealthy",
332
- latencyMs: connectionTimeMs + (pingTimeMs ?? 0),
333
- message: "Redis PING failed",
334
- metadata: result,
335
- };
336
- }
271
+ const client: RedisTransportClient = {
272
+ async exec(command: RedisCommand): Promise<RedisCommandResult> {
273
+ try {
274
+ let value: string | undefined;
275
+ switch (command.cmd) {
276
+ case "PING": {
277
+ value = await connection.ping();
278
+ break;
279
+ }
280
+ case "INFO": {
281
+ value = await connection.info(command.args?.[0] ?? "server");
282
+ break;
283
+ }
284
+ case "GET": {
285
+ value = await connection.get(command.args?.[0] ?? "");
286
+ break;
287
+ }
288
+ default: {
289
+ return {
290
+ value: undefined,
291
+ error: `Unsupported command: ${command.cmd}`,
292
+ };
293
+ }
294
+ }
295
+ return { value };
296
+ } catch (error) {
297
+ return {
298
+ value: undefined,
299
+ error: error instanceof Error ? error.message : String(error),
300
+ };
301
+ }
302
+ },
303
+ };
337
304
 
338
- return {
339
- status: "healthy",
340
- latencyMs: connectionTimeMs + (pingTimeMs ?? 0),
341
- message: `Redis ${redisVersion ?? "unknown"} (${
342
- role ?? "unknown"
343
- }) - PONG in ${pingTimeMs}ms`,
344
- metadata: result,
345
- };
346
- } catch (error: unknown) {
347
- const end = performance.now();
348
- const isError = error instanceof Error;
349
- return {
350
- status: "unhealthy",
351
- latencyMs: Math.round(end - start),
352
- message: isError ? error.message : "Redis connection failed",
353
- metadata: {
354
- connected: false,
355
- connectionTimeMs: Math.round(end - start),
356
- pingSuccess: false,
357
- error: isError ? error.name : "UnknownError",
358
- },
359
- };
360
- }
305
+ return {
306
+ client,
307
+ close: () => {
308
+ connection.quit().catch(() => {
309
+ // Ignore quit errors
310
+ });
311
+ },
312
+ };
361
313
  }
362
314
  }
@@ -0,0 +1,25 @@
1
+ import type { TransportClient } from "@checkstack/common";
2
+
3
+ /**
4
+ * Redis command for transport client.
5
+ */
6
+ export interface RedisCommand {
7
+ cmd: "PING" | "INFO" | "GET" | "SET" | "KEYS" | "CUSTOM";
8
+ args?: string[];
9
+ }
10
+
11
+ /**
12
+ * Redis command result.
13
+ */
14
+ export interface RedisCommandResult {
15
+ value: string | undefined;
16
+ error?: string;
17
+ }
18
+
19
+ /**
20
+ * Redis transport client for collector execution.
21
+ */
22
+ export type RedisTransportClient = TransportClient<
23
+ RedisCommand,
24
+ RedisCommandResult
25
+ >;