@checkstack/healthcheck-script-backend 0.1.12 → 0.2.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,73 @@
1
1
  # @checkstack/healthcheck-script-backend
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - f676e11: Add script execution support and migrate CodeEditor to Monaco
8
+
9
+ **Integration providers** (`@checkstack/integration-script-backend`):
10
+
11
+ - **Script** - Execute TypeScript/JavaScript with context object
12
+ - **Bash** - Execute shell scripts with environment variables ($EVENT*ID, $PAYLOAD*\*)
13
+
14
+ **Health check collectors** (`@checkstack/healthcheck-script-backend`):
15
+
16
+ - **InlineScriptCollector** - Run TypeScript directly for health checks
17
+ - **ExecuteCollector** - Bash syntax highlighting for command field
18
+
19
+ **CodeEditor migration to Monaco** (`@checkstack/ui`):
20
+
21
+ - Replaced CodeMirror with Monaco Editor (VS Code's editor)
22
+ - Full TypeScript/JavaScript IntelliSense with custom type definitions
23
+ - Added `generateTypeDefinitions()` for JSON Schema → TypeScript conversion
24
+ - Removed all CodeMirror dependencies
25
+
26
+ **Type updates** (`@checkstack/common`):
27
+
28
+ - Added `javascript`, `typescript`, and `bash` to `EditorType` union
29
+
30
+ ### Patch Changes
31
+
32
+ - 48c2080: Migrate aggregation from batch to incremental (`mergeResult`)
33
+
34
+ ### Breaking Changes (Internal)
35
+
36
+ - Replaced `aggregateResult(runs[])` with `mergeResult(existing, run)` interface across all HealthCheckStrategy and CollectorStrategy implementations
37
+
38
+ ### New Features
39
+
40
+ - Added incremental aggregation utilities in `@checkstack/backend-api`:
41
+ - `mergeCounter()` - track occurrences
42
+ - `mergeAverage()` - track sum/count, compute avg
43
+ - `mergeRate()` - track success/total, compute %
44
+ - `mergeMinMax()` - track min/max values
45
+ - Exported Zod schemas for internal state: `averageStateSchema`, `rateStateSchema`, `minMaxStateSchema`, `counterStateSchema`
46
+
47
+ ### Improvements
48
+
49
+ - Enables O(1) storage overhead by maintaining incremental aggregation state
50
+ - Prepares for real-time hourly aggregation without batch accumulation
51
+
52
+ - Updated dependencies [f676e11]
53
+ - Updated dependencies [48c2080]
54
+ - @checkstack/common@0.6.2
55
+ - @checkstack/backend-api@0.6.0
56
+ - @checkstack/healthcheck-common@0.8.2
57
+
58
+ ## 0.1.13
59
+
60
+ ### Patch Changes
61
+
62
+ - 0b9fc58: Fix workspace:\* protocol resolution in published packages
63
+
64
+ Published packages now correctly have resolved dependency versions instead of `workspace:*` references. This is achieved by using `bun publish` which properly resolves workspace protocol references.
65
+
66
+ - Updated dependencies [0b9fc58]
67
+ - @checkstack/backend-api@0.5.2
68
+ - @checkstack/common@0.6.1
69
+ - @checkstack/healthcheck-common@0.8.1
70
+
3
71
  ## 0.1.12
4
72
 
5
73
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-script-backend",
3
- "version": "0.1.12",
3
+ "version": "0.2.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": "workspace:*",
13
- "@checkstack/common": "workspace:*",
14
- "@checkstack/healthcheck-common": "workspace:*"
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": "workspace:*",
20
- "@checkstack/scripts": "workspace:*"
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,7 +136,8 @@ 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
142
  expect(aggregated.avgExecutionTimeMs).toBe(75);
142
143
  expect(aggregated.successRate).toBe(100);
@@ -177,7 +178,8 @@ 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
184
  expect(aggregated.successRate).toBe(50);
183
185
  });
@@ -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
+ averageStateSchema,
10
+ mergeRate,
11
+ rateStateSchema,
12
+ type AverageState,
13
+ type RateState,
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,7 +78,7 @@ const executeResultSchema = healthResultSchema({
69
78
 
70
79
  export type ExecuteResult = z.infer<typeof executeResultSchema>;
71
80
 
72
- const executeAggregatedSchema = healthResultSchema({
81
+ const executeAggregatedDisplaySchema = healthResultSchema({
73
82
  avgExecutionTimeMs: healthResultNumber({
74
83
  "x-chart-type": "line",
75
84
  "x-chart-label": "Avg Execution Time",
@@ -82,6 +91,15 @@ const executeAggregatedSchema = healthResultSchema({
82
91
  }),
83
92
  });
84
93
 
94
+ const executeAggregatedInternalSchema = z.object({
95
+ _executionTime: averageStateSchema.optional(),
96
+ _success: rateStateSchema.optional(),
97
+ });
98
+
99
+ const executeAggregatedSchema = executeAggregatedDisplaySchema.merge(
100
+ executeAggregatedInternalSchema,
101
+ );
102
+
85
103
  export type ExecuteAggregatedResult = z.infer<typeof executeAggregatedSchema>;
86
104
 
87
105
  // ============================================================================
@@ -92,15 +110,12 @@ export type ExecuteAggregatedResult = z.infer<typeof executeAggregatedSchema>;
92
110
  * Built-in Script execute collector.
93
111
  * Runs commands and checks results.
94
112
  */
95
- export class ExecuteCollector
96
- implements
97
- CollectorStrategy<
98
- ScriptTransportClient,
99
- ExecuteConfig,
100
- ExecuteResult,
101
- ExecuteAggregatedResult
102
- >
103
- {
113
+ export class ExecuteCollector implements CollectorStrategy<
114
+ ScriptTransportClient,
115
+ ExecuteConfig,
116
+ ExecuteResult,
117
+ ExecuteAggregatedResult
118
+ > {
104
119
  id = "execute";
105
120
  displayName = "Execute Script";
106
121
  description = "Execute a command or script and check the result";
@@ -152,28 +167,27 @@ export class ExecuteCollector
152
167
  };
153
168
  }
154
169
 
155
- aggregateResult(
156
- runs: HealthCheckRunForAggregation<ExecuteResult>[]
170
+ mergeResult(
171
+ existing: ExecuteAggregatedResult | undefined,
172
+ run: HealthCheckRunForAggregation<ExecuteResult>,
157
173
  ): ExecuteAggregatedResult {
158
- const times = runs
159
- .map((r) => r.metadata?.executionTimeMs)
160
- .filter((v): v is number => typeof v === "number");
174
+ const metadata = run.metadata;
161
175
 
162
- const successes = runs
163
- .map((r) => r.metadata?.success)
164
- .filter((v): v is boolean => typeof v === "boolean");
176
+ const executionTimeState = mergeAverage(
177
+ existing?._executionTime as AverageState | undefined,
178
+ metadata?.executionTimeMs,
179
+ );
165
180
 
166
- const successCount = successes.filter(Boolean).length;
181
+ const successState = mergeRate(
182
+ existing?._success as RateState | undefined,
183
+ metadata?.success,
184
+ );
167
185
 
168
186
  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,
187
+ avgExecutionTimeMs: executionTimeState.avg,
188
+ successRate: successState.rate,
189
+ _executionTime: executionTimeState,
190
+ _success: successState,
177
191
  };
178
192
  }
179
193
  }
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).toBe(150); // (100+200+150)/3
253
+ expect(aggregated.successRate).toBeCloseTo(67, 0); // 2/3 * 100 = ~67
254
+ });
255
+ });
256
+ });
@@ -0,0 +1,364 @@
1
+ import {
2
+ Versioned,
3
+ z,
4
+ configString,
5
+ configNumber,
6
+ type HealthCheckRunForAggregation,
7
+ type CollectorResult,
8
+ type CollectorStrategy,
9
+ mergeAverage,
10
+ averageStateSchema,
11
+ mergeRate,
12
+ rateStateSchema,
13
+ type AverageState,
14
+ type RateState,
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
+ const inlineScriptAggregatedDisplaySchema = healthResultSchema({
183
+ avgExecutionTimeMs: healthResultNumber({
184
+ "x-chart-type": "line",
185
+ "x-chart-label": "Avg Execution Time",
186
+ "x-chart-unit": "ms",
187
+ }),
188
+ successRate: healthResultNumber({
189
+ "x-chart-type": "gauge",
190
+ "x-chart-label": "Success Rate",
191
+ "x-chart-unit": "%",
192
+ }),
193
+ });
194
+
195
+ const inlineScriptAggregatedInternalSchema = z.object({
196
+ _executionTime: averageStateSchema.optional(),
197
+ _success: rateStateSchema.optional(),
198
+ });
199
+
200
+ const inlineScriptAggregatedSchema = inlineScriptAggregatedDisplaySchema.merge(
201
+ inlineScriptAggregatedInternalSchema,
202
+ );
203
+
204
+ export type InlineScriptAggregatedResult = z.infer<
205
+ typeof inlineScriptAggregatedSchema
206
+ >;
207
+
208
+ // ============================================================================
209
+ // INLINE SCRIPT COLLECTOR
210
+ // ============================================================================
211
+
212
+ /**
213
+ * Inline Script collector for health checks.
214
+ * Executes TypeScript/JavaScript code directly and checks the result.
215
+ *
216
+ * Scripts should return an object with:
217
+ * - success: boolean - Whether the check passed
218
+ * - message?: string - Optional status message
219
+ * - value?: number - Optional numeric value for metrics
220
+ *
221
+ * Scripts have access to:
222
+ * - context.config - The collector configuration
223
+ * - console.log/warn/error - Logging functions
224
+ * - fetch - HTTP client for making requests
225
+ *
226
+ * @example
227
+ * ```typescript
228
+ * // Simple check
229
+ * return { success: true, message: "All good!" };
230
+ *
231
+ * // HTTP health check
232
+ * const response = await fetch("https://api.example.com/health");
233
+ * return {
234
+ * success: response.ok,
235
+ * message: `Status: ${response.status}`,
236
+ * value: response.status
237
+ * };
238
+ * ```
239
+ */
240
+ export class InlineScriptCollector implements CollectorStrategy<
241
+ ScriptTransportClient,
242
+ InlineScriptConfig,
243
+ InlineScriptResult,
244
+ InlineScriptAggregatedResult
245
+ > {
246
+ id = "inline-script";
247
+ displayName = "Inline Script";
248
+ description = "Execute TypeScript/JavaScript code for health checking";
249
+
250
+ supportedPlugins = [pluginMetadata];
251
+
252
+ allowMultiple = true;
253
+
254
+ config = new Versioned({ version: 1, schema: inlineScriptConfigSchema });
255
+ result = new Versioned({ version: 1, schema: inlineScriptResultSchema });
256
+ aggregatedResult = new Versioned({
257
+ version: 1,
258
+ schema: inlineScriptAggregatedSchema,
259
+ });
260
+
261
+ async execute({
262
+ config,
263
+ }: {
264
+ config: InlineScriptConfig;
265
+ client: ScriptTransportClient;
266
+ pluginId: string;
267
+ }): Promise<CollectorResult<InlineScriptResult>> {
268
+ const startTime = Date.now();
269
+
270
+ // Build context for the script
271
+ const scriptContext: ScriptContext = {
272
+ config: config as unknown as Record<string, unknown>,
273
+ fetch,
274
+ };
275
+
276
+ // Create a safe console that captures logs
277
+ const logs: string[] = [];
278
+ const safeConsole: SafeConsole = {
279
+ log: (...args) => {
280
+ logs.push(
281
+ args
282
+ .map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a)))
283
+ .join(" "),
284
+ );
285
+ },
286
+ warn: (...args) => {
287
+ logs.push(
288
+ `[WARN] ${args.map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a))).join(" ")}`,
289
+ );
290
+ },
291
+ error: (...args) => {
292
+ logs.push(
293
+ `[ERROR] ${args.map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a))).join(" ")}`,
294
+ );
295
+ },
296
+ info: (...args) => {
297
+ logs.push(
298
+ `[INFO] ${args.map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a))).join(" ")}`,
299
+ );
300
+ },
301
+ };
302
+
303
+ // Execute the script
304
+ const { result, error, timedOut } = await executeInlineScript({
305
+ script: config.script,
306
+ context: scriptContext,
307
+ safeConsole,
308
+ timeoutMs: config.timeout,
309
+ });
310
+
311
+ const executionTimeMs = Date.now() - startTime;
312
+
313
+ if (error) {
314
+ return {
315
+ result: {
316
+ success: false,
317
+ message: error,
318
+ executionTimeMs,
319
+ timedOut,
320
+ },
321
+ error,
322
+ };
323
+ }
324
+
325
+ return {
326
+ result: {
327
+ success: result?.success ?? true,
328
+ message:
329
+ result?.message ?? (logs.length > 0 ? logs.join("\n") : undefined),
330
+ value: result?.value,
331
+ executionTimeMs,
332
+ timedOut: false,
333
+ },
334
+ error:
335
+ result?.success === false
336
+ ? (result.message ?? "Check failed")
337
+ : undefined,
338
+ };
339
+ }
340
+
341
+ mergeResult(
342
+ existing: InlineScriptAggregatedResult | undefined,
343
+ run: HealthCheckRunForAggregation<InlineScriptResult>,
344
+ ): InlineScriptAggregatedResult {
345
+ const metadata = run.metadata;
346
+
347
+ const executionTimeState = mergeAverage(
348
+ existing?._executionTime as AverageState | undefined,
349
+ metadata?.executionTimeMs,
350
+ );
351
+
352
+ const successState = mergeRate(
353
+ existing?._success as RateState | undefined,
354
+ metadata?.success,
355
+ );
356
+
357
+ return {
358
+ avgExecutionTimeMs: executionTimeState.avg,
359
+ successRate: successState.rate,
360
+ _executionTime: executionTimeState,
361
+ _success: successState,
362
+ };
363
+ }
364
+ }
@@ -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,7 +172,8 @@ 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
178
  expect(aggregated.avgExecutionTime).toBe(75);
178
179
  expect(aggregated.successRate).toBe(100);
@@ -213,7 +214,8 @@ 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
220
  expect(aggregated.errorCount).toBe(1);
219
221
  expect(aggregated.timeoutCount).toBe(1);
package/src/strategy.ts CHANGED
@@ -5,6 +5,15 @@ import {
5
5
  Versioned,
6
6
  z,
7
7
  type ConnectedClient,
8
+ mergeAverage,
9
+ averageStateSchema,
10
+ mergeRate,
11
+ rateStateSchema,
12
+ mergeCounter,
13
+ counterStateSchema,
14
+ type AverageState,
15
+ type RateState,
16
+ type CounterState,
8
17
  } from "@checkstack/backend-api";
9
18
  import {
10
19
  healthResultBoolean,
@@ -82,7 +91,7 @@ type ScriptResult = z.infer<typeof scriptResultSchema>;
82
91
  /**
83
92
  * Aggregated metadata for buckets.
84
93
  */
85
- const scriptAggregatedSchema = healthResultSchema({
94
+ const scriptAggregatedDisplaySchema = healthResultSchema({
86
95
  avgExecutionTime: healthResultNumber({
87
96
  "x-chart-type": "line",
88
97
  "x-chart-label": "Avg Execution Time",
@@ -103,6 +112,19 @@ const scriptAggregatedSchema = healthResultSchema({
103
112
  }),
104
113
  });
105
114
 
115
+ const scriptAggregatedInternalSchema = z.object({
116
+ _executionTime: averageStateSchema
117
+ .optional(),
118
+ _success: rateStateSchema
119
+ .optional(),
120
+ _errors: counterStateSchema.optional(),
121
+ _timeouts: counterStateSchema.optional(),
122
+ });
123
+
124
+ const scriptAggregatedSchema = scriptAggregatedDisplaySchema.merge(
125
+ scriptAggregatedInternalSchema,
126
+ );
127
+
106
128
  type ScriptAggregatedResult = z.infer<typeof scriptAggregatedSchema>;
107
129
 
108
130
  // ============================================================================
@@ -182,15 +204,12 @@ const defaultScriptExecutor: ScriptExecutor = {
182
204
  // STRATEGY
183
205
  // ============================================================================
184
206
 
185
- export class ScriptHealthCheckStrategy
186
- implements
187
- HealthCheckStrategy<
188
- ScriptConfig,
189
- ScriptTransportClient,
190
- ScriptResult,
191
- ScriptAggregatedResult
192
- >
193
- {
207
+ export class ScriptHealthCheckStrategy implements HealthCheckStrategy<
208
+ ScriptConfig,
209
+ ScriptTransportClient,
210
+ ScriptResult,
211
+ ScriptAggregatedResult
212
+ > {
194
213
  id = "script";
195
214
  displayName = "Script Health Check";
196
215
  description = "Execute local scripts or commands for health checking";
@@ -234,54 +253,46 @@ export class ScriptHealthCheckStrategy
234
253
  schema: scriptAggregatedSchema,
235
254
  });
236
255
 
237
- aggregateResult(
238
- runs: HealthCheckRunForAggregation<ScriptResult>[]
256
+ mergeResult(
257
+ existing: ScriptAggregatedResult | undefined,
258
+ run: HealthCheckRunForAggregation<ScriptResult>,
239
259
  ): 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
- }
250
-
251
- const executionTimes = validRuns
252
- .map((r) => r.metadata?.executionTimeMs)
253
- .filter((t): t is number => typeof t === "number");
260
+ const metadata = run.metadata;
254
261
 
255
- const avgExecutionTime =
256
- executionTimes.length > 0
257
- ? Math.round(
258
- executionTimes.reduce((a, b) => a + b, 0) / executionTimes.length
259
- )
260
- : 0;
262
+ const executionTimeState = mergeAverage(
263
+ existing?._executionTime as AverageState | undefined,
264
+ metadata?.executionTimeMs,
265
+ );
261
266
 
262
- const successCount = validRuns.filter(
263
- (r) => r.metadata?.success === true
264
- ).length;
265
- const successRate = Math.round((successCount / validRuns.length) * 100);
267
+ const successState = mergeRate(
268
+ existing?._success as RateState | undefined,
269
+ metadata?.success,
270
+ );
266
271
 
267
- const errorCount = validRuns.filter(
268
- (r) => r.metadata?.error !== undefined
269
- ).length;
272
+ const errorState = mergeCounter(
273
+ existing?._errors as CounterState | undefined,
274
+ metadata?.error !== undefined,
275
+ );
270
276
 
271
- const timeoutCount = validRuns.filter(
272
- (r) => r.metadata?.timedOut === true
273
- ).length;
277
+ const timeoutState = mergeCounter(
278
+ existing?._timeouts as CounterState | undefined,
279
+ metadata?.timedOut === true,
280
+ );
274
281
 
275
282
  return {
276
- avgExecutionTime,
277
- successRate,
278
- errorCount,
279
- timeoutCount,
283
+ avgExecutionTime: executionTimeState.avg,
284
+ successRate: successState.rate,
285
+ errorCount: errorState.count,
286
+ timeoutCount: timeoutState.count,
287
+ _executionTime: executionTimeState,
288
+ _success: successState,
289
+ _errors: errorState,
290
+ _timeouts: timeoutState,
280
291
  };
281
292
  }
282
293
 
283
294
  async createClient(
284
- _config: ScriptConfigInput
295
+ _config: ScriptConfigInput,
285
296
  ): Promise<ConnectedClient<ScriptTransportClient>> {
286
297
  const client: ScriptTransportClient = {
287
298
  exec: async (request: ScriptRequest): Promise<ScriptResultType> => {