@checkstack/healthcheck-http-backend 0.0.3 → 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 +57 -0
- package/package.json +1 -1
- package/src/index.ts +7 -5
- package/src/request-collector.test.ts +212 -0
- package/src/request-collector.ts +186 -0
- package/src/strategy.test.ts +182 -324
- package/src/strategy.ts +106 -401
- package/src/transport-client.ts +29 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,62 @@
|
|
|
1
1
|
# @checkstack/healthcheck-http-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
|
+
- f5b1f49: Added JSONPath assertions for response body validation and fully qualified strategy IDs.
|
|
32
|
+
|
|
33
|
+
**JSONPath Assertions:**
|
|
34
|
+
|
|
35
|
+
- Added `healthResultJSONPath()` factory in healthcheck-common for fields supporting JSONPath queries
|
|
36
|
+
- Extended AssertionBuilder with jsonpath field type showing path input (e.g., `$.data.status`)
|
|
37
|
+
- Added `jsonPath` field to `CollectorAssertionSchema` for persistence
|
|
38
|
+
- HTTP Request collector body field now supports JSONPath assertions
|
|
39
|
+
|
|
40
|
+
**Fully Qualified Strategy IDs:**
|
|
41
|
+
|
|
42
|
+
- HealthCheckRegistry now uses scoped factories like CollectorRegistry
|
|
43
|
+
- Strategies are stored with `pluginId.strategyId` format
|
|
44
|
+
- Added `getStrategiesWithMeta()` method to HealthCheckRegistry interface
|
|
45
|
+
- Router returns qualified IDs so frontend can correctly fetch collectors
|
|
46
|
+
|
|
47
|
+
**UI Improvements:**
|
|
48
|
+
|
|
49
|
+
- Save button disabled when collector configs have invalid required fields
|
|
50
|
+
- Fixed nested button warning in CollectorList accordion
|
|
51
|
+
|
|
52
|
+
- Updated dependencies [f5b1f49]
|
|
53
|
+
- Updated dependencies [f5b1f49]
|
|
54
|
+
- Updated dependencies [f5b1f49]
|
|
55
|
+
- Updated dependencies [f5b1f49]
|
|
56
|
+
- @checkstack/backend-api@0.1.0
|
|
57
|
+
- @checkstack/healthcheck-common@0.1.0
|
|
58
|
+
- @checkstack/common@0.0.3
|
|
59
|
+
|
|
3
60
|
## 0.0.3
|
|
4
61
|
|
|
5
62
|
### 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 { HttpHealthCheckStrategy } from "./strategy";
|
|
6
3
|
import { pluginMetadata } from "./plugin-metadata";
|
|
4
|
+
import { RequestCollector } from "./request-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 HTTP Health Check Strategy...");
|
|
18
17
|
const strategy = new HttpHealthCheckStrategy();
|
|
19
18
|
healthCheckRegistry.register(strategy);
|
|
19
|
+
collectorRegistry.register(new RequestCollector());
|
|
20
20
|
},
|
|
21
21
|
});
|
|
22
22
|
},
|
|
23
23
|
});
|
|
24
|
+
|
|
25
|
+
export { pluginMetadata } from "./plugin-metadata";
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { describe, expect, it, mock } from "bun:test";
|
|
2
|
+
import { RequestCollector, type RequestConfig } from "./request-collector";
|
|
3
|
+
import type { HttpTransportClient } from "./transport-client";
|
|
4
|
+
|
|
5
|
+
describe("RequestCollector", () => {
|
|
6
|
+
const createMockClient = (
|
|
7
|
+
response: {
|
|
8
|
+
statusCode?: number;
|
|
9
|
+
statusText?: string;
|
|
10
|
+
body?: string;
|
|
11
|
+
} = {}
|
|
12
|
+
): HttpTransportClient => ({
|
|
13
|
+
exec: mock(() =>
|
|
14
|
+
Promise.resolve({
|
|
15
|
+
statusCode: response.statusCode ?? 200,
|
|
16
|
+
statusText: response.statusText ?? "OK",
|
|
17
|
+
headers: {},
|
|
18
|
+
body: response.body ?? "",
|
|
19
|
+
})
|
|
20
|
+
),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("execute", () => {
|
|
24
|
+
it("should execute HTTP request successfully", async () => {
|
|
25
|
+
const collector = new RequestCollector();
|
|
26
|
+
const client = createMockClient({
|
|
27
|
+
statusCode: 200,
|
|
28
|
+
statusText: "OK",
|
|
29
|
+
body: "Hello World",
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const result = await collector.execute({
|
|
33
|
+
config: { url: "https://example.com", method: "GET", timeout: 5000 },
|
|
34
|
+
client,
|
|
35
|
+
pluginId: "test",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
expect(result.result.statusCode).toBe(200);
|
|
39
|
+
expect(result.result.statusText).toBe("OK");
|
|
40
|
+
expect(result.result.success).toBe(true);
|
|
41
|
+
expect(result.result.bodyLength).toBe(11);
|
|
42
|
+
expect(result.result.responseTimeMs).toBeGreaterThanOrEqual(0);
|
|
43
|
+
expect(result.error).toBeUndefined();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("should return error for failed requests", async () => {
|
|
47
|
+
const collector = new RequestCollector();
|
|
48
|
+
const client = createMockClient({
|
|
49
|
+
statusCode: 500,
|
|
50
|
+
statusText: "Internal Server Error",
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const result = await collector.execute({
|
|
54
|
+
config: {
|
|
55
|
+
url: "https://example.com/error",
|
|
56
|
+
method: "GET",
|
|
57
|
+
timeout: 5000,
|
|
58
|
+
},
|
|
59
|
+
client,
|
|
60
|
+
pluginId: "test",
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(result.result.statusCode).toBe(500);
|
|
64
|
+
expect(result.result.success).toBe(false);
|
|
65
|
+
expect(result.error).toContain("500");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("should convert headers array to record", async () => {
|
|
69
|
+
const collector = new RequestCollector();
|
|
70
|
+
const client = createMockClient();
|
|
71
|
+
|
|
72
|
+
await collector.execute({
|
|
73
|
+
config: {
|
|
74
|
+
url: "https://example.com",
|
|
75
|
+
method: "POST",
|
|
76
|
+
timeout: 5000,
|
|
77
|
+
headers: [
|
|
78
|
+
{ name: "Content-Type", value: "application/json" },
|
|
79
|
+
{ name: "Authorization", value: "Bearer token" },
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
client,
|
|
83
|
+
pluginId: "test",
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(client.exec).toHaveBeenCalledWith(
|
|
87
|
+
expect.objectContaining({
|
|
88
|
+
headers: {
|
|
89
|
+
"Content-Type": "application/json",
|
|
90
|
+
Authorization: "Bearer token",
|
|
91
|
+
},
|
|
92
|
+
})
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("should pass body to client", async () => {
|
|
97
|
+
const collector = new RequestCollector();
|
|
98
|
+
const client = createMockClient();
|
|
99
|
+
|
|
100
|
+
await collector.execute({
|
|
101
|
+
config: {
|
|
102
|
+
url: "https://example.com",
|
|
103
|
+
method: "POST",
|
|
104
|
+
timeout: 5000,
|
|
105
|
+
body: '{"key":"value"}',
|
|
106
|
+
},
|
|
107
|
+
client,
|
|
108
|
+
pluginId: "test",
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(client.exec).toHaveBeenCalledWith(
|
|
112
|
+
expect.objectContaining({
|
|
113
|
+
body: '{"key":"value"}',
|
|
114
|
+
})
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("aggregateResult", () => {
|
|
120
|
+
it("should calculate average response time", () => {
|
|
121
|
+
const collector = new RequestCollector();
|
|
122
|
+
const runs = [
|
|
123
|
+
{
|
|
124
|
+
id: "1",
|
|
125
|
+
status: "healthy" as const,
|
|
126
|
+
latencyMs: 100,
|
|
127
|
+
checkId: "c1",
|
|
128
|
+
timestamp: new Date(),
|
|
129
|
+
metadata: {
|
|
130
|
+
statusCode: 200,
|
|
131
|
+
statusText: "OK",
|
|
132
|
+
responseTimeMs: 50,
|
|
133
|
+
body: "",
|
|
134
|
+
bodyLength: 100,
|
|
135
|
+
success: true,
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
id: "2",
|
|
140
|
+
status: "healthy" as const,
|
|
141
|
+
latencyMs: 150,
|
|
142
|
+
checkId: "c1",
|
|
143
|
+
timestamp: new Date(),
|
|
144
|
+
metadata: {
|
|
145
|
+
statusCode: 200,
|
|
146
|
+
statusText: "OK",
|
|
147
|
+
responseTimeMs: 100,
|
|
148
|
+
body: "",
|
|
149
|
+
bodyLength: 200,
|
|
150
|
+
success: true,
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
const aggregated = collector.aggregateResult(runs);
|
|
156
|
+
|
|
157
|
+
expect(aggregated.avgResponseTimeMs).toBe(75);
|
|
158
|
+
expect(aggregated.successRate).toBe(100);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("should calculate success rate correctly", () => {
|
|
162
|
+
const collector = new RequestCollector();
|
|
163
|
+
const runs = [
|
|
164
|
+
{
|
|
165
|
+
id: "1",
|
|
166
|
+
status: "healthy" as const,
|
|
167
|
+
latencyMs: 100,
|
|
168
|
+
checkId: "c1",
|
|
169
|
+
timestamp: new Date(),
|
|
170
|
+
metadata: {
|
|
171
|
+
statusCode: 200,
|
|
172
|
+
statusText: "OK",
|
|
173
|
+
responseTimeMs: 50,
|
|
174
|
+
body: "",
|
|
175
|
+
bodyLength: 100,
|
|
176
|
+
success: true,
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
id: "2",
|
|
181
|
+
status: "unhealthy" as const,
|
|
182
|
+
latencyMs: 150,
|
|
183
|
+
checkId: "c1",
|
|
184
|
+
timestamp: new Date(),
|
|
185
|
+
metadata: {
|
|
186
|
+
statusCode: 500,
|
|
187
|
+
statusText: "Error",
|
|
188
|
+
responseTimeMs: 100,
|
|
189
|
+
body: "",
|
|
190
|
+
bodyLength: 0,
|
|
191
|
+
success: false,
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
];
|
|
195
|
+
|
|
196
|
+
const aggregated = collector.aggregateResult(runs);
|
|
197
|
+
|
|
198
|
+
expect(aggregated.successRate).toBe(50);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe("metadata", () => {
|
|
203
|
+
it("should have correct static properties", () => {
|
|
204
|
+
const collector = new RequestCollector();
|
|
205
|
+
|
|
206
|
+
expect(collector.id).toBe("request");
|
|
207
|
+
expect(collector.displayName).toBe("HTTP Request");
|
|
208
|
+
expect(collector.allowMultiple).toBe(true);
|
|
209
|
+
expect(collector.supportedPlugins).toHaveLength(1);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
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
|
+
healthResultJSONPath,
|
|
13
|
+
} from "@checkstack/healthcheck-common";
|
|
14
|
+
import { pluginMetadata } from "./plugin-metadata";
|
|
15
|
+
import type { HttpTransportClient } from "./transport-client";
|
|
16
|
+
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// CONFIGURATION SCHEMA
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
const requestConfigSchema = z.object({
|
|
22
|
+
url: z.string().url().describe("Full URL to request"),
|
|
23
|
+
method: z
|
|
24
|
+
.enum(["GET", "POST", "PUT", "DELETE", "HEAD"])
|
|
25
|
+
.default("GET")
|
|
26
|
+
.describe("HTTP method"),
|
|
27
|
+
headers: z
|
|
28
|
+
.array(z.object({ name: z.string(), value: z.string() }))
|
|
29
|
+
.optional()
|
|
30
|
+
.describe("Request headers"),
|
|
31
|
+
body: z.string().optional().describe("Request body"),
|
|
32
|
+
timeout: z
|
|
33
|
+
.number()
|
|
34
|
+
.min(100)
|
|
35
|
+
.default(30_000)
|
|
36
|
+
.describe("Timeout in milliseconds"),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export type RequestConfig = z.infer<typeof requestConfigSchema>;
|
|
40
|
+
|
|
41
|
+
// ============================================================================
|
|
42
|
+
// RESULT SCHEMAS
|
|
43
|
+
// ============================================================================
|
|
44
|
+
|
|
45
|
+
const requestResultSchema = z.object({
|
|
46
|
+
statusCode: healthResultNumber({
|
|
47
|
+
"x-chart-type": "counter",
|
|
48
|
+
"x-chart-label": "Status Code",
|
|
49
|
+
}),
|
|
50
|
+
statusText: healthResultString({
|
|
51
|
+
"x-chart-type": "text",
|
|
52
|
+
"x-chart-label": "Status",
|
|
53
|
+
}),
|
|
54
|
+
responseTimeMs: healthResultNumber({
|
|
55
|
+
"x-chart-type": "line",
|
|
56
|
+
"x-chart-label": "Response Time",
|
|
57
|
+
"x-chart-unit": "ms",
|
|
58
|
+
}),
|
|
59
|
+
body: healthResultJSONPath({}),
|
|
60
|
+
bodyLength: healthResultNumber({
|
|
61
|
+
"x-chart-type": "counter",
|
|
62
|
+
"x-chart-label": "Body Length",
|
|
63
|
+
"x-chart-unit": "bytes",
|
|
64
|
+
}),
|
|
65
|
+
success: healthResultBoolean({
|
|
66
|
+
"x-chart-type": "boolean",
|
|
67
|
+
"x-chart-label": "Success",
|
|
68
|
+
}),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
export type RequestResult = z.infer<typeof requestResultSchema>;
|
|
72
|
+
|
|
73
|
+
const requestAggregatedSchema = z.object({
|
|
74
|
+
avgResponseTimeMs: healthResultNumber({
|
|
75
|
+
"x-chart-type": "line",
|
|
76
|
+
"x-chart-label": "Avg Response Time",
|
|
77
|
+
"x-chart-unit": "ms",
|
|
78
|
+
}),
|
|
79
|
+
successRate: healthResultNumber({
|
|
80
|
+
"x-chart-type": "gauge",
|
|
81
|
+
"x-chart-label": "Success Rate",
|
|
82
|
+
"x-chart-unit": "%",
|
|
83
|
+
}),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
export type RequestAggregatedResult = z.infer<typeof requestAggregatedSchema>;
|
|
87
|
+
|
|
88
|
+
// ============================================================================
|
|
89
|
+
// REQUEST COLLECTOR
|
|
90
|
+
// ============================================================================
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Built-in HTTP request collector.
|
|
94
|
+
* Allows users to make HTTP requests and check responses.
|
|
95
|
+
*/
|
|
96
|
+
export class RequestCollector
|
|
97
|
+
implements
|
|
98
|
+
CollectorStrategy<
|
|
99
|
+
HttpTransportClient,
|
|
100
|
+
RequestConfig,
|
|
101
|
+
RequestResult,
|
|
102
|
+
RequestAggregatedResult
|
|
103
|
+
>
|
|
104
|
+
{
|
|
105
|
+
id = "request";
|
|
106
|
+
displayName = "HTTP Request";
|
|
107
|
+
description = "Make an HTTP request and check the response";
|
|
108
|
+
|
|
109
|
+
supportedPlugins = [pluginMetadata];
|
|
110
|
+
|
|
111
|
+
allowMultiple = true;
|
|
112
|
+
|
|
113
|
+
config = new Versioned({ version: 1, schema: requestConfigSchema });
|
|
114
|
+
result = new Versioned({ version: 1, schema: requestResultSchema });
|
|
115
|
+
aggregatedResult = new Versioned({
|
|
116
|
+
version: 1,
|
|
117
|
+
schema: requestAggregatedSchema,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
async execute({
|
|
121
|
+
config,
|
|
122
|
+
client,
|
|
123
|
+
}: {
|
|
124
|
+
config: RequestConfig;
|
|
125
|
+
client: HttpTransportClient;
|
|
126
|
+
pluginId: string;
|
|
127
|
+
}): Promise<CollectorResult<RequestResult>> {
|
|
128
|
+
const startTime = Date.now();
|
|
129
|
+
|
|
130
|
+
// Convert headers array to record
|
|
131
|
+
const headers: Record<string, string> = {};
|
|
132
|
+
for (const h of config.headers ?? []) {
|
|
133
|
+
headers[h.name] = h.value;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const response = await client.exec({
|
|
137
|
+
url: config.url,
|
|
138
|
+
method: config.method,
|
|
139
|
+
headers,
|
|
140
|
+
body: config.body,
|
|
141
|
+
timeout: config.timeout,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const responseTimeMs = Date.now() - startTime;
|
|
145
|
+
const success = response.statusCode >= 200 && response.statusCode < 400;
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
result: {
|
|
149
|
+
statusCode: response.statusCode,
|
|
150
|
+
statusText: response.statusText,
|
|
151
|
+
responseTimeMs,
|
|
152
|
+
body: response.body ?? "",
|
|
153
|
+
bodyLength: response.body?.length ?? 0,
|
|
154
|
+
success,
|
|
155
|
+
},
|
|
156
|
+
error: success
|
|
157
|
+
? undefined
|
|
158
|
+
: `HTTP ${response.statusCode}: ${response.statusText}`,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
aggregateResult(
|
|
163
|
+
runs: HealthCheckRunForAggregation<RequestResult>[]
|
|
164
|
+
): RequestAggregatedResult {
|
|
165
|
+
const times = runs
|
|
166
|
+
.map((r) => r.metadata?.responseTimeMs)
|
|
167
|
+
.filter((v): v is number => typeof v === "number");
|
|
168
|
+
|
|
169
|
+
const successes = runs
|
|
170
|
+
.map((r) => r.metadata?.success)
|
|
171
|
+
.filter((v): v is boolean => typeof v === "boolean");
|
|
172
|
+
|
|
173
|
+
const successCount = successes.filter(Boolean).length;
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
avgResponseTimeMs:
|
|
177
|
+
times.length > 0
|
|
178
|
+
? Math.round(times.reduce((a, b) => a + b, 0) / times.length)
|
|
179
|
+
: 0,
|
|
180
|
+
successRate:
|
|
181
|
+
successes.length > 0
|
|
182
|
+
? Math.round((successCount / successes.length) * 100)
|
|
183
|
+
: 0,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
}
|