@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-http-backend",
3
- "version": "0.0.3",
3
+ "version": "0.1.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "scripts": {
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
+ }