@checkstack/healthcheck-script-backend 0.1.13 → 0.3.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,84 @@
1
1
  # @checkstack/healthcheck-script-backend
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 3dd1914: Migrate health check strategies to VersionedAggregated with \_type discriminator
8
+
9
+ All 13 health check strategies now use `VersionedAggregated` for their `aggregatedResult` property, enabling automatic bucket merging with 100% mathematical fidelity.
10
+
11
+ **Key changes:**
12
+
13
+ - **`_type` discriminator**: All aggregated state objects now include a required `_type` field (`"average"`, `"rate"`, `"counter"`, `"minmax"`) for reliable type detection
14
+ - The `HealthCheckStrategy` interface now requires `aggregatedResult` to be a `VersionedAggregated<AggregatedResultShape>`
15
+ - Strategy/collector `mergeResult` methods return state objects with `_type` (e.g., `{ _type: "average", _sum, _count, avg }`)
16
+ - `mergeAggregatedBucketResults`, `combineBuckets`, and `reaggregateBuckets` now require `registry` and `strategyId` parameters
17
+ - `HealthCheckService` constructor now requires both `registry` and `collectorRegistry` parameters
18
+ - Frontend `extractComputedValue` now uses `_type` discriminator for robust type detection
19
+
20
+ **Breaking Change**: State objects now require `_type`. Merge functions automatically add `_type` to output. The bucket merging functions and `HealthCheckService` now require additional required parameters.
21
+
22
+ ### Patch Changes
23
+
24
+ - Updated dependencies [3dd1914]
25
+ - @checkstack/backend-api@0.7.0
26
+
27
+ ## 0.2.0
28
+
29
+ ### Minor Changes
30
+
31
+ - f676e11: Add script execution support and migrate CodeEditor to Monaco
32
+
33
+ **Integration providers** (`@checkstack/integration-script-backend`):
34
+
35
+ - **Script** - Execute TypeScript/JavaScript with context object
36
+ - **Bash** - Execute shell scripts with environment variables ($EVENT*ID, $PAYLOAD*\*)
37
+
38
+ **Health check collectors** (`@checkstack/healthcheck-script-backend`):
39
+
40
+ - **InlineScriptCollector** - Run TypeScript directly for health checks
41
+ - **ExecuteCollector** - Bash syntax highlighting for command field
42
+
43
+ **CodeEditor migration to Monaco** (`@checkstack/ui`):
44
+
45
+ - Replaced CodeMirror with Monaco Editor (VS Code's editor)
46
+ - Full TypeScript/JavaScript IntelliSense with custom type definitions
47
+ - Added `generateTypeDefinitions()` for JSON Schema → TypeScript conversion
48
+ - Removed all CodeMirror dependencies
49
+
50
+ **Type updates** (`@checkstack/common`):
51
+
52
+ - Added `javascript`, `typescript`, and `bash` to `EditorType` union
53
+
54
+ ### Patch Changes
55
+
56
+ - 48c2080: Migrate aggregation from batch to incremental (`mergeResult`)
57
+
58
+ ### Breaking Changes (Internal)
59
+
60
+ - Replaced `aggregateResult(runs[])` with `mergeResult(existing, run)` interface across all HealthCheckStrategy and CollectorStrategy implementations
61
+
62
+ ### New Features
63
+
64
+ - Added incremental aggregation utilities in `@checkstack/backend-api`:
65
+ - `mergeCounter()` - track occurrences
66
+ - `mergeAverage()` - track sum/count, compute avg
67
+ - `mergeRate()` - track success/total, compute %
68
+ - `mergeMinMax()` - track min/max values
69
+ - Exported Zod schemas for internal state: `averageStateSchema`, `rateStateSchema`, `minMaxStateSchema`, `counterStateSchema`
70
+
71
+ ### Improvements
72
+
73
+ - Enables O(1) storage overhead by maintaining incremental aggregation state
74
+ - Prepares for real-time hourly aggregation without batch accumulation
75
+
76
+ - Updated dependencies [f676e11]
77
+ - Updated dependencies [48c2080]
78
+ - @checkstack/common@0.6.2
79
+ - @checkstack/backend-api@0.6.0
80
+ - @checkstack/healthcheck-common@0.8.2
81
+
3
82
  ## 0.1.13
4
83
 
5
84
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-script-backend",
3
- "version": "0.1.13",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "scripts": {
@@ -9,14 +9,14 @@
9
9
  "lint:code": "eslint . --max-warnings 0"
10
10
  },
11
11
  "dependencies": {
12
- "@checkstack/backend-api": "0.5.1",
13
- "@checkstack/common": "0.6.0",
14
- "@checkstack/healthcheck-common": "0.7.0"
12
+ "@checkstack/backend-api": "0.5.2",
13
+ "@checkstack/common": "0.6.1",
14
+ "@checkstack/healthcheck-common": "0.8.1"
15
15
  },
16
16
  "devDependencies": {
17
17
  "@types/bun": "^1.0.0",
18
18
  "typescript": "^5.0.0",
19
- "@checkstack/tsconfig": "0.0.2",
20
- "@checkstack/scripts": "0.1.0"
19
+ "@checkstack/tsconfig": "0.0.3",
20
+ "@checkstack/scripts": "0.1.1"
21
21
  }
22
22
  }
@@ -100,7 +100,7 @@ describe("ExecuteCollector", () => {
100
100
  });
101
101
  });
102
102
 
103
- describe("aggregateResult", () => {
103
+ describe("mergeResult", () => {
104
104
  it("should calculate average execution time and success rate", () => {
105
105
  const collector = new ExecuteCollector();
106
106
  const runs = [
@@ -136,10 +136,11 @@ describe("ExecuteCollector", () => {
136
136
  },
137
137
  ];
138
138
 
139
- const aggregated = collector.aggregateResult(runs);
139
+ let aggregated = collector.mergeResult(undefined, runs[0]);
140
+ aggregated = collector.mergeResult(aggregated, runs[1]);
140
141
 
141
- expect(aggregated.avgExecutionTimeMs).toBe(75);
142
- expect(aggregated.successRate).toBe(100);
142
+ expect(aggregated.avgExecutionTimeMs.avg).toBe(75);
143
+ expect(aggregated.successRate.rate).toBe(100);
143
144
  });
144
145
 
145
146
  it("should calculate success rate correctly", () => {
@@ -177,9 +178,10 @@ describe("ExecuteCollector", () => {
177
178
  },
178
179
  ];
179
180
 
180
- const aggregated = collector.aggregateResult(runs);
181
+ let aggregated = collector.mergeResult(undefined, runs[0]);
182
+ aggregated = collector.mergeResult(aggregated, runs[1]);
181
183
 
182
- expect(aggregated.successRate).toBe(50);
184
+ expect(aggregated.successRate.rate).toBe(50);
183
185
  });
184
186
  });
185
187
 
@@ -1,9 +1,16 @@
1
1
  import {
2
2
  Versioned,
3
3
  z,
4
+ configString,
4
5
  type HealthCheckRunForAggregation,
5
6
  type CollectorResult,
6
7
  type CollectorStrategy,
8
+ mergeAverage,
9
+ mergeRate,
10
+ VersionedAggregated,
11
+ aggregatedAverage,
12
+ aggregatedRate,
13
+ type InferAggregatedResult,
7
14
  } from "@checkstack/backend-api";
8
15
  import {
9
16
  healthResultNumber,
@@ -19,7 +26,9 @@ import type { ScriptTransportClient } from "./transport-client";
19
26
  // ============================================================================
20
27
 
21
28
  const executeConfigSchema = z.object({
22
- command: z.string().min(1).describe("Command or script path to execute"),
29
+ command: configString({
30
+ "x-editor-types": ["shell"],
31
+ }).describe("Shell command or script to execute"),
23
32
  args: z.array(z.string()).default([]).describe("Command arguments"),
24
33
  cwd: z.string().optional().describe("Working directory"),
25
34
  env: z
@@ -69,20 +78,24 @@ const executeResultSchema = healthResultSchema({
69
78
 
70
79
  export type ExecuteResult = z.infer<typeof executeResultSchema>;
71
80
 
72
- const executeAggregatedSchema = healthResultSchema({
73
- avgExecutionTimeMs: healthResultNumber({
81
+ // Aggregated result fields definition
82
+ const executeAggregatedFields = {
83
+ avgExecutionTimeMs: aggregatedAverage({
74
84
  "x-chart-type": "line",
75
85
  "x-chart-label": "Avg Execution Time",
76
86
  "x-chart-unit": "ms",
77
87
  }),
78
- successRate: healthResultNumber({
88
+ successRate: aggregatedRate({
79
89
  "x-chart-type": "gauge",
80
90
  "x-chart-label": "Success Rate",
81
91
  "x-chart-unit": "%",
82
92
  }),
83
- });
93
+ };
84
94
 
85
- export type ExecuteAggregatedResult = z.infer<typeof executeAggregatedSchema>;
95
+ // Type inferred from field definitions
96
+ export type ExecuteAggregatedResult = InferAggregatedResult<
97
+ typeof executeAggregatedFields
98
+ >;
86
99
 
87
100
  // ============================================================================
88
101
  // EXECUTE COLLECTOR
@@ -92,15 +105,12 @@ export type ExecuteAggregatedResult = z.infer<typeof executeAggregatedSchema>;
92
105
  * Built-in Script execute collector.
93
106
  * Runs commands and checks results.
94
107
  */
95
- export class ExecuteCollector
96
- implements
97
- CollectorStrategy<
98
- ScriptTransportClient,
99
- ExecuteConfig,
100
- ExecuteResult,
101
- ExecuteAggregatedResult
102
- >
103
- {
108
+ export class ExecuteCollector implements CollectorStrategy<
109
+ ScriptTransportClient,
110
+ ExecuteConfig,
111
+ ExecuteResult,
112
+ ExecuteAggregatedResult
113
+ > {
104
114
  id = "execute";
105
115
  displayName = "Execute Script";
106
116
  description = "Execute a command or script and check the result";
@@ -111,9 +121,9 @@ export class ExecuteCollector
111
121
 
112
122
  config = new Versioned({ version: 1, schema: executeConfigSchema });
113
123
  result = new Versioned({ version: 1, schema: executeResultSchema });
114
- aggregatedResult = new Versioned({
124
+ aggregatedResult = new VersionedAggregated({
115
125
  version: 1,
116
- schema: executeAggregatedSchema,
126
+ fields: executeAggregatedFields,
117
127
  });
118
128
 
119
129
  async execute({
@@ -152,28 +162,18 @@ export class ExecuteCollector
152
162
  };
153
163
  }
154
164
 
155
- aggregateResult(
156
- runs: HealthCheckRunForAggregation<ExecuteResult>[]
165
+ mergeResult(
166
+ existing: ExecuteAggregatedResult | undefined,
167
+ run: HealthCheckRunForAggregation<ExecuteResult>,
157
168
  ): ExecuteAggregatedResult {
158
- const times = runs
159
- .map((r) => r.metadata?.executionTimeMs)
160
- .filter((v): v is number => typeof v === "number");
161
-
162
- const successes = runs
163
- .map((r) => r.metadata?.success)
164
- .filter((v): v is boolean => typeof v === "boolean");
165
-
166
- const successCount = successes.filter(Boolean).length;
169
+ const metadata = run.metadata;
167
170
 
168
171
  return {
169
- avgExecutionTimeMs:
170
- times.length > 0
171
- ? Math.round(times.reduce((a, b) => a + b, 0) / times.length)
172
- : 0,
173
- successRate:
174
- successes.length > 0
175
- ? Math.round((successCount / successes.length) * 100)
176
- : 0,
172
+ avgExecutionTimeMs: mergeAverage(
173
+ existing?.avgExecutionTimeMs,
174
+ metadata?.executionTimeMs,
175
+ ),
176
+ successRate: mergeRate(existing?.successRate, metadata?.success),
177
177
  };
178
178
  }
179
179
  }
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ import { createBackendPlugin, coreServices } from "@checkstack/backend-api";
2
2
  import { ScriptHealthCheckStrategy } from "./strategy";
3
3
  import { pluginMetadata } from "./plugin-metadata";
4
4
  import { ExecuteCollector } from "./execute-collector";
5
+ import { InlineScriptCollector } from "./inline-script-collector";
5
6
 
6
7
  export default createBackendPlugin({
7
8
  metadata: pluginMetadata,
@@ -17,6 +18,7 @@ export default createBackendPlugin({
17
18
  const strategy = new ScriptHealthCheckStrategy();
18
19
  healthCheckRegistry.register(strategy);
19
20
  collectorRegistry.register(new ExecuteCollector());
21
+ collectorRegistry.register(new InlineScriptCollector());
20
22
  },
21
23
  });
22
24
  },
@@ -0,0 +1,256 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import {
3
+ InlineScriptCollector,
4
+ type InlineScriptConfig,
5
+ } from "./inline-script-collector";
6
+ import type { ScriptTransportClient } from "./transport-client";
7
+
8
+ /**
9
+ * Unit tests for the Inline Script Collector.
10
+ *
11
+ * Tests cover:
12
+ * - Basic script execution
13
+ * - Return value handling
14
+ * - Error handling
15
+ * - Timeout protection
16
+ */
17
+
18
+ // Mock client (not actually used by InlineScriptCollector, but required by interface)
19
+ const mockClient: ScriptTransportClient = {
20
+ exec: async () => ({
21
+ exitCode: 0,
22
+ stdout: "",
23
+ stderr: "",
24
+ timedOut: false,
25
+ }),
26
+ };
27
+
28
+ function createConfig(
29
+ overrides: Partial<InlineScriptConfig> = {},
30
+ ): InlineScriptConfig {
31
+ return {
32
+ script: "return { success: true };",
33
+ timeout: 5000,
34
+ ...overrides,
35
+ };
36
+ }
37
+
38
+ describe("InlineScriptCollector", () => {
39
+ const collector = new InlineScriptCollector();
40
+
41
+ // ─────────────────────────────────────────────────────────────
42
+ // Metadata
43
+ // ─────────────────────────────────────────────────────────────
44
+
45
+ describe("metadata", () => {
46
+ it("has correct basic metadata", () => {
47
+ expect(collector.id).toBe("inline-script");
48
+ expect(collector.displayName).toBe("Inline Script");
49
+ expect(collector.description).toContain("TypeScript");
50
+ });
51
+
52
+ it("allows multiple instances", () => {
53
+ expect(collector.allowMultiple).toBe(true);
54
+ });
55
+ });
56
+
57
+ // ─────────────────────────────────────────────────────────────
58
+ // Basic Execution
59
+ // ─────────────────────────────────────────────────────────────
60
+
61
+ describe("execute - basic", () => {
62
+ it("executes script returning success object", async () => {
63
+ const config = createConfig({
64
+ script: 'return { success: true, message: "All good" };',
65
+ });
66
+
67
+ const result = await collector.execute({
68
+ config,
69
+ client: mockClient,
70
+ pluginId: "script",
71
+ });
72
+
73
+ expect(result.result.success).toBe(true);
74
+ expect(result.result.message).toBe("All good");
75
+ expect(result.error).toBeUndefined();
76
+ });
77
+
78
+ it("executes script returning boolean", async () => {
79
+ const config = createConfig({
80
+ script: "return true;",
81
+ });
82
+
83
+ const result = await collector.execute({
84
+ config,
85
+ client: mockClient,
86
+ pluginId: "script",
87
+ });
88
+
89
+ expect(result.result.success).toBe(true);
90
+ });
91
+
92
+ it("executes script with no return (defaults to success)", async () => {
93
+ const config = createConfig({
94
+ script: "const x = 1 + 1;",
95
+ });
96
+
97
+ const result = await collector.execute({
98
+ config,
99
+ client: mockClient,
100
+ pluginId: "script",
101
+ });
102
+
103
+ expect(result.result.success).toBe(true);
104
+ });
105
+
106
+ it("executes async script", async () => {
107
+ const config = createConfig({
108
+ script: `
109
+ await Promise.resolve();
110
+ return { success: true, message: "async works" };
111
+ `,
112
+ });
113
+
114
+ const result = await collector.execute({
115
+ config,
116
+ client: mockClient,
117
+ pluginId: "script",
118
+ });
119
+
120
+ expect(result.result.success).toBe(true);
121
+ expect(result.result.message).toBe("async works");
122
+ });
123
+
124
+ it("captures numeric value from script", async () => {
125
+ const config = createConfig({
126
+ script: "return { success: true, value: 42 };",
127
+ });
128
+
129
+ const result = await collector.execute({
130
+ config,
131
+ client: mockClient,
132
+ pluginId: "script",
133
+ });
134
+
135
+ expect(result.result.value).toBe(42);
136
+ });
137
+ });
138
+
139
+ // ─────────────────────────────────────────────────────────────
140
+ // Failure Handling
141
+ // ─────────────────────────────────────────────────────────────
142
+
143
+ describe("execute - failures", () => {
144
+ it("handles script returning false", async () => {
145
+ const config = createConfig({
146
+ script: "return false;",
147
+ });
148
+
149
+ const result = await collector.execute({
150
+ config,
151
+ client: mockClient,
152
+ pluginId: "script",
153
+ });
154
+
155
+ expect(result.result.success).toBe(false);
156
+ expect(result.error).toBeDefined();
157
+ });
158
+
159
+ it("handles script returning success: false", async () => {
160
+ const config = createConfig({
161
+ script: 'return { success: false, message: "Check failed" };',
162
+ });
163
+
164
+ const result = await collector.execute({
165
+ config,
166
+ client: mockClient,
167
+ pluginId: "script",
168
+ });
169
+
170
+ expect(result.result.success).toBe(false);
171
+ expect(result.result.message).toBe("Check failed");
172
+ expect(result.error).toBe("Check failed");
173
+ });
174
+
175
+ it("handles script errors", async () => {
176
+ const config = createConfig({
177
+ script: 'throw new Error("Something went wrong");',
178
+ });
179
+
180
+ const result = await collector.execute({
181
+ config,
182
+ client: mockClient,
183
+ pluginId: "script",
184
+ });
185
+
186
+ expect(result.result.success).toBe(false);
187
+ expect(result.error).toContain("Something went wrong");
188
+ });
189
+
190
+ it("handles syntax errors", async () => {
191
+ const config = createConfig({
192
+ script: "const x = {",
193
+ });
194
+
195
+ const result = await collector.execute({
196
+ config,
197
+ client: mockClient,
198
+ pluginId: "script",
199
+ });
200
+
201
+ expect(result.result.success).toBe(false);
202
+ expect(result.error).toBeDefined();
203
+ });
204
+ });
205
+
206
+ // ─────────────────────────────────────────────────────────────
207
+ // Timeout
208
+ // ─────────────────────────────────────────────────────────────
209
+
210
+ describe("execute - timeout", () => {
211
+ it("times out long-running scripts", async () => {
212
+ const config = createConfig({
213
+ script: `
214
+ await new Promise(resolve => setTimeout(resolve, 5000));
215
+ return { success: true };
216
+ `,
217
+ timeout: 1000,
218
+ });
219
+
220
+ const result = await collector.execute({
221
+ config,
222
+ client: mockClient,
223
+ pluginId: "script",
224
+ });
225
+
226
+ expect(result.result.success).toBe(false);
227
+ expect(result.result.timedOut).toBe(true);
228
+ expect(result.error).toContain("timed out");
229
+ }, 10000);
230
+ });
231
+
232
+ // ─────────────────────────────────────────────────────────────
233
+ // Aggregation
234
+ // ─────────────────────────────────────────────────────────────
235
+
236
+ describe("mergeResult", () => {
237
+ it("aggregates execution time and success rate", () => {
238
+ const run1 = {
239
+ metadata: { success: true, executionTimeMs: 100, timedOut: false },
240
+ };
241
+ const run2 = {
242
+ metadata: { success: true, executionTimeMs: 200, timedOut: false },
243
+ };
244
+ const run3 = {
245
+ metadata: { success: false, executionTimeMs: 150, timedOut: false },
246
+ };
247
+
248
+ let aggregated = collector.mergeResult(undefined, run1 as never);
249
+ aggregated = collector.mergeResult(aggregated, run2 as never);
250
+ aggregated = collector.mergeResult(aggregated, run3 as never);
251
+
252
+ expect(aggregated.avgExecutionTimeMs.avg).toBe(150); // (100+200+150)/3
253
+ expect(aggregated.successRate.rate).toBeCloseTo(67, 0); // 2/3 * 100 = ~67
254
+ });
255
+ });
256
+ });
@@ -0,0 +1,348 @@
1
+ import {
2
+ Versioned,
3
+ z,
4
+ configString,
5
+ configNumber,
6
+ type HealthCheckRunForAggregation,
7
+ type CollectorResult,
8
+ type CollectorStrategy,
9
+ mergeAverage,
10
+ mergeRate,
11
+ VersionedAggregated,
12
+ aggregatedAverage,
13
+ aggregatedRate,
14
+ type InferAggregatedResult,
15
+ } from "@checkstack/backend-api";
16
+ import {
17
+ healthResultNumber,
18
+ healthResultString,
19
+ healthResultBoolean,
20
+ healthResultSchema,
21
+ } from "@checkstack/healthcheck-common";
22
+ import { pluginMetadata } from "./plugin-metadata";
23
+ import type { ScriptTransportClient } from "./transport-client";
24
+
25
+ // ============================================================================
26
+ // SCRIPT EXECUTION UTILITIES (shared with integration-script-backend pattern)
27
+ // ============================================================================
28
+
29
+ /**
30
+ * Context available to inline scripts.
31
+ */
32
+ interface ScriptContext {
33
+ /** Health check configuration */
34
+ config: Record<string, unknown>;
35
+ /** Fetch API for HTTP requests */
36
+ fetch: typeof fetch;
37
+ }
38
+
39
+ /**
40
+ * Safe console interface for scripts.
41
+ */
42
+ interface SafeConsole {
43
+ log: (...args: unknown[]) => void;
44
+ warn: (...args: unknown[]) => void;
45
+ error: (...args: unknown[]) => void;
46
+ info: (...args: unknown[]) => void;
47
+ }
48
+
49
+ /**
50
+ * Expected return type from health check scripts.
51
+ */
52
+ interface ScriptHealthResult {
53
+ /** Whether the health check passed */
54
+ success: boolean;
55
+ /** Optional message describing the result */
56
+ message?: string;
57
+ /** Optional numeric value for metrics */
58
+ value?: number;
59
+ }
60
+
61
+ /**
62
+ * Execute an inline script with the given context.
63
+ */
64
+ async function executeInlineScript({
65
+ script,
66
+ context,
67
+ safeConsole,
68
+ timeoutMs,
69
+ }: {
70
+ script: string;
71
+ context: ScriptContext;
72
+ safeConsole: SafeConsole;
73
+ timeoutMs: number;
74
+ }): Promise<{
75
+ result: ScriptHealthResult | undefined;
76
+ error?: string;
77
+ timedOut: boolean;
78
+ }> {
79
+ try {
80
+ // Create an async function from the script
81
+ const asyncFn = new Function(
82
+ "context",
83
+ "console",
84
+ "fetch",
85
+ `return (async () => { ${script} })();`,
86
+ );
87
+
88
+ // Execute with timeout
89
+ const result = await Promise.race([
90
+ asyncFn(context, safeConsole, fetch),
91
+ new Promise<never>((_, reject) =>
92
+ setTimeout(() => reject(new Error("__TIMEOUT__")), timeoutMs),
93
+ ),
94
+ ]);
95
+
96
+ // Normalize result
97
+ if (result === undefined || result === null) {
98
+ return { result: { success: true }, timedOut: false };
99
+ }
100
+
101
+ if (typeof result === "boolean") {
102
+ return { result: { success: result }, timedOut: false };
103
+ }
104
+
105
+ if (typeof result === "object") {
106
+ return {
107
+ result: {
108
+ success: Boolean(result.success ?? true),
109
+ message: result.message,
110
+ value: result.value,
111
+ },
112
+ timedOut: false,
113
+ };
114
+ }
115
+
116
+ return {
117
+ result: { success: true, message: String(result) },
118
+ timedOut: false,
119
+ };
120
+ } catch (error) {
121
+ const message = error instanceof Error ? error.message : String(error);
122
+ if (message === "__TIMEOUT__") {
123
+ return {
124
+ result: undefined,
125
+ error: "Script execution timed out",
126
+ timedOut: true,
127
+ };
128
+ }
129
+ return { result: undefined, error: message, timedOut: false };
130
+ }
131
+ }
132
+
133
+ // ============================================================================
134
+ // CONFIGURATION SCHEMA
135
+ // ============================================================================
136
+
137
+ const inlineScriptConfigSchema = z.object({
138
+ script: configString({
139
+ "x-editor-types": ["typescript"],
140
+ }).describe(
141
+ "TypeScript/JavaScript code to execute. Return { success: boolean, message?: string, value?: number }",
142
+ ),
143
+ timeout: configNumber({})
144
+ .min(1000)
145
+ .max(60_000)
146
+ .default(10_000)
147
+ .describe("Maximum execution time in milliseconds"),
148
+ });
149
+
150
+ export type InlineScriptConfig = z.infer<typeof inlineScriptConfigSchema>;
151
+
152
+ // ============================================================================
153
+ // RESULT SCHEMAS
154
+ // ============================================================================
155
+
156
+ const inlineScriptResultSchema = healthResultSchema({
157
+ success: healthResultBoolean({
158
+ "x-chart-type": "boolean",
159
+ "x-chart-label": "Success",
160
+ }),
161
+ message: healthResultString({
162
+ "x-chart-type": "text",
163
+ "x-chart-label": "Message",
164
+ }).optional(),
165
+ value: healthResultNumber({
166
+ "x-chart-type": "line",
167
+ "x-chart-label": "Value",
168
+ }).optional(),
169
+ executionTimeMs: healthResultNumber({
170
+ "x-chart-type": "line",
171
+ "x-chart-label": "Execution Time",
172
+ "x-chart-unit": "ms",
173
+ }),
174
+ timedOut: healthResultBoolean({
175
+ "x-chart-type": "boolean",
176
+ "x-chart-label": "Timed Out",
177
+ }),
178
+ });
179
+
180
+ export type InlineScriptResult = z.infer<typeof inlineScriptResultSchema>;
181
+
182
+ // Aggregated result fields definition
183
+ const inlineScriptAggregatedFields = {
184
+ avgExecutionTimeMs: aggregatedAverage({
185
+ "x-chart-type": "line",
186
+ "x-chart-label": "Avg Execution Time",
187
+ "x-chart-unit": "ms",
188
+ }),
189
+ successRate: aggregatedRate({
190
+ "x-chart-type": "gauge",
191
+ "x-chart-label": "Success Rate",
192
+ "x-chart-unit": "%",
193
+ }),
194
+ };
195
+
196
+ // Type inferred from field definitions
197
+ export type InlineScriptAggregatedResult = InferAggregatedResult<
198
+ typeof inlineScriptAggregatedFields
199
+ >;
200
+
201
+ // ============================================================================
202
+ // INLINE SCRIPT COLLECTOR
203
+ // ============================================================================
204
+
205
+ /**
206
+ * Inline Script collector for health checks.
207
+ * Executes TypeScript/JavaScript code directly and checks the result.
208
+ *
209
+ * Scripts should return an object with:
210
+ * - success: boolean - Whether the check passed
211
+ * - message?: string - Optional status message
212
+ * - value?: number - Optional numeric value for metrics
213
+ *
214
+ * Scripts have access to:
215
+ * - context.config - The collector configuration
216
+ * - console.log/warn/error - Logging functions
217
+ * - fetch - HTTP client for making requests
218
+ *
219
+ * @example
220
+ * ```typescript
221
+ * // Simple check
222
+ * return { success: true, message: "All good!" };
223
+ *
224
+ * // HTTP health check
225
+ * const response = await fetch("https://api.example.com/health");
226
+ * return {
227
+ * success: response.ok,
228
+ * message: `Status: ${response.status}`,
229
+ * value: response.status
230
+ * };
231
+ * ```
232
+ */
233
+ export class InlineScriptCollector implements CollectorStrategy<
234
+ ScriptTransportClient,
235
+ InlineScriptConfig,
236
+ InlineScriptResult,
237
+ InlineScriptAggregatedResult
238
+ > {
239
+ id = "inline-script";
240
+ displayName = "Inline Script";
241
+ description = "Execute TypeScript/JavaScript code for health checking";
242
+
243
+ supportedPlugins = [pluginMetadata];
244
+
245
+ allowMultiple = true;
246
+
247
+ config = new Versioned({ version: 1, schema: inlineScriptConfigSchema });
248
+ result = new Versioned({ version: 1, schema: inlineScriptResultSchema });
249
+ aggregatedResult = new VersionedAggregated({
250
+ version: 1,
251
+ fields: inlineScriptAggregatedFields,
252
+ });
253
+
254
+ async execute({
255
+ config,
256
+ }: {
257
+ config: InlineScriptConfig;
258
+ client: ScriptTransportClient;
259
+ pluginId: string;
260
+ }): Promise<CollectorResult<InlineScriptResult>> {
261
+ const startTime = Date.now();
262
+
263
+ // Build context for the script
264
+ const scriptContext: ScriptContext = {
265
+ config: config as unknown as Record<string, unknown>,
266
+ fetch,
267
+ };
268
+
269
+ // Create a safe console that captures logs
270
+ const logs: string[] = [];
271
+ const safeConsole: SafeConsole = {
272
+ log: (...args) => {
273
+ logs.push(
274
+ args
275
+ .map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a)))
276
+ .join(" "),
277
+ );
278
+ },
279
+ warn: (...args) => {
280
+ logs.push(
281
+ `[WARN] ${args.map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a))).join(" ")}`,
282
+ );
283
+ },
284
+ error: (...args) => {
285
+ logs.push(
286
+ `[ERROR] ${args.map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a))).join(" ")}`,
287
+ );
288
+ },
289
+ info: (...args) => {
290
+ logs.push(
291
+ `[INFO] ${args.map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a))).join(" ")}`,
292
+ );
293
+ },
294
+ };
295
+
296
+ // Execute the script
297
+ const { result, error, timedOut } = await executeInlineScript({
298
+ script: config.script,
299
+ context: scriptContext,
300
+ safeConsole,
301
+ timeoutMs: config.timeout,
302
+ });
303
+
304
+ const executionTimeMs = Date.now() - startTime;
305
+
306
+ if (error) {
307
+ return {
308
+ result: {
309
+ success: false,
310
+ message: error,
311
+ executionTimeMs,
312
+ timedOut,
313
+ },
314
+ error,
315
+ };
316
+ }
317
+
318
+ return {
319
+ result: {
320
+ success: result?.success ?? true,
321
+ message:
322
+ result?.message ?? (logs.length > 0 ? logs.join("\n") : undefined),
323
+ value: result?.value,
324
+ executionTimeMs,
325
+ timedOut: false,
326
+ },
327
+ error:
328
+ result?.success === false
329
+ ? (result.message ?? "Check failed")
330
+ : undefined,
331
+ };
332
+ }
333
+
334
+ mergeResult(
335
+ existing: InlineScriptAggregatedResult | undefined,
336
+ run: HealthCheckRunForAggregation<InlineScriptResult>,
337
+ ): InlineScriptAggregatedResult {
338
+ const metadata = run.metadata;
339
+
340
+ return {
341
+ avgExecutionTimeMs: mergeAverage(
342
+ existing?.avgExecutionTimeMs,
343
+ metadata?.executionTimeMs,
344
+ ),
345
+ successRate: mergeRate(existing?.successRate, metadata?.success),
346
+ };
347
+ }
348
+ }
@@ -138,7 +138,7 @@ describe("ScriptHealthCheckStrategy", () => {
138
138
  });
139
139
  });
140
140
 
141
- describe("aggregateResult", () => {
141
+ describe("mergeResult", () => {
142
142
  it("should calculate averages correctly", () => {
143
143
  const strategy = new ScriptHealthCheckStrategy();
144
144
  const runs = [
@@ -172,12 +172,13 @@ describe("ScriptHealthCheckStrategy", () => {
172
172
  },
173
173
  ];
174
174
 
175
- const aggregated = strategy.aggregateResult(runs);
175
+ let aggregated = strategy.mergeResult(undefined, runs[0]);
176
+ aggregated = strategy.mergeResult(aggregated, runs[1]);
176
177
 
177
- expect(aggregated.avgExecutionTime).toBe(75);
178
- expect(aggregated.successRate).toBe(100);
179
- expect(aggregated.errorCount).toBe(0);
180
- expect(aggregated.timeoutCount).toBe(0);
178
+ expect(aggregated.avgExecutionTime.avg).toBe(75);
179
+ expect(aggregated.successRate.rate).toBe(100);
180
+ expect(aggregated.errorCount.count).toBe(0);
181
+ expect(aggregated.timeoutCount.count).toBe(0);
181
182
  });
182
183
 
183
184
  it("should count errors and timeouts", () => {
@@ -213,11 +214,12 @@ describe("ScriptHealthCheckStrategy", () => {
213
214
  },
214
215
  ];
215
216
 
216
- const aggregated = strategy.aggregateResult(runs);
217
+ let aggregated = strategy.mergeResult(undefined, runs[0]);
218
+ aggregated = strategy.mergeResult(aggregated, runs[1]);
217
219
 
218
- expect(aggregated.errorCount).toBe(1);
219
- expect(aggregated.timeoutCount).toBe(1);
220
- expect(aggregated.successRate).toBe(0);
220
+ expect(aggregated.errorCount.count).toBe(1);
221
+ expect(aggregated.timeoutCount.count).toBe(1);
222
+ expect(aggregated.successRate.rate).toBe(0);
221
223
  });
222
224
  });
223
225
  });
package/src/strategy.ts CHANGED
@@ -3,8 +3,16 @@ import {
3
3
  HealthCheckStrategy,
4
4
  HealthCheckRunForAggregation,
5
5
  Versioned,
6
+ VersionedAggregated,
7
+ aggregatedAverage,
8
+ aggregatedRate,
9
+ aggregatedCounter,
10
+ mergeAverage,
11
+ mergeRate,
12
+ mergeCounter,
6
13
  z,
7
14
  type ConnectedClient,
15
+ type InferAggregatedResult,
8
16
  } from "@checkstack/backend-api";
9
17
  import {
10
18
  healthResultBoolean,
@@ -79,31 +87,31 @@ const scriptResultSchema = healthResultSchema({
79
87
 
80
88
  type ScriptResult = z.infer<typeof scriptResultSchema>;
81
89
 
82
- /**
83
- * Aggregated metadata for buckets.
84
- */
85
- const scriptAggregatedSchema = healthResultSchema({
86
- avgExecutionTime: healthResultNumber({
90
+ /** Aggregated field definitions for bucket merging */
91
+ const scriptAggregatedFields = {
92
+ avgExecutionTime: aggregatedAverage({
87
93
  "x-chart-type": "line",
88
94
  "x-chart-label": "Avg Execution Time",
89
95
  "x-chart-unit": "ms",
90
96
  }),
91
- successRate: healthResultNumber({
97
+ successRate: aggregatedRate({
92
98
  "x-chart-type": "gauge",
93
99
  "x-chart-label": "Success Rate",
94
100
  "x-chart-unit": "%",
95
101
  }),
96
- errorCount: healthResultNumber({
102
+ errorCount: aggregatedCounter({
97
103
  "x-chart-type": "counter",
98
104
  "x-chart-label": "Errors",
99
105
  }),
100
- timeoutCount: healthResultNumber({
106
+ timeoutCount: aggregatedCounter({
101
107
  "x-chart-type": "counter",
102
108
  "x-chart-label": "Timeouts",
103
109
  }),
104
- });
110
+ };
105
111
 
106
- type ScriptAggregatedResult = z.infer<typeof scriptAggregatedSchema>;
112
+ type ScriptAggregatedResult = InferAggregatedResult<
113
+ typeof scriptAggregatedFields
114
+ >;
107
115
 
108
116
  // ============================================================================
109
117
  // SCRIPT EXECUTOR INTERFACE (for testability)
@@ -182,15 +190,12 @@ const defaultScriptExecutor: ScriptExecutor = {
182
190
  // STRATEGY
183
191
  // ============================================================================
184
192
 
185
- export class ScriptHealthCheckStrategy
186
- implements
187
- HealthCheckStrategy<
188
- ScriptConfig,
189
- ScriptTransportClient,
190
- ScriptResult,
191
- ScriptAggregatedResult
192
- >
193
- {
193
+ export class ScriptHealthCheckStrategy implements HealthCheckStrategy<
194
+ ScriptConfig,
195
+ ScriptTransportClient,
196
+ ScriptResult,
197
+ typeof scriptAggregatedFields
198
+ > {
194
199
  id = "script";
195
200
  displayName = "Script Health Check";
196
201
  description = "Execute local scripts or commands for health checking";
@@ -229,59 +234,36 @@ export class ScriptHealthCheckStrategy
229
234
  ],
230
235
  });
231
236
 
232
- aggregatedResult: Versioned<ScriptAggregatedResult> = new Versioned({
237
+ aggregatedResult = new VersionedAggregated({
233
238
  version: 1,
234
- schema: scriptAggregatedSchema,
239
+ fields: scriptAggregatedFields,
235
240
  });
236
241
 
237
- aggregateResult(
238
- runs: HealthCheckRunForAggregation<ScriptResult>[]
242
+ mergeResult(
243
+ existing: ScriptAggregatedResult | undefined,
244
+ run: HealthCheckRunForAggregation<ScriptResult>,
239
245
  ): ScriptAggregatedResult {
240
- const validRuns = runs.filter((r) => r.metadata);
241
-
242
- if (validRuns.length === 0) {
243
- return {
244
- avgExecutionTime: 0,
245
- successRate: 0,
246
- errorCount: 0,
247
- timeoutCount: 0,
248
- };
249
- }
246
+ const metadata = run.metadata;
250
247
 
251
- const executionTimes = validRuns
252
- .map((r) => r.metadata?.executionTimeMs)
253
- .filter((t): t is number => typeof t === "number");
248
+ const avgExecutionTime = mergeAverage(
249
+ existing?.avgExecutionTime,
250
+ metadata?.executionTimeMs,
251
+ );
254
252
 
255
- const avgExecutionTime =
256
- executionTimes.length > 0
257
- ? Math.round(
258
- executionTimes.reduce((a, b) => a + b, 0) / executionTimes.length
259
- )
260
- : 0;
253
+ const isSuccess = metadata?.success ?? false;
254
+ const successRate = mergeRate(existing?.successRate, isSuccess);
261
255
 
262
- const successCount = validRuns.filter(
263
- (r) => r.metadata?.success === true
264
- ).length;
265
- const successRate = Math.round((successCount / validRuns.length) * 100);
256
+ const hasError = metadata?.error !== undefined;
257
+ const errorCount = mergeCounter(existing?.errorCount, hasError);
266
258
 
267
- const errorCount = validRuns.filter(
268
- (r) => r.metadata?.error !== undefined
269
- ).length;
259
+ const hasTimeout = metadata?.timedOut === true;
260
+ const timeoutCount = mergeCounter(existing?.timeoutCount, hasTimeout);
270
261
 
271
- const timeoutCount = validRuns.filter(
272
- (r) => r.metadata?.timedOut === true
273
- ).length;
274
-
275
- return {
276
- avgExecutionTime,
277
- successRate,
278
- errorCount,
279
- timeoutCount,
280
- };
262
+ return { avgExecutionTime, successRate, errorCount, timeoutCount };
281
263
  }
282
264
 
283
265
  async createClient(
284
- _config: ScriptConfigInput
266
+ _config: ScriptConfigInput,
285
267
  ): Promise<ConnectedClient<ScriptTransportClient>> {
286
268
  const client: ScriptTransportClient = {
287
269
  exec: async (request: ScriptRequest): Promise<ScriptResultType> => {