@checkstack/healthcheck-mysql-backend 0.0.2 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -0
- package/package.json +1 -1
- package/src/index.ts +7 -5
- package/src/query-collector.test.ts +132 -0
- package/src/query-collector.ts +141 -0
- package/src/strategy.test.ts +46 -77
- package/src/strategy.ts +107 -170
- package/src/transport-client.ts +24 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,48 @@
|
|
|
1
1
|
# @checkstack/healthcheck-mysql-backend
|
|
2
2
|
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- f5b1f49: Refactored health check strategies to use `createClient()` pattern with built-in collectors.
|
|
8
|
+
|
|
9
|
+
**Strategy Changes:**
|
|
10
|
+
|
|
11
|
+
- Replaced `execute()` with `createClient()` that returns a transport client
|
|
12
|
+
- Strategy configs now only contain connection parameters
|
|
13
|
+
- Collector configs handle what to do with the connection
|
|
14
|
+
|
|
15
|
+
**Built-in Collectors Added:**
|
|
16
|
+
|
|
17
|
+
- DNS: `LookupCollector` for hostname resolution
|
|
18
|
+
- gRPC: `HealthCollector` for gRPC health protocol
|
|
19
|
+
- HTTP: `RequestCollector` for HTTP requests
|
|
20
|
+
- MySQL: `QueryCollector` for database queries
|
|
21
|
+
- Ping: `PingCollector` for ICMP ping
|
|
22
|
+
- Postgres: `QueryCollector` for database queries
|
|
23
|
+
- Redis: `CommandCollector` for Redis commands
|
|
24
|
+
- Script: `ExecuteCollector` for script execution
|
|
25
|
+
- SSH: `CommandCollector` for SSH commands
|
|
26
|
+
- TCP: `BannerCollector` for TCP banner grabbing
|
|
27
|
+
- TLS: `CertificateCollector` for certificate inspection
|
|
28
|
+
|
|
29
|
+
### Patch Changes
|
|
30
|
+
|
|
31
|
+
- Updated dependencies [f5b1f49]
|
|
32
|
+
- Updated dependencies [f5b1f49]
|
|
33
|
+
- Updated dependencies [f5b1f49]
|
|
34
|
+
- Updated dependencies [f5b1f49]
|
|
35
|
+
- @checkstack/backend-api@0.1.0
|
|
36
|
+
- @checkstack/healthcheck-common@0.1.0
|
|
37
|
+
- @checkstack/common@0.0.3
|
|
38
|
+
|
|
39
|
+
## 0.0.3
|
|
40
|
+
|
|
41
|
+
### Patch Changes
|
|
42
|
+
|
|
43
|
+
- Updated dependencies [cb82e4d]
|
|
44
|
+
- @checkstack/healthcheck-common@0.0.3
|
|
45
|
+
|
|
3
46
|
## 0.0.2
|
|
4
47
|
|
|
5
48
|
### Patch Changes
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
createBackendPlugin,
|
|
3
|
-
coreServices,
|
|
4
|
-
} from "@checkstack/backend-api";
|
|
1
|
+
import { createBackendPlugin, coreServices } from "@checkstack/backend-api";
|
|
5
2
|
import { MysqlHealthCheckStrategy } from "./strategy";
|
|
6
3
|
import { pluginMetadata } from "./plugin-metadata";
|
|
4
|
+
import { QueryCollector } from "./query-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 MySQL Health Check Strategy...");
|
|
18
17
|
const strategy = new MysqlHealthCheckStrategy();
|
|
19
18
|
healthCheckRegistry.register(strategy);
|
|
19
|
+
collectorRegistry.register(new QueryCollector());
|
|
20
20
|
},
|
|
21
21
|
});
|
|
22
22
|
},
|
|
23
23
|
});
|
|
24
|
+
|
|
25
|
+
export { pluginMetadata } from "./plugin-metadata";
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, expect, it, mock } from "bun:test";
|
|
2
|
+
import { QueryCollector, type QueryConfig } from "./query-collector";
|
|
3
|
+
import type { MysqlTransportClient } from "./transport-client";
|
|
4
|
+
|
|
5
|
+
describe("QueryCollector", () => {
|
|
6
|
+
const createMockClient = (
|
|
7
|
+
response: {
|
|
8
|
+
rowCount?: number;
|
|
9
|
+
error?: string;
|
|
10
|
+
} = {}
|
|
11
|
+
): MysqlTransportClient => ({
|
|
12
|
+
exec: mock(() =>
|
|
13
|
+
Promise.resolve({
|
|
14
|
+
rowCount: response.rowCount ?? 1,
|
|
15
|
+
error: response.error,
|
|
16
|
+
})
|
|
17
|
+
),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("execute", () => {
|
|
21
|
+
it("should execute query successfully", async () => {
|
|
22
|
+
const collector = new QueryCollector();
|
|
23
|
+
const client = createMockClient({ rowCount: 5 });
|
|
24
|
+
|
|
25
|
+
const result = await collector.execute({
|
|
26
|
+
config: { query: "SELECT * FROM users" },
|
|
27
|
+
client,
|
|
28
|
+
pluginId: "test",
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
expect(result.result.rowCount).toBe(5);
|
|
32
|
+
expect(result.result.success).toBe(true);
|
|
33
|
+
expect(result.result.executionTimeMs).toBeGreaterThanOrEqual(0);
|
|
34
|
+
expect(result.error).toBeUndefined();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("should return error for failed query", async () => {
|
|
38
|
+
const collector = new QueryCollector();
|
|
39
|
+
const client = createMockClient({ error: "Table not found" });
|
|
40
|
+
|
|
41
|
+
const result = await collector.execute({
|
|
42
|
+
config: { query: "SELECT * FROM nonexistent" },
|
|
43
|
+
client,
|
|
44
|
+
pluginId: "test",
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
expect(result.result.success).toBe(false);
|
|
48
|
+
expect(result.error).toBe("Table not found");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("should pass query to client", async () => {
|
|
52
|
+
const collector = new QueryCollector();
|
|
53
|
+
const client = createMockClient();
|
|
54
|
+
|
|
55
|
+
await collector.execute({
|
|
56
|
+
config: { query: "SELECT COUNT(*) FROM orders" },
|
|
57
|
+
client,
|
|
58
|
+
pluginId: "test",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(client.exec).toHaveBeenCalledWith({
|
|
62
|
+
query: "SELECT COUNT(*) FROM orders",
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("aggregateResult", () => {
|
|
68
|
+
it("should calculate average execution time and success rate", () => {
|
|
69
|
+
const collector = new QueryCollector();
|
|
70
|
+
const runs = [
|
|
71
|
+
{
|
|
72
|
+
id: "1",
|
|
73
|
+
status: "healthy" as const,
|
|
74
|
+
latencyMs: 10,
|
|
75
|
+
checkId: "c1",
|
|
76
|
+
timestamp: new Date(),
|
|
77
|
+
metadata: { rowCount: 1, executionTimeMs: 50, success: true },
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: "2",
|
|
81
|
+
status: "healthy" as const,
|
|
82
|
+
latencyMs: 15,
|
|
83
|
+
checkId: "c1",
|
|
84
|
+
timestamp: new Date(),
|
|
85
|
+
metadata: { rowCount: 5, executionTimeMs: 100, success: true },
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
const aggregated = collector.aggregateResult(runs);
|
|
90
|
+
|
|
91
|
+
expect(aggregated.avgExecutionTimeMs).toBe(75);
|
|
92
|
+
expect(aggregated.successRate).toBe(100);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("should calculate success rate correctly", () => {
|
|
96
|
+
const collector = new QueryCollector();
|
|
97
|
+
const runs = [
|
|
98
|
+
{
|
|
99
|
+
id: "1",
|
|
100
|
+
status: "healthy" as const,
|
|
101
|
+
latencyMs: 10,
|
|
102
|
+
checkId: "c1",
|
|
103
|
+
timestamp: new Date(),
|
|
104
|
+
metadata: { rowCount: 1, executionTimeMs: 50, success: true },
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
id: "2",
|
|
108
|
+
status: "unhealthy" as const,
|
|
109
|
+
latencyMs: 15,
|
|
110
|
+
checkId: "c1",
|
|
111
|
+
timestamp: new Date(),
|
|
112
|
+
metadata: { rowCount: 0, executionTimeMs: 100, success: false },
|
|
113
|
+
},
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
const aggregated = collector.aggregateResult(runs);
|
|
117
|
+
|
|
118
|
+
expect(aggregated.successRate).toBe(50);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("metadata", () => {
|
|
123
|
+
it("should have correct static properties", () => {
|
|
124
|
+
const collector = new QueryCollector();
|
|
125
|
+
|
|
126
|
+
expect(collector.id).toBe("query");
|
|
127
|
+
expect(collector.displayName).toBe("SQL Query");
|
|
128
|
+
expect(collector.allowMultiple).toBe(true);
|
|
129
|
+
expect(collector.supportedPlugins).toHaveLength(1);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
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
|
+
healthResultBoolean,
|
|
11
|
+
} from "@checkstack/healthcheck-common";
|
|
12
|
+
import { pluginMetadata } from "./plugin-metadata";
|
|
13
|
+
import type { MysqlTransportClient } from "./transport-client";
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// CONFIGURATION SCHEMA
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
const queryConfigSchema = z.object({
|
|
20
|
+
query: z.string().min(1).default("SELECT 1").describe("SQL query to execute"),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export type QueryConfig = z.infer<typeof queryConfigSchema>;
|
|
24
|
+
|
|
25
|
+
// ============================================================================
|
|
26
|
+
// RESULT SCHEMAS
|
|
27
|
+
// ============================================================================
|
|
28
|
+
|
|
29
|
+
const queryResultSchema = z.object({
|
|
30
|
+
rowCount: healthResultNumber({
|
|
31
|
+
"x-chart-type": "counter",
|
|
32
|
+
"x-chart-label": "Row Count",
|
|
33
|
+
}),
|
|
34
|
+
executionTimeMs: healthResultNumber({
|
|
35
|
+
"x-chart-type": "line",
|
|
36
|
+
"x-chart-label": "Execution Time",
|
|
37
|
+
"x-chart-unit": "ms",
|
|
38
|
+
}),
|
|
39
|
+
success: healthResultBoolean({
|
|
40
|
+
"x-chart-type": "boolean",
|
|
41
|
+
"x-chart-label": "Success",
|
|
42
|
+
}),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export type QueryResult = z.infer<typeof queryResultSchema>;
|
|
46
|
+
|
|
47
|
+
const queryAggregatedSchema = z.object({
|
|
48
|
+
avgExecutionTimeMs: healthResultNumber({
|
|
49
|
+
"x-chart-type": "line",
|
|
50
|
+
"x-chart-label": "Avg Execution Time",
|
|
51
|
+
"x-chart-unit": "ms",
|
|
52
|
+
}),
|
|
53
|
+
successRate: healthResultNumber({
|
|
54
|
+
"x-chart-type": "gauge",
|
|
55
|
+
"x-chart-label": "Success Rate",
|
|
56
|
+
"x-chart-unit": "%",
|
|
57
|
+
}),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
export type QueryAggregatedResult = z.infer<typeof queryAggregatedSchema>;
|
|
61
|
+
|
|
62
|
+
// ============================================================================
|
|
63
|
+
// QUERY COLLECTOR
|
|
64
|
+
// ============================================================================
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Built-in MySQL query collector.
|
|
68
|
+
* Executes SQL queries and checks results.
|
|
69
|
+
*/
|
|
70
|
+
export class QueryCollector
|
|
71
|
+
implements
|
|
72
|
+
CollectorStrategy<
|
|
73
|
+
MysqlTransportClient,
|
|
74
|
+
QueryConfig,
|
|
75
|
+
QueryResult,
|
|
76
|
+
QueryAggregatedResult
|
|
77
|
+
>
|
|
78
|
+
{
|
|
79
|
+
id = "query";
|
|
80
|
+
displayName = "SQL Query";
|
|
81
|
+
description = "Execute a SQL query and check the result";
|
|
82
|
+
|
|
83
|
+
supportedPlugins = [pluginMetadata];
|
|
84
|
+
|
|
85
|
+
allowMultiple = true;
|
|
86
|
+
|
|
87
|
+
config = new Versioned({ version: 1, schema: queryConfigSchema });
|
|
88
|
+
result = new Versioned({ version: 1, schema: queryResultSchema });
|
|
89
|
+
aggregatedResult = new Versioned({
|
|
90
|
+
version: 1,
|
|
91
|
+
schema: queryAggregatedSchema,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
async execute({
|
|
95
|
+
config,
|
|
96
|
+
client,
|
|
97
|
+
}: {
|
|
98
|
+
config: QueryConfig;
|
|
99
|
+
client: MysqlTransportClient;
|
|
100
|
+
pluginId: string;
|
|
101
|
+
}): Promise<CollectorResult<QueryResult>> {
|
|
102
|
+
const startTime = Date.now();
|
|
103
|
+
|
|
104
|
+
const response = await client.exec({ query: config.query });
|
|
105
|
+
const executionTimeMs = Date.now() - startTime;
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
result: {
|
|
109
|
+
rowCount: response.rowCount,
|
|
110
|
+
executionTimeMs,
|
|
111
|
+
success: !response.error,
|
|
112
|
+
},
|
|
113
|
+
error: response.error,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
aggregateResult(
|
|
118
|
+
runs: HealthCheckRunForAggregation<QueryResult>[]
|
|
119
|
+
): QueryAggregatedResult {
|
|
120
|
+
const times = runs
|
|
121
|
+
.map((r) => r.metadata?.executionTimeMs)
|
|
122
|
+
.filter((v): v is number => typeof v === "number");
|
|
123
|
+
|
|
124
|
+
const successes = runs
|
|
125
|
+
.map((r) => r.metadata?.success)
|
|
126
|
+
.filter((v): v is boolean => typeof v === "boolean");
|
|
127
|
+
|
|
128
|
+
const successCount = successes.filter(Boolean).length;
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
avgExecutionTimeMs:
|
|
132
|
+
times.length > 0
|
|
133
|
+
? Math.round(times.reduce((a, b) => a + b, 0) / times.length)
|
|
134
|
+
: 0,
|
|
135
|
+
successRate:
|
|
136
|
+
successes.length > 0
|
|
137
|
+
? Math.round((successCount / successes.length) * 100)
|
|
138
|
+
: 0,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
package/src/strategy.test.ts
CHANGED
|
@@ -24,11 +24,11 @@ describe("MysqlHealthCheckStrategy", () => {
|
|
|
24
24
|
),
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
-
describe("
|
|
28
|
-
it("should return
|
|
27
|
+
describe("createClient", () => {
|
|
28
|
+
it("should return a connected client for successful connection", async () => {
|
|
29
29
|
const strategy = new MysqlHealthCheckStrategy(createMockClient());
|
|
30
30
|
|
|
31
|
-
const
|
|
31
|
+
const connectedClient = await strategy.createClient({
|
|
32
32
|
host: "localhost",
|
|
33
33
|
port: 3306,
|
|
34
34
|
database: "test",
|
|
@@ -37,17 +37,35 @@ describe("MysqlHealthCheckStrategy", () => {
|
|
|
37
37
|
timeout: 5000,
|
|
38
38
|
});
|
|
39
39
|
|
|
40
|
-
expect(
|
|
41
|
-
expect(
|
|
42
|
-
expect(
|
|
40
|
+
expect(connectedClient.client).toBeDefined();
|
|
41
|
+
expect(connectedClient.client.exec).toBeDefined();
|
|
42
|
+
expect(connectedClient.close).toBeDefined();
|
|
43
|
+
|
|
44
|
+
connectedClient.close();
|
|
43
45
|
});
|
|
44
46
|
|
|
45
|
-
it("should
|
|
47
|
+
it("should throw for connection error", async () => {
|
|
46
48
|
const strategy = new MysqlHealthCheckStrategy(
|
|
47
49
|
createMockClient({ connectError: new Error("Connection refused") })
|
|
48
50
|
);
|
|
49
51
|
|
|
50
|
-
|
|
52
|
+
await expect(
|
|
53
|
+
strategy.createClient({
|
|
54
|
+
host: "localhost",
|
|
55
|
+
port: 3306,
|
|
56
|
+
database: "test",
|
|
57
|
+
user: "root",
|
|
58
|
+
password: "secret",
|
|
59
|
+
timeout: 5000,
|
|
60
|
+
})
|
|
61
|
+
).rejects.toThrow("Connection refused");
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("client.exec", () => {
|
|
66
|
+
it("should execute query successfully", async () => {
|
|
67
|
+
const strategy = new MysqlHealthCheckStrategy(createMockClient());
|
|
68
|
+
const connectedClient = await strategy.createClient({
|
|
51
69
|
host: "localhost",
|
|
52
70
|
port: 3306,
|
|
53
71
|
database: "test",
|
|
@@ -56,17 +74,20 @@ describe("MysqlHealthCheckStrategy", () => {
|
|
|
56
74
|
timeout: 5000,
|
|
57
75
|
});
|
|
58
76
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
77
|
+
const result = await connectedClient.client.exec({
|
|
78
|
+
query: "SELECT 1",
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
expect(result.rowCount).toBe(1);
|
|
82
|
+
|
|
83
|
+
connectedClient.close();
|
|
62
84
|
});
|
|
63
85
|
|
|
64
|
-
it("should return
|
|
86
|
+
it("should return error for query error", async () => {
|
|
65
87
|
const strategy = new MysqlHealthCheckStrategy(
|
|
66
88
|
createMockClient({ queryError: new Error("Syntax error") })
|
|
67
89
|
);
|
|
68
|
-
|
|
69
|
-
const result = await strategy.execute({
|
|
90
|
+
const connectedClient = await strategy.createClient({
|
|
70
91
|
host: "localhost",
|
|
71
92
|
port: 3306,
|
|
72
93
|
database: "test",
|
|
@@ -75,81 +96,35 @@ describe("MysqlHealthCheckStrategy", () => {
|
|
|
75
96
|
timeout: 5000,
|
|
76
97
|
});
|
|
77
98
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it("should pass connectionTime assertion when below threshold", async () => {
|
|
83
|
-
const strategy = new MysqlHealthCheckStrategy(createMockClient());
|
|
84
|
-
|
|
85
|
-
const result = await strategy.execute({
|
|
86
|
-
host: "localhost",
|
|
87
|
-
port: 3306,
|
|
88
|
-
database: "test",
|
|
89
|
-
user: "root",
|
|
90
|
-
password: "secret",
|
|
91
|
-
timeout: 5000,
|
|
92
|
-
assertions: [
|
|
93
|
-
{ field: "connectionTime", operator: "lessThan", value: 5000 },
|
|
94
|
-
],
|
|
99
|
+
const result = await connectedClient.client.exec({
|
|
100
|
+
query: "INVALID SQL",
|
|
95
101
|
});
|
|
96
102
|
|
|
97
|
-
expect(result.
|
|
103
|
+
expect(result.error).toContain("Syntax error");
|
|
104
|
+
|
|
105
|
+
connectedClient.close();
|
|
98
106
|
});
|
|
99
107
|
|
|
100
|
-
it("should
|
|
108
|
+
it("should return custom row count", async () => {
|
|
101
109
|
const strategy = new MysqlHealthCheckStrategy(
|
|
102
110
|
createMockClient({ rowCount: 5 })
|
|
103
111
|
);
|
|
104
|
-
|
|
105
|
-
const result = await strategy.execute({
|
|
112
|
+
const connectedClient = await strategy.createClient({
|
|
106
113
|
host: "localhost",
|
|
107
114
|
port: 3306,
|
|
108
115
|
database: "test",
|
|
109
116
|
user: "root",
|
|
110
117
|
password: "secret",
|
|
111
118
|
timeout: 5000,
|
|
112
|
-
assertions: [
|
|
113
|
-
{ field: "rowCount", operator: "greaterThanOrEqual", value: 1 },
|
|
114
|
-
],
|
|
115
119
|
});
|
|
116
120
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
it("should fail rowCount assertion when no rows", async () => {
|
|
121
|
-
const strategy = new MysqlHealthCheckStrategy(
|
|
122
|
-
createMockClient({ rowCount: 0 })
|
|
123
|
-
);
|
|
124
|
-
|
|
125
|
-
const result = await strategy.execute({
|
|
126
|
-
host: "localhost",
|
|
127
|
-
port: 3306,
|
|
128
|
-
database: "test",
|
|
129
|
-
user: "root",
|
|
130
|
-
password: "secret",
|
|
131
|
-
timeout: 5000,
|
|
132
|
-
assertions: [{ field: "rowCount", operator: "greaterThan", value: 0 }],
|
|
121
|
+
const result = await connectedClient.client.exec({
|
|
122
|
+
query: "SELECT * FROM users",
|
|
133
123
|
});
|
|
134
124
|
|
|
135
|
-
expect(result.
|
|
136
|
-
expect(result.message).toContain("Assertion failed");
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
it("should pass querySuccess assertion", async () => {
|
|
140
|
-
const strategy = new MysqlHealthCheckStrategy(createMockClient());
|
|
141
|
-
|
|
142
|
-
const result = await strategy.execute({
|
|
143
|
-
host: "localhost",
|
|
144
|
-
port: 3306,
|
|
145
|
-
database: "test",
|
|
146
|
-
user: "root",
|
|
147
|
-
password: "secret",
|
|
148
|
-
timeout: 5000,
|
|
149
|
-
assertions: [{ field: "querySuccess", operator: "isTrue" }],
|
|
150
|
-
});
|
|
125
|
+
expect(result.rowCount).toBe(5);
|
|
151
126
|
|
|
152
|
-
|
|
127
|
+
connectedClient.close();
|
|
153
128
|
});
|
|
154
129
|
});
|
|
155
130
|
|
|
@@ -166,9 +141,7 @@ describe("MysqlHealthCheckStrategy", () => {
|
|
|
166
141
|
metadata: {
|
|
167
142
|
connected: true,
|
|
168
143
|
connectionTimeMs: 50,
|
|
169
|
-
queryTimeMs: 10,
|
|
170
144
|
rowCount: 1,
|
|
171
|
-
querySuccess: true,
|
|
172
145
|
},
|
|
173
146
|
},
|
|
174
147
|
{
|
|
@@ -180,9 +153,7 @@ describe("MysqlHealthCheckStrategy", () => {
|
|
|
180
153
|
metadata: {
|
|
181
154
|
connected: true,
|
|
182
155
|
connectionTimeMs: 100,
|
|
183
|
-
queryTimeMs: 20,
|
|
184
156
|
rowCount: 5,
|
|
185
|
-
querySuccess: true,
|
|
186
157
|
},
|
|
187
158
|
},
|
|
188
159
|
];
|
|
@@ -190,7 +161,6 @@ describe("MysqlHealthCheckStrategy", () => {
|
|
|
190
161
|
const aggregated = strategy.aggregateResult(runs);
|
|
191
162
|
|
|
192
163
|
expect(aggregated.avgConnectionTime).toBe(75);
|
|
193
|
-
expect(aggregated.avgQueryTime).toBe(15);
|
|
194
164
|
expect(aggregated.successRate).toBe(100);
|
|
195
165
|
expect(aggregated.errorCount).toBe(0);
|
|
196
166
|
});
|
|
@@ -207,7 +177,6 @@ describe("MysqlHealthCheckStrategy", () => {
|
|
|
207
177
|
metadata: {
|
|
208
178
|
connected: false,
|
|
209
179
|
connectionTimeMs: 100,
|
|
210
|
-
querySuccess: false,
|
|
211
180
|
error: "Connection refused",
|
|
212
181
|
},
|
|
213
182
|
},
|
package/src/strategy.ts
CHANGED
|
@@ -1,39 +1,28 @@
|
|
|
1
1
|
import mysql from "mysql2/promise";
|
|
2
2
|
import {
|
|
3
3
|
HealthCheckStrategy,
|
|
4
|
-
HealthCheckResult,
|
|
5
4
|
HealthCheckRunForAggregation,
|
|
6
5
|
Versioned,
|
|
7
6
|
z,
|
|
8
|
-
timeThresholdField,
|
|
9
|
-
numericField,
|
|
10
|
-
booleanField,
|
|
11
|
-
evaluateAssertions,
|
|
12
7
|
configString,
|
|
13
8
|
configNumber,
|
|
9
|
+
type ConnectedClient,
|
|
14
10
|
} from "@checkstack/backend-api";
|
|
15
11
|
import {
|
|
16
12
|
healthResultBoolean,
|
|
17
13
|
healthResultNumber,
|
|
18
14
|
healthResultString,
|
|
19
15
|
} from "@checkstack/healthcheck-common";
|
|
16
|
+
import type {
|
|
17
|
+
MysqlTransportClient,
|
|
18
|
+
SqlQueryRequest,
|
|
19
|
+
SqlQueryResult,
|
|
20
|
+
} from "./transport-client";
|
|
20
21
|
|
|
21
22
|
// ============================================================================
|
|
22
23
|
// SCHEMAS
|
|
23
24
|
// ============================================================================
|
|
24
25
|
|
|
25
|
-
/**
|
|
26
|
-
* Assertion schema for MySQL health checks using shared factories.
|
|
27
|
-
*/
|
|
28
|
-
const mysqlAssertionSchema = z.discriminatedUnion("field", [
|
|
29
|
-
timeThresholdField("connectionTime"),
|
|
30
|
-
timeThresholdField("queryTime"),
|
|
31
|
-
numericField("rowCount", { min: 0 }),
|
|
32
|
-
booleanField("querySuccess"),
|
|
33
|
-
]);
|
|
34
|
-
|
|
35
|
-
export type MysqlAssertion = z.infer<typeof mysqlAssertionSchema>;
|
|
36
|
-
|
|
37
26
|
/**
|
|
38
27
|
* Configuration schema for MySQL health checks.
|
|
39
28
|
*/
|
|
@@ -46,21 +35,12 @@ export const mysqlConfigSchema = z.object({
|
|
|
46
35
|
.default(3306)
|
|
47
36
|
.describe("MySQL port"),
|
|
48
37
|
database: configString({}).describe("Database name"),
|
|
49
|
-
user: configString({}).describe("
|
|
50
|
-
password: configString({ "x-secret": true }).describe(
|
|
51
|
-
"Password for authentication"
|
|
52
|
-
),
|
|
38
|
+
user: configString({}).describe("Database user"),
|
|
39
|
+
password: configString({ "x-secret": true }).describe("Database password"),
|
|
53
40
|
timeout: configNumber({})
|
|
54
41
|
.min(100)
|
|
55
42
|
.default(10_000)
|
|
56
43
|
.describe("Connection timeout in milliseconds"),
|
|
57
|
-
query: configString({})
|
|
58
|
-
.default("SELECT 1")
|
|
59
|
-
.describe("Health check query to execute"),
|
|
60
|
-
assertions: z
|
|
61
|
-
.array(mysqlAssertionSchema)
|
|
62
|
-
.optional()
|
|
63
|
-
.describe("Validation conditions"),
|
|
64
44
|
});
|
|
65
45
|
|
|
66
46
|
export type MysqlConfig = z.infer<typeof mysqlConfigSchema>;
|
|
@@ -79,27 +59,13 @@ const mysqlResultSchema = z.object({
|
|
|
79
59
|
"x-chart-label": "Connection Time",
|
|
80
60
|
"x-chart-unit": "ms",
|
|
81
61
|
}),
|
|
82
|
-
queryTimeMs: healthResultNumber({
|
|
83
|
-
"x-chart-type": "line",
|
|
84
|
-
"x-chart-label": "Query Time",
|
|
85
|
-
"x-chart-unit": "ms",
|
|
86
|
-
}).optional(),
|
|
87
|
-
rowCount: healthResultNumber({
|
|
88
|
-
"x-chart-type": "counter",
|
|
89
|
-
"x-chart-label": "Row Count",
|
|
90
|
-
}).optional(),
|
|
91
|
-
querySuccess: healthResultBoolean({
|
|
92
|
-
"x-chart-type": "boolean",
|
|
93
|
-
"x-chart-label": "Query Success",
|
|
94
|
-
}),
|
|
95
|
-
failedAssertion: mysqlAssertionSchema.optional(),
|
|
96
62
|
error: healthResultString({
|
|
97
63
|
"x-chart-type": "status",
|
|
98
64
|
"x-chart-label": "Error",
|
|
99
65
|
}).optional(),
|
|
100
66
|
});
|
|
101
67
|
|
|
102
|
-
|
|
68
|
+
type MysqlResult = z.infer<typeof mysqlResultSchema>;
|
|
103
69
|
|
|
104
70
|
/**
|
|
105
71
|
* Aggregated metadata for buckets.
|
|
@@ -110,9 +76,9 @@ const mysqlAggregatedSchema = z.object({
|
|
|
110
76
|
"x-chart-label": "Avg Connection Time",
|
|
111
77
|
"x-chart-unit": "ms",
|
|
112
78
|
}),
|
|
113
|
-
|
|
79
|
+
maxConnectionTime: healthResultNumber({
|
|
114
80
|
"x-chart-type": "line",
|
|
115
|
-
"x-chart-label": "
|
|
81
|
+
"x-chart-label": "Max Connection Time",
|
|
116
82
|
"x-chart-unit": "ms",
|
|
117
83
|
}),
|
|
118
84
|
successRate: healthResultNumber({
|
|
@@ -126,17 +92,17 @@ const mysqlAggregatedSchema = z.object({
|
|
|
126
92
|
}),
|
|
127
93
|
});
|
|
128
94
|
|
|
129
|
-
|
|
95
|
+
type MysqlAggregatedResult = z.infer<typeof mysqlAggregatedSchema>;
|
|
130
96
|
|
|
131
97
|
// ============================================================================
|
|
132
98
|
// DATABASE CLIENT INTERFACE (for testability)
|
|
133
99
|
// ============================================================================
|
|
134
100
|
|
|
135
|
-
|
|
101
|
+
interface DbQueryResult {
|
|
136
102
|
rowCount: number;
|
|
137
103
|
}
|
|
138
104
|
|
|
139
|
-
|
|
105
|
+
interface DbConnection {
|
|
140
106
|
query(sql: string): Promise<DbQueryResult>;
|
|
141
107
|
end(): Promise<void>;
|
|
142
108
|
}
|
|
@@ -166,9 +132,8 @@ const defaultDbClient: DbClient = {
|
|
|
166
132
|
|
|
167
133
|
return {
|
|
168
134
|
async query(sql: string): Promise<DbQueryResult> {
|
|
169
|
-
const [rows] = await connection.
|
|
170
|
-
|
|
171
|
-
return { rowCount };
|
|
135
|
+
const [rows] = await connection.execute(sql);
|
|
136
|
+
return { rowCount: Array.isArray(rows) ? rows.length : 0 };
|
|
172
137
|
},
|
|
173
138
|
async end() {
|
|
174
139
|
await connection.end();
|
|
@@ -183,7 +148,12 @@ const defaultDbClient: DbClient = {
|
|
|
183
148
|
|
|
184
149
|
export class MysqlHealthCheckStrategy
|
|
185
150
|
implements
|
|
186
|
-
HealthCheckStrategy<
|
|
151
|
+
HealthCheckStrategy<
|
|
152
|
+
MysqlConfig,
|
|
153
|
+
MysqlTransportClient,
|
|
154
|
+
MysqlResult,
|
|
155
|
+
MysqlAggregatedResult
|
|
156
|
+
>
|
|
187
157
|
{
|
|
188
158
|
id = "mysql";
|
|
189
159
|
displayName = "MySQL Health Check";
|
|
@@ -196,13 +166,29 @@ export class MysqlHealthCheckStrategy
|
|
|
196
166
|
}
|
|
197
167
|
|
|
198
168
|
config: Versioned<MysqlConfig> = new Versioned({
|
|
199
|
-
version:
|
|
169
|
+
version: 2,
|
|
200
170
|
schema: mysqlConfigSchema,
|
|
171
|
+
migrations: [
|
|
172
|
+
{
|
|
173
|
+
fromVersion: 1,
|
|
174
|
+
toVersion: 2,
|
|
175
|
+
description: "Migrate to createClient pattern (no config changes)",
|
|
176
|
+
migrate: (data: unknown) => data,
|
|
177
|
+
},
|
|
178
|
+
],
|
|
201
179
|
});
|
|
202
180
|
|
|
203
181
|
result: Versioned<MysqlResult> = new Versioned({
|
|
204
|
-
version:
|
|
182
|
+
version: 2,
|
|
205
183
|
schema: mysqlResultSchema,
|
|
184
|
+
migrations: [
|
|
185
|
+
{
|
|
186
|
+
fromVersion: 1,
|
|
187
|
+
toVersion: 2,
|
|
188
|
+
description: "Migrate to createClient pattern (no result changes)",
|
|
189
|
+
migrate: (data: unknown) => data,
|
|
190
|
+
},
|
|
191
|
+
],
|
|
206
192
|
});
|
|
207
193
|
|
|
208
194
|
aggregatedResult: Versioned<MysqlAggregatedResult> = new Versioned({
|
|
@@ -213,132 +199,83 @@ export class MysqlHealthCheckStrategy
|
|
|
213
199
|
aggregateResult(
|
|
214
200
|
runs: HealthCheckRunForAggregation<MysqlResult>[]
|
|
215
201
|
): MysqlAggregatedResult {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
errorCount++;
|
|
226
|
-
continue;
|
|
227
|
-
}
|
|
228
|
-
if (run.status === "healthy") {
|
|
229
|
-
successCount++;
|
|
230
|
-
}
|
|
231
|
-
if (run.metadata) {
|
|
232
|
-
totalConnectionTime += run.metadata.connectionTimeMs;
|
|
233
|
-
if (run.metadata.queryTimeMs !== undefined) {
|
|
234
|
-
totalQueryTime += run.metadata.queryTimeMs;
|
|
235
|
-
queryRuns++;
|
|
236
|
-
}
|
|
237
|
-
validRuns++;
|
|
238
|
-
}
|
|
202
|
+
const validRuns = runs.filter((r) => r.metadata);
|
|
203
|
+
|
|
204
|
+
if (validRuns.length === 0) {
|
|
205
|
+
return {
|
|
206
|
+
avgConnectionTime: 0,
|
|
207
|
+
maxConnectionTime: 0,
|
|
208
|
+
successRate: 0,
|
|
209
|
+
errorCount: 0,
|
|
210
|
+
};
|
|
239
211
|
}
|
|
240
212
|
|
|
213
|
+
const connectionTimes = validRuns
|
|
214
|
+
.map((r) => r.metadata?.connectionTimeMs)
|
|
215
|
+
.filter((t): t is number => typeof t === "number");
|
|
216
|
+
|
|
217
|
+
const avgConnectionTime =
|
|
218
|
+
connectionTimes.length > 0
|
|
219
|
+
? Math.round(
|
|
220
|
+
connectionTimes.reduce((a, b) => a + b, 0) / connectionTimes.length
|
|
221
|
+
)
|
|
222
|
+
: 0;
|
|
223
|
+
|
|
224
|
+
const maxConnectionTime =
|
|
225
|
+
connectionTimes.length > 0 ? Math.max(...connectionTimes) : 0;
|
|
226
|
+
|
|
227
|
+
const successCount = validRuns.filter(
|
|
228
|
+
(r) => r.metadata?.connected === true
|
|
229
|
+
).length;
|
|
230
|
+
const successRate = Math.round((successCount / validRuns.length) * 100);
|
|
231
|
+
|
|
232
|
+
const errorCount = validRuns.filter(
|
|
233
|
+
(r) => r.metadata?.error !== undefined
|
|
234
|
+
).length;
|
|
235
|
+
|
|
241
236
|
return {
|
|
242
|
-
avgConnectionTime
|
|
243
|
-
|
|
244
|
-
successRate
|
|
237
|
+
avgConnectionTime,
|
|
238
|
+
maxConnectionTime,
|
|
239
|
+
successRate,
|
|
245
240
|
errorCount,
|
|
246
241
|
};
|
|
247
242
|
}
|
|
248
243
|
|
|
249
|
-
async
|
|
244
|
+
async createClient(
|
|
250
245
|
config: MysqlConfigInput
|
|
251
|
-
): Promise<
|
|
246
|
+
): Promise<ConnectedClient<MysqlTransportClient>> {
|
|
252
247
|
const validatedConfig = this.config.validate(config);
|
|
253
|
-
const start = performance.now();
|
|
254
|
-
|
|
255
|
-
try {
|
|
256
|
-
// Connect to database
|
|
257
|
-
const connection = await this.dbClient.connect({
|
|
258
|
-
host: validatedConfig.host,
|
|
259
|
-
port: validatedConfig.port,
|
|
260
|
-
database: validatedConfig.database,
|
|
261
|
-
user: validatedConfig.user,
|
|
262
|
-
password: validatedConfig.password,
|
|
263
|
-
connectTimeout: validatedConfig.timeout,
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
const connectionTimeMs = Math.round(performance.now() - start);
|
|
267
|
-
|
|
268
|
-
// Execute health check query
|
|
269
|
-
const queryStart = performance.now();
|
|
270
|
-
let querySuccess = false;
|
|
271
|
-
let rowCount: number | undefined;
|
|
272
|
-
let queryTimeMs: number | undefined;
|
|
273
|
-
|
|
274
|
-
try {
|
|
275
|
-
const result = await connection.query(validatedConfig.query);
|
|
276
|
-
querySuccess = true;
|
|
277
|
-
rowCount = result.rowCount;
|
|
278
|
-
queryTimeMs = Math.round(performance.now() - queryStart);
|
|
279
|
-
} catch {
|
|
280
|
-
querySuccess = false;
|
|
281
|
-
queryTimeMs = Math.round(performance.now() - queryStart);
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
await connection.end();
|
|
285
|
-
|
|
286
|
-
const result: Omit<MysqlResult, "failedAssertion" | "error"> = {
|
|
287
|
-
connected: true,
|
|
288
|
-
connectionTimeMs,
|
|
289
|
-
queryTimeMs,
|
|
290
|
-
rowCount,
|
|
291
|
-
querySuccess,
|
|
292
|
-
};
|
|
293
248
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
if (failedAssertion) {
|
|
303
|
-
return {
|
|
304
|
-
status: "unhealthy",
|
|
305
|
-
latencyMs: connectionTimeMs + (queryTimeMs ?? 0),
|
|
306
|
-
message: `Assertion failed: ${failedAssertion.field} ${
|
|
307
|
-
failedAssertion.operator
|
|
308
|
-
}${"value" in failedAssertion ? ` ${failedAssertion.value}` : ""}`,
|
|
309
|
-
metadata: { ...result, failedAssertion },
|
|
310
|
-
};
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
if (!querySuccess) {
|
|
314
|
-
return {
|
|
315
|
-
status: "unhealthy",
|
|
316
|
-
latencyMs: connectionTimeMs + (queryTimeMs ?? 0),
|
|
317
|
-
message: "Health check query failed",
|
|
318
|
-
metadata: result,
|
|
319
|
-
};
|
|
320
|
-
}
|
|
249
|
+
const connection = await this.dbClient.connect({
|
|
250
|
+
host: validatedConfig.host,
|
|
251
|
+
port: validatedConfig.port,
|
|
252
|
+
database: validatedConfig.database,
|
|
253
|
+
user: validatedConfig.user,
|
|
254
|
+
password: validatedConfig.password,
|
|
255
|
+
connectTimeout: validatedConfig.timeout,
|
|
256
|
+
});
|
|
321
257
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
258
|
+
const client: MysqlTransportClient = {
|
|
259
|
+
async exec(request: SqlQueryRequest): Promise<SqlQueryResult> {
|
|
260
|
+
try {
|
|
261
|
+
const result = await connection.query(request.query);
|
|
262
|
+
return { rowCount: result.rowCount };
|
|
263
|
+
} catch (error) {
|
|
264
|
+
return {
|
|
265
|
+
rowCount: 0,
|
|
266
|
+
error: error instanceof Error ? error.message : String(error),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
client,
|
|
274
|
+
close: () => {
|
|
275
|
+
connection.end().catch(() => {
|
|
276
|
+
// Ignore close errors
|
|
277
|
+
});
|
|
278
|
+
},
|
|
279
|
+
};
|
|
343
280
|
}
|
|
344
281
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { TransportClient } from "@checkstack/common";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SQL query request.
|
|
5
|
+
*/
|
|
6
|
+
export interface SqlQueryRequest {
|
|
7
|
+
query: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* SQL query result.
|
|
12
|
+
*/
|
|
13
|
+
export interface SqlQueryResult {
|
|
14
|
+
rowCount: number;
|
|
15
|
+
error?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* MySQL transport client for query execution.
|
|
20
|
+
*/
|
|
21
|
+
export type MysqlTransportClient = TransportClient<
|
|
22
|
+
SqlQueryRequest,
|
|
23
|
+
SqlQueryResult
|
|
24
|
+
>;
|