@checkstack/healthcheck-common 0.4.2 → 0.6.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-common
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 11d2679: Add ability to pause health check configurations globally. When paused, health checks continue to be scheduled but execution is skipped for all systems using that configuration. Users with manage access can pause/resume from the Health Checks config page.
8
+
9
+ ## 0.5.0
10
+
11
+ ### Minor Changes
12
+
13
+ - ac3a4cf: ### Dynamic Bucket Sizing for Health Check Visualization
14
+
15
+ Implements industry-standard dynamic bucket sizing for health check data aggregation, following patterns from Grafana/VictoriaMetrics.
16
+
17
+ **What changed:**
18
+
19
+ - Replaced fixed `bucketSize: "hourly" | "daily" | "auto"` with dynamic `targetPoints` parameter (default: 500)
20
+ - Bucket interval is now calculated as `(endDate - startDate) / targetPoints` with a minimum of 1 second
21
+ - Added `bucketIntervalSeconds` to aggregated response and individual buckets
22
+ - Updated chart components to use dynamic time formatting based on bucket interval
23
+
24
+ **Why:**
25
+
26
+ - A 24-hour view with 1-second health checks previously returned 86,400+ data points, causing lag
27
+ - Now returns ~500 data points regardless of timeframe, ensuring consistent chart performance
28
+ - Charts still preserve visual fidelity through proper aggregation
29
+
30
+ **Breaking Change:**
31
+
32
+ - `bucketSize` parameter removed from `getAggregatedHistory` and `getDetailedAggregatedHistory` endpoints
33
+ - Use `targetPoints` instead (defaults to 500 if not specified)
34
+
35
+ ***
36
+
37
+ ### Collector Aggregated Charts Fix
38
+
39
+ Fixed issue where collector auto-charts (like HTTP request response time charts) were not showing in aggregated data mode.
40
+
41
+ **What changed:**
42
+
43
+ - Added `aggregatedResultSchema` to `CollectorDtoSchema`
44
+ - Backend now returns collector aggregated schemas via `getCollectors` endpoint
45
+ - Frontend `useStrategySchemas` hook now merges collector aggregated schemas
46
+ - Service now calls each collector's `aggregateResult()` when building buckets
47
+ - Aggregated collector data stored in `aggregatedResult.collectors[uuid]`
48
+
49
+ **Why:**
50
+
51
+ - Previously only strategy-level aggregated results were computed
52
+ - Collectors like HTTP Request Collector have their own `aggregateResult` method
53
+ - Without calling these, fields like `avgResponseTimeMs` and `successRate` were missing from aggregated buckets
54
+
55
+ - db1f56f: Add ephemeral field stripping to reduce database storage for health checks
56
+
57
+ - Added `x-ephemeral` metadata flag to `HealthResultMeta` for marking fields that should not be persisted
58
+ - All health result factory functions (`healthResultString`, `healthResultNumber`, `healthResultBoolean`, `healthResultArray`, `healthResultJSONPath`) now accept `x-ephemeral`
59
+ - Added `stripEphemeralFields()` utility to remove ephemeral fields before database storage
60
+ - Integrated ephemeral field stripping into `queue-executor.ts` for all collector results
61
+ - HTTP Request collector now explicitly marks `body` as ephemeral
62
+
63
+ This significantly reduces database storage for health checks with large response bodies, while still allowing assertions to run against the full response at execution time.
64
+
65
+ ### Patch Changes
66
+
67
+ - Updated dependencies [db1f56f]
68
+ - @checkstack/common@0.6.0
69
+ - @checkstack/signal-common@0.1.4
70
+
3
71
  ## 0.4.2
4
72
 
5
73
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-common",
3
- "version": "0.4.2",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
package/src/index.ts CHANGED
@@ -28,6 +28,7 @@ export interface HealthCheckConfiguration {
28
28
  config: Record<string, unknown>;
29
29
  intervalSeconds: number;
30
30
  collectors?: CollectorConfigEntry[];
31
+ paused: boolean;
31
32
  createdAt: Date;
32
33
  updatedAt: Date;
33
34
  }
@@ -58,5 +59,5 @@ export const HEALTH_CHECK_RUN_COMPLETED = createSignal(
58
59
  configurationName: z.string(),
59
60
  status: z.enum(["healthy", "degraded", "unhealthy"]),
60
61
  latencyMs: z.number().optional(),
61
- })
62
+ }),
62
63
  );
@@ -99,6 +99,22 @@ export const healthCheckContract = {
99
99
  .input(z.string())
100
100
  .output(z.void()),
101
101
 
102
+ pauseConfiguration: proc({
103
+ operationType: "mutation",
104
+ userType: "authenticated",
105
+ access: [healthCheckAccess.configuration.manage],
106
+ })
107
+ .input(z.string())
108
+ .output(z.void()),
109
+
110
+ resumeConfiguration: proc({
111
+ operationType: "mutation",
112
+ userType: "authenticated",
113
+ access: [healthCheckAccess.configuration.manage],
114
+ })
115
+ .input(z.string())
116
+ .output(z.void()),
117
+
102
118
  // ==========================================================================
103
119
  // SYSTEM ASSOCIATION (userType: "authenticated")
104
120
  // ==========================================================================
@@ -248,12 +264,15 @@ export const healthCheckContract = {
248
264
  configurationId: z.string(),
249
265
  startDate: z.date(),
250
266
  endDate: z.date(),
251
- bucketSize: z.enum(["hourly", "daily", "auto"]),
267
+ /** Target number of data points (default: 500). Bucket interval is calculated as (endDate - startDate) / targetPoints */
268
+ targetPoints: z.number().min(10).max(2000).default(500),
252
269
  }),
253
270
  )
254
271
  .output(
255
272
  z.object({
256
273
  buckets: z.array(AggregatedBucketBaseSchema),
274
+ /** The calculated bucket interval in seconds */
275
+ bucketIntervalSeconds: z.number(),
257
276
  }),
258
277
  ),
259
278
 
@@ -268,12 +287,15 @@ export const healthCheckContract = {
268
287
  configurationId: z.string(),
269
288
  startDate: z.date(),
270
289
  endDate: z.date(),
271
- bucketSize: z.enum(["hourly", "daily", "auto"]),
290
+ /** Target number of data points (default: 500). Bucket interval is calculated as (endDate - startDate) / targetPoints */
291
+ targetPoints: z.number().min(10).max(2000).default(500),
272
292
  }),
273
293
  )
274
294
  .output(
275
295
  z.object({
276
296
  buckets: z.array(AggregatedBucketSchema),
297
+ /** The calculated bucket interval in seconds */
298
+ bucketIntervalSeconds: z.number(),
277
299
  }),
278
300
  ),
279
301
 
package/src/schemas.ts CHANGED
@@ -32,6 +32,8 @@ export const CollectorDtoSchema = z.object({
32
32
  configSchema: z.record(z.string(), z.unknown()),
33
33
  /** JSON Schema for per-run result metadata (with chart annotations) */
34
34
  resultSchema: z.record(z.string(), z.unknown()),
35
+ /** JSON Schema for aggregated result metadata (with chart annotations) */
36
+ aggregatedResultSchema: z.record(z.string(), z.unknown()).optional(),
35
37
  /** Whether multiple instances of this collector are allowed per config */
36
38
  allowMultiple: z.boolean(),
37
39
  });
@@ -79,6 +81,8 @@ export const HealthCheckConfigurationSchema = z.object({
79
81
  intervalSeconds: z.number(),
80
82
  /** Optional collector configurations */
81
83
  collectors: z.array(CollectorConfigEntrySchema).optional(),
84
+ /** Whether this configuration is paused (execution skipped for all systems) */
85
+ paused: z.boolean(),
82
86
  createdAt: z.date(),
83
87
  updatedAt: z.date(),
84
88
  });
@@ -276,7 +280,10 @@ export const DEFAULT_RETENTION_CONFIG: RetentionConfig = {
276
280
  */
277
281
  export const AggregatedBucketBaseSchema = z.object({
278
282
  bucketStart: z.date(),
279
- bucketSize: z.enum(["hourly", "daily"]),
283
+ /** @deprecated Use bucketIntervalSeconds instead. Kept for backward compatibility. */
284
+ bucketSize: z.enum(["hourly", "daily"]).optional(),
285
+ /** Bucket interval in seconds (e.g., 7 for 7-second buckets) */
286
+ bucketIntervalSeconds: z.number(),
280
287
  runCount: z.number(),
281
288
  healthyCount: z.number(),
282
289
  degradedCount: z.number(),
@@ -0,0 +1,105 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { z } from "zod";
3
+ import {
4
+ healthResultSchema,
5
+ healthResultNumber,
6
+ healthResultString,
7
+ healthResultJSONPath,
8
+ stripEphemeralFields,
9
+ getHealthResultMeta,
10
+ } from "./zod-health-result";
11
+
12
+ describe("stripEphemeralFields", () => {
13
+ it("should strip fields marked with x-ephemeral", () => {
14
+ const schema = healthResultSchema({
15
+ statusCode: healthResultNumber({ "x-chart-type": "counter" }),
16
+ body: healthResultJSONPath({ "x-ephemeral": true }), // Explicitly marked ephemeral
17
+ });
18
+
19
+ const result = {
20
+ statusCode: 200,
21
+ body: '{"large":"response body that should not be stored"}',
22
+ };
23
+
24
+ const stripped = stripEphemeralFields(result, schema);
25
+
26
+ expect(stripped).toEqual({ statusCode: 200 });
27
+ expect(stripped).not.toHaveProperty("body");
28
+ });
29
+
30
+ it("should preserve non-ephemeral fields", () => {
31
+ const schema = healthResultSchema({
32
+ responseTimeMs: healthResultNumber({ "x-chart-type": "line" }),
33
+ statusText: healthResultString({ "x-chart-type": "text" }),
34
+ });
35
+
36
+ const result = {
37
+ responseTimeMs: 150,
38
+ statusText: "OK",
39
+ };
40
+
41
+ const stripped = stripEphemeralFields(result, schema);
42
+
43
+ expect(stripped).toEqual(result);
44
+ });
45
+
46
+ it("should preserve unknown fields like _collectorId", () => {
47
+ const schema = healthResultSchema({
48
+ value: healthResultNumber({ "x-chart-type": "counter" }),
49
+ body: healthResultJSONPath({ "x-ephemeral": true }),
50
+ });
51
+
52
+ const result = {
53
+ _collectorId: "http.request",
54
+ _assertionFailed: undefined,
55
+ value: 42,
56
+ body: "large body content",
57
+ };
58
+
59
+ const stripped = stripEphemeralFields(result, schema);
60
+
61
+ expect(stripped).toEqual({
62
+ _collectorId: "http.request",
63
+ _assertionFailed: undefined,
64
+ value: 42,
65
+ });
66
+ });
67
+
68
+ it("should return original result for non-ZodObject schemas", () => {
69
+ const schema = z.string();
70
+ const result = { foo: "bar" };
71
+
72
+ const stripped = stripEphemeralFields(result, schema);
73
+
74
+ expect(stripped).toEqual(result);
75
+ });
76
+
77
+ it("should handle empty result objects", () => {
78
+ const schema = healthResultSchema({
79
+ body: healthResultJSONPath({ "x-ephemeral": true }),
80
+ });
81
+
82
+ const result = {};
83
+ const stripped = stripEphemeralFields(result, schema);
84
+
85
+ expect(stripped).toEqual({});
86
+ });
87
+ });
88
+
89
+ describe("healthResultJSONPath", () => {
90
+ it("should allow explicitly marking fields as ephemeral", () => {
91
+ const field = healthResultJSONPath({ "x-ephemeral": true });
92
+ const meta = getHealthResultMeta(field);
93
+
94
+ expect(meta?.["x-ephemeral"]).toBe(true);
95
+ expect(meta?.["x-jsonpath"]).toBe(true);
96
+ });
97
+
98
+ it("should not be ephemeral by default", () => {
99
+ const field = healthResultJSONPath({});
100
+ const meta = getHealthResultMeta(field);
101
+
102
+ expect(meta?.["x-ephemeral"]).toBeUndefined();
103
+ expect(meta?.["x-jsonpath"]).toBe(true);
104
+ });
105
+ });
@@ -86,7 +86,7 @@ export type HealthResultShape = Record<string, HealthResultFieldType>;
86
86
  * ```
87
87
  */
88
88
  export function healthResultSchema<T extends HealthResultShape>(
89
- shape: T
89
+ shape: T,
90
90
  ): z.ZodObject<T> {
91
91
  return z.object(shape);
92
92
  }
@@ -111,7 +111,7 @@ type ChartMeta = Omit<HealthResultMeta, "x-jsonpath">;
111
111
  * ```
112
112
  */
113
113
  export function healthResultString(
114
- meta: ChartMeta
114
+ meta: ChartMeta,
115
115
  ): HealthResultField<z.ZodString> {
116
116
  const schema = z.string();
117
117
  schema.register(healthResultRegistry, meta);
@@ -122,7 +122,7 @@ export function healthResultString(
122
122
  * Create a health result number field with typed chart metadata.
123
123
  */
124
124
  export function healthResultNumber(
125
- meta: ChartMeta
125
+ meta: ChartMeta,
126
126
  ): HealthResultField<z.ZodNumber> {
127
127
  const schema = z.number();
128
128
  schema.register(healthResultRegistry, meta);
@@ -133,7 +133,7 @@ export function healthResultNumber(
133
133
  * Create a health result boolean field with typed chart metadata.
134
134
  */
135
135
  export function healthResultBoolean(
136
- meta: ChartMeta
136
+ meta: ChartMeta,
137
137
  ): HealthResultField<z.ZodBoolean> {
138
138
  const schema = z.boolean();
139
139
  schema.register(healthResultRegistry, meta);
@@ -152,7 +152,7 @@ export function healthResultBoolean(
152
152
  * ```
153
153
  */
154
154
  export function healthResultArray(
155
- meta: ChartMeta
155
+ meta: ChartMeta,
156
156
  ): HealthResultField<z.ZodArray<z.ZodString>> {
157
157
  const schema = z.array(z.string());
158
158
  schema.register(healthResultRegistry, meta);
@@ -173,7 +173,7 @@ export function healthResultArray(
173
173
  * ```
174
174
  */
175
175
  export function healthResultJSONPath(
176
- meta: ChartMeta
176
+ meta: ChartMeta,
177
177
  ): HealthResultField<z.ZodString> {
178
178
  const schema = z.string();
179
179
  schema.register(healthResultRegistry, { ...meta, "x-jsonpath": true });
@@ -213,7 +213,60 @@ function unwrapSchema(schema: z.ZodTypeAny): z.ZodTypeAny {
213
213
  * Automatically unwraps Optional/Default/Nullable wrappers.
214
214
  */
215
215
  export function getHealthResultMeta(
216
- schema: z.ZodTypeAny
216
+ schema: z.ZodTypeAny,
217
217
  ): HealthResultMeta | undefined {
218
218
  return healthResultRegistry.get(unwrapSchema(schema));
219
219
  }
220
+
221
+ // ============================================================================
222
+ // EPHEMERAL FIELD STRIPPING - For storage optimization
223
+ // ============================================================================
224
+
225
+ /**
226
+ * Strip ephemeral fields from a result object before database storage.
227
+ *
228
+ * Ephemeral fields (marked with x-ephemeral: true) are used during health check
229
+ * execution for assertions but should not be persisted to save storage space.
230
+ * Common example: HTTP response bodies used for JSONPath assertions.
231
+ *
232
+ * @param result - The full result object from collector execution
233
+ * @param schema - The Zod schema with health result metadata
234
+ * @returns A new object with ephemeral fields removed
235
+ *
236
+ * @example
237
+ * ```typescript
238
+ * const stripped = stripEphemeralFields(collectorResult.result, collector.result.schema);
239
+ * // The 'body' field (marked with x-ephemeral) is removed before storage
240
+ * ```
241
+ */
242
+ export function stripEphemeralFields<T extends Record<string, unknown>>(
243
+ result: T,
244
+ schema: z.ZodTypeAny,
245
+ ): Partial<T> {
246
+ // Handle ZodObject schemas
247
+ if (!(schema instanceof z.ZodObject)) {
248
+ return result;
249
+ }
250
+
251
+ const shape = schema.shape as Record<string, z.ZodTypeAny>;
252
+ const stripped: Record<string, unknown> = {};
253
+
254
+ for (const [key, value] of Object.entries(result)) {
255
+ const fieldSchema = shape[key];
256
+ if (!fieldSchema) {
257
+ // Keep unknown fields (e.g., _collectorId, _assertionFailed)
258
+ stripped[key] = value;
259
+ continue;
260
+ }
261
+
262
+ const meta = getHealthResultMeta(fieldSchema);
263
+ if (meta?.["x-ephemeral"]) {
264
+ // Skip ephemeral fields
265
+ continue;
266
+ }
267
+
268
+ stripped[key] = value;
269
+ }
270
+
271
+ return stripped as Partial<T>;
272
+ }