@checkstack/healthcheck-jenkins-backend 0.2.12 → 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,59 @@
1
1
  # @checkstack/healthcheck-jenkins-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.13
28
+
29
+ ### Patch Changes
30
+
31
+ - 48c2080: Migrate aggregation from batch to incremental (`mergeResult`)
32
+
33
+ ### Breaking Changes (Internal)
34
+
35
+ - Replaced `aggregateResult(runs[])` with `mergeResult(existing, run)` interface across all HealthCheckStrategy and CollectorStrategy implementations
36
+
37
+ ### New Features
38
+
39
+ - Added incremental aggregation utilities in `@checkstack/backend-api`:
40
+ - `mergeCounter()` - track occurrences
41
+ - `mergeAverage()` - track sum/count, compute avg
42
+ - `mergeRate()` - track success/total, compute %
43
+ - `mergeMinMax()` - track min/max values
44
+ - Exported Zod schemas for internal state: `averageStateSchema`, `rateStateSchema`, `minMaxStateSchema`, `counterStateSchema`
45
+
46
+ ### Improvements
47
+
48
+ - Enables O(1) storage overhead by maintaining incremental aggregation state
49
+ - Prepares for real-time hourly aggregation without batch accumulation
50
+
51
+ - Updated dependencies [f676e11]
52
+ - Updated dependencies [48c2080]
53
+ - @checkstack/common@0.6.2
54
+ - @checkstack/backend-api@0.6.0
55
+ - @checkstack/healthcheck-common@0.8.2
56
+
3
57
  ## 0.2.12
4
58
 
5
59
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-jenkins-backend",
3
- "version": "0.2.12",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "scripts": {
@@ -10,14 +10,14 @@
10
10
  "test": "bun test"
11
11
  },
12
12
  "dependencies": {
13
- "@checkstack/backend-api": "0.5.1",
14
- "@checkstack/common": "0.6.0",
15
- "@checkstack/healthcheck-common": "0.7.0"
13
+ "@checkstack/backend-api": "0.5.2",
14
+ "@checkstack/common": "0.6.1",
15
+ "@checkstack/healthcheck-common": "0.8.1"
16
16
  },
17
17
  "devDependencies": {
18
18
  "@types/bun": "^1.0.0",
19
19
  "typescript": "^5.0.0",
20
- "@checkstack/tsconfig": "0.0.2",
21
- "@checkstack/scripts": "0.1.0"
20
+ "@checkstack/tsconfig": "0.0.3",
21
+ "@checkstack/scripts": "0.1.1"
22
22
  }
23
23
  }
@@ -65,7 +65,7 @@ describe("BuildHistoryCollector", () => {
65
65
  });
66
66
 
67
67
  it("should aggregate correctly", () => {
68
- const runs: Parameters<typeof collector.aggregateResult>[0] = [
68
+ const runs: Parameters<typeof collector.mergeResult>[1][] = [
69
69
  {
70
70
  status: "healthy" as const,
71
71
  latencyMs: 100,
@@ -98,9 +98,10 @@ describe("BuildHistoryCollector", () => {
98
98
  },
99
99
  ];
100
100
 
101
- const aggregated = collector.aggregateResult(runs);
101
+ let aggregated = collector.mergeResult(undefined, runs[0]);
102
+ aggregated = collector.mergeResult(aggregated, runs[1]);
102
103
 
103
- expect(aggregated.avgSuccessRate).toBe(70);
104
- expect(aggregated.avgBuildDuration).toBe(70000);
104
+ expect(aggregated.avgSuccessRate.avg).toBe(70);
105
+ expect(aggregated.avgBuildDuration.avg).toBe(70000);
105
106
  });
106
107
  });
@@ -4,6 +4,10 @@ import {
4
4
  type HealthCheckRunForAggregation,
5
5
  type CollectorResult,
6
6
  type CollectorStrategy,
7
+ mergeAverage,
8
+ VersionedAggregated,
9
+ aggregatedAverage,
10
+ type InferAggregatedResult,
7
11
  } from "@checkstack/backend-api";
8
12
  import { healthResultNumber } from "@checkstack/healthcheck-common";
9
13
  import { pluginMetadata } from "../plugin-metadata";
@@ -86,21 +90,23 @@ const buildHistoryResultSchema = z.object({
86
90
 
87
91
  export type BuildHistoryResult = z.infer<typeof buildHistoryResultSchema>;
88
92
 
89
- const buildHistoryAggregatedSchema = z.object({
90
- avgSuccessRate: healthResultNumber({
93
+ // Aggregated result fields definition
94
+ const buildHistoryAggregatedFields = {
95
+ avgSuccessRate: aggregatedAverage({
91
96
  "x-chart-type": "gauge",
92
97
  "x-chart-label": "Avg Success Rate",
93
98
  "x-chart-unit": "%",
94
99
  }),
95
- avgBuildDuration: healthResultNumber({
100
+ avgBuildDuration: aggregatedAverage({
96
101
  "x-chart-type": "line",
97
102
  "x-chart-label": "Avg Build Duration",
98
103
  "x-chart-unit": "ms",
99
104
  }),
100
- });
105
+ };
101
106
 
102
- export type BuildHistoryAggregatedResult = z.infer<
103
- typeof buildHistoryAggregatedSchema
107
+ // Type inferred from field definitions
108
+ export type BuildHistoryAggregatedResult = InferAggregatedResult<
109
+ typeof buildHistoryAggregatedFields
104
110
  >;
105
111
 
106
112
  // ============================================================================
@@ -111,15 +117,12 @@ export type BuildHistoryAggregatedResult = z.infer<
111
117
  * Collector for Jenkins build history.
112
118
  * Analyzes recent builds for trends and patterns.
113
119
  */
114
- export class BuildHistoryCollector
115
- implements
116
- CollectorStrategy<
117
- JenkinsTransportClient,
118
- BuildHistoryConfig,
119
- BuildHistoryResult,
120
- BuildHistoryAggregatedResult
121
- >
122
- {
120
+ export class BuildHistoryCollector implements CollectorStrategy<
121
+ JenkinsTransportClient,
122
+ BuildHistoryConfig,
123
+ BuildHistoryResult,
124
+ BuildHistoryAggregatedResult
125
+ > {
123
126
  id = "build-history";
124
127
  displayName = "Build History";
125
128
  description = "Analyze recent build trends for a Jenkins job";
@@ -129,9 +132,9 @@ export class BuildHistoryCollector
129
132
 
130
133
  config = new Versioned({ version: 1, schema: buildHistoryConfigSchema });
131
134
  result = new Versioned({ version: 1, schema: buildHistoryResultSchema });
132
- aggregatedResult = new Versioned({
135
+ aggregatedResult = new VersionedAggregated({
133
136
  version: 1,
134
- schema: buildHistoryAggregatedSchema,
137
+ fields: buildHistoryAggregatedFields,
135
138
  });
136
139
 
137
140
  async execute({
@@ -253,28 +256,21 @@ export class BuildHistoryCollector
253
256
  };
254
257
  }
255
258
 
256
- aggregateResult(
257
- runs: HealthCheckRunForAggregation<BuildHistoryResult>[]
259
+ mergeResult(
260
+ existing: BuildHistoryAggregatedResult | undefined,
261
+ run: HealthCheckRunForAggregation<BuildHistoryResult>,
258
262
  ): BuildHistoryAggregatedResult {
259
- const successRates = runs
260
- .map((r) => r.metadata?.successRate)
261
- .filter((v): v is number => typeof v === "number");
262
-
263
- const durations = runs
264
- .map((r) => r.metadata?.avgDurationMs)
265
- .filter((v): v is number => typeof v === "number");
263
+ const metadata = run.metadata;
266
264
 
267
265
  return {
268
- avgSuccessRate:
269
- successRates.length > 0
270
- ? Math.round(
271
- successRates.reduce((a, b) => a + b, 0) / successRates.length
272
- )
273
- : 0,
274
- avgBuildDuration:
275
- durations.length > 0
276
- ? Math.round(durations.reduce((a, b) => a + b, 0) / durations.length)
277
- : 0,
266
+ avgSuccessRate: mergeAverage(
267
+ existing?.avgSuccessRate,
268
+ metadata?.successRate,
269
+ ),
270
+ avgBuildDuration: mergeAverage(
271
+ existing?.avgBuildDuration,
272
+ metadata?.avgDurationMs,
273
+ ),
278
274
  };
279
275
  }
280
276
  }
@@ -9,7 +9,7 @@ describe("JobStatusCollector", () => {
9
9
  const collector = new JobStatusCollector();
10
10
 
11
11
  const createMockClient = (
12
- response: JenkinsResponse
12
+ response: JenkinsResponse,
13
13
  ): JenkinsTransportClient => ({
14
14
  exec: async () => response,
15
15
  });
@@ -93,12 +93,12 @@ describe("JobStatusCollector", () => {
93
93
  });
94
94
 
95
95
  expect(capturedPath).toBe(
96
- "/job/folder/job/subfolder/job/nested-job/api/json"
96
+ "/job/folder/job/subfolder/job/nested-job/api/json",
97
97
  );
98
98
  });
99
99
 
100
100
  it("should aggregate success rate correctly", () => {
101
- const runs: Parameters<typeof collector.aggregateResult>[0] = [
101
+ const runs: Parameters<typeof collector.mergeResult>[1][] = [
102
102
  {
103
103
  status: "healthy" as const,
104
104
  latencyMs: 100,
@@ -137,10 +137,12 @@ describe("JobStatusCollector", () => {
137
137
  },
138
138
  ];
139
139
 
140
- const aggregated = collector.aggregateResult(runs);
140
+ let aggregated = collector.mergeResult(undefined, runs[0]);
141
+ aggregated = collector.mergeResult(aggregated, runs[1]);
142
+ aggregated = collector.mergeResult(aggregated, runs[2]);
141
143
 
142
- expect(aggregated.successRate).toBe(67); // 2/3
143
- expect(aggregated.avgBuildDurationMs).toBe(60000); // (60000+80000+40000)/3
144
- expect(aggregated.buildableRate).toBe(100);
144
+ expect(aggregated.successRate.rate).toBe(67); // 2/3
145
+ expect(aggregated.avgBuildDurationMs.avg).toBe(60000); // (60000+80000+40000)/3
146
+ expect(aggregated.buildableRate.rate).toBe(100);
145
147
  });
146
148
  });
@@ -4,6 +4,12 @@ import {
4
4
  type HealthCheckRunForAggregation,
5
5
  type CollectorResult,
6
6
  type CollectorStrategy,
7
+ mergeAverage,
8
+ mergeRate,
9
+ VersionedAggregated,
10
+ aggregatedAverage,
11
+ aggregatedRate,
12
+ type InferAggregatedResult,
7
13
  } from "@checkstack/backend-api";
8
14
  import {
9
15
  healthResultNumber,
@@ -77,26 +83,28 @@ const jobStatusResultSchema = z.object({
77
83
 
78
84
  export type JobStatusResult = z.infer<typeof jobStatusResultSchema>;
79
85
 
80
- const jobStatusAggregatedSchema = z.object({
81
- avgBuildDurationMs: healthResultNumber({
86
+ // Aggregated result fields definition
87
+ const jobStatusAggregatedFields = {
88
+ avgBuildDurationMs: aggregatedAverage({
82
89
  "x-chart-type": "line",
83
90
  "x-chart-label": "Avg Build Duration",
84
91
  "x-chart-unit": "ms",
85
92
  }),
86
- successRate: healthResultNumber({
93
+ successRate: aggregatedRate({
87
94
  "x-chart-type": "gauge",
88
95
  "x-chart-label": "Success Rate",
89
96
  "x-chart-unit": "%",
90
97
  }),
91
- buildableRate: healthResultNumber({
98
+ buildableRate: aggregatedRate({
92
99
  "x-chart-type": "gauge",
93
100
  "x-chart-label": "Enabled Rate",
94
101
  "x-chart-unit": "%",
95
102
  }),
96
- });
103
+ };
97
104
 
98
- export type JobStatusAggregatedResult = z.infer<
99
- typeof jobStatusAggregatedSchema
105
+ // Type inferred from field definitions
106
+ export type JobStatusAggregatedResult = InferAggregatedResult<
107
+ typeof jobStatusAggregatedFields
100
108
  >;
101
109
 
102
110
  // ============================================================================
@@ -107,15 +115,12 @@ export type JobStatusAggregatedResult = z.infer<
107
115
  * Collector for Jenkins job status.
108
116
  * Monitors individual job health and last build information.
109
117
  */
110
- export class JobStatusCollector
111
- implements
112
- CollectorStrategy<
113
- JenkinsTransportClient,
114
- JobStatusConfig,
115
- JobStatusResult,
116
- JobStatusAggregatedResult
117
- >
118
- {
118
+ export class JobStatusCollector implements CollectorStrategy<
119
+ JenkinsTransportClient,
120
+ JobStatusConfig,
121
+ JobStatusResult,
122
+ JobStatusAggregatedResult
123
+ > {
119
124
  id = "job-status";
120
125
  displayName = "Job Status";
121
126
  description = "Monitor Jenkins job status and last build information";
@@ -125,9 +130,9 @@ export class JobStatusCollector
125
130
 
126
131
  config = new Versioned({ version: 1, schema: jobStatusConfigSchema });
127
132
  result = new Versioned({ version: 1, schema: jobStatusResultSchema });
128
- aggregatedResult = new Versioned({
133
+ aggregatedResult = new VersionedAggregated({
129
134
  version: 1,
130
- schema: jobStatusAggregatedSchema,
135
+ fields: jobStatusAggregatedFields,
131
136
  });
132
137
 
133
138
  async execute({
@@ -205,37 +210,22 @@ export class JobStatusCollector
205
210
  };
206
211
  }
207
212
 
208
- aggregateResult(
209
- runs: HealthCheckRunForAggregation<JobStatusResult>[]
213
+ mergeResult(
214
+ existing: JobStatusAggregatedResult | undefined,
215
+ run: HealthCheckRunForAggregation<JobStatusResult>,
210
216
  ): JobStatusAggregatedResult {
211
- const durations = runs
212
- .map((r) => r.metadata?.lastBuildDurationMs)
213
- .filter((v): v is number => typeof v === "number");
214
-
215
- const results = runs
216
- .map((r) => r.metadata?.lastBuildResult)
217
- .filter((v): v is string => typeof v === "string");
218
-
219
- const buildables = runs
220
- .map((r) => r.metadata?.buildable)
221
- .filter((v): v is boolean => typeof v === "boolean");
222
-
223
- const successCount = results.filter((r) => r === "SUCCESS").length;
224
- const buildableCount = buildables.filter(Boolean).length;
217
+ const metadata = run.metadata;
225
218
 
226
219
  return {
227
- avgBuildDurationMs:
228
- durations.length > 0
229
- ? Math.round(durations.reduce((a, b) => a + b, 0) / durations.length)
230
- : 0,
231
- successRate:
232
- results.length > 0
233
- ? Math.round((successCount / results.length) * 100)
234
- : 0,
235
- buildableRate:
236
- buildables.length > 0
237
- ? Math.round((buildableCount / buildables.length) * 100)
238
- : 0,
220
+ avgBuildDurationMs: mergeAverage(
221
+ existing?.avgBuildDurationMs,
222
+ metadata?.lastBuildDurationMs,
223
+ ),
224
+ successRate: mergeRate(
225
+ existing?.successRate,
226
+ metadata?.lastBuildResult === "SUCCESS",
227
+ ),
228
+ buildableRate: mergeRate(existing?.buildableRate, metadata?.buildable),
239
229
  };
240
230
  }
241
231
  }
@@ -111,7 +111,7 @@ describe("NodeHealthCollector", () => {
111
111
  });
112
112
 
113
113
  it("should aggregate correctly", () => {
114
- const runs: Parameters<typeof collector.aggregateResult>[0] = [
114
+ const runs: Parameters<typeof collector.mergeResult>[1][] = [
115
115
  {
116
116
  status: "healthy" as const,
117
117
  latencyMs: 100,
@@ -140,10 +140,11 @@ describe("NodeHealthCollector", () => {
140
140
  },
141
141
  ];
142
142
 
143
- const aggregated = collector.aggregateResult(runs);
143
+ let aggregated = collector.mergeResult(undefined, runs[0]);
144
+ aggregated = collector.mergeResult(aggregated, runs[1]);
144
145
 
145
- expect(aggregated.avgOnlineNodes).toBe(4);
146
- expect(aggregated.avgUtilization).toBe(60);
147
- expect(aggregated.minOnlineNodes).toBe(3);
146
+ expect(aggregated.avgOnlineNodes.avg).toBe(4);
147
+ expect(aggregated.avgUtilization.avg).toBe(60);
148
+ expect(aggregated.minOnlineNodes.min).toBe(3);
148
149
  });
149
150
  });
@@ -4,6 +4,12 @@ import {
4
4
  type HealthCheckRunForAggregation,
5
5
  type CollectorResult,
6
6
  type CollectorStrategy,
7
+ mergeAverage,
8
+ mergeMinMax,
9
+ VersionedAggregated,
10
+ aggregatedAverage,
11
+ aggregatedMinMax,
12
+ type InferAggregatedResult,
7
13
  } from "@checkstack/backend-api";
8
14
  import {
9
15
  healthResultBoolean,
@@ -77,24 +83,26 @@ const nodeHealthResultSchema = z.object({
77
83
 
78
84
  export type NodeHealthResult = z.infer<typeof nodeHealthResultSchema>;
79
85
 
80
- const nodeHealthAggregatedSchema = z.object({
81
- avgOnlineNodes: healthResultNumber({
86
+ // Aggregated result fields definition
87
+ const nodeHealthAggregatedFields = {
88
+ avgOnlineNodes: aggregatedAverage({
82
89
  "x-chart-type": "line",
83
90
  "x-chart-label": "Avg Online Nodes",
84
91
  }),
85
- avgUtilization: healthResultNumber({
92
+ avgUtilization: aggregatedAverage({
86
93
  "x-chart-type": "gauge",
87
94
  "x-chart-label": "Avg Utilization",
88
95
  "x-chart-unit": "%",
89
96
  }),
90
- minOnlineNodes: healthResultNumber({
97
+ minOnlineNodes: aggregatedMinMax({
91
98
  "x-chart-type": "line",
92
99
  "x-chart-label": "Min Online Nodes",
93
100
  }),
94
- });
101
+ };
95
102
 
96
- export type NodeHealthAggregatedResult = z.infer<
97
- typeof nodeHealthAggregatedSchema
103
+ // Type inferred from field definitions
104
+ export type NodeHealthAggregatedResult = InferAggregatedResult<
105
+ typeof nodeHealthAggregatedFields
98
106
  >;
99
107
 
100
108
  // ============================================================================
@@ -105,15 +113,12 @@ export type NodeHealthAggregatedResult = z.infer<
105
113
  * Collector for Jenkins node/agent health.
106
114
  * Monitors node availability and executor utilization.
107
115
  */
108
- export class NodeHealthCollector
109
- implements
110
- CollectorStrategy<
111
- JenkinsTransportClient,
112
- NodeHealthConfig,
113
- NodeHealthResult,
114
- NodeHealthAggregatedResult
115
- >
116
- {
116
+ export class NodeHealthCollector implements CollectorStrategy<
117
+ JenkinsTransportClient,
118
+ NodeHealthConfig,
119
+ NodeHealthResult,
120
+ NodeHealthAggregatedResult
121
+ > {
117
122
  id = "node-health";
118
123
  displayName = "Node Health";
119
124
  description = "Monitor Jenkins agent/node availability and executor usage";
@@ -123,9 +128,9 @@ export class NodeHealthCollector
123
128
 
124
129
  config = new Versioned({ version: 1, schema: nodeHealthConfigSchema });
125
130
  result = new Versioned({ version: 1, schema: nodeHealthResultSchema });
126
- aggregatedResult = new Versioned({
131
+ aggregatedResult = new VersionedAggregated({
127
132
  version: 1,
128
- schema: nodeHealthAggregatedSchema,
133
+ fields: nodeHealthAggregatedFields,
129
134
  });
130
135
 
131
136
  async execute({
@@ -147,7 +152,7 @@ export class NodeHealthCollector
147
152
 
148
153
  private async executeForSingleNode(
149
154
  nodeName: string,
150
- client: JenkinsTransportClient
155
+ client: JenkinsTransportClient,
151
156
  ): Promise<CollectorResult<NodeHealthResult>> {
152
157
  const encodedName = encodeURIComponent(nodeName);
153
158
  const response = await client.exec({
@@ -209,7 +214,7 @@ export class NodeHealthCollector
209
214
  }
210
215
 
211
216
  private async executeForAllNodes(
212
- client: JenkinsTransportClient
217
+ client: JenkinsTransportClient,
213
218
  ): Promise<CollectorResult<NodeHealthResult>> {
214
219
  const response = await client.exec({
215
220
  path: "/computer/api/json",
@@ -275,31 +280,25 @@ export class NodeHealthCollector
275
280
  };
276
281
  }
277
282
 
278
- aggregateResult(
279
- runs: HealthCheckRunForAggregation<NodeHealthResult>[]
283
+ mergeResult(
284
+ existing: NodeHealthAggregatedResult | undefined,
285
+ run: HealthCheckRunForAggregation<NodeHealthResult>,
280
286
  ): NodeHealthAggregatedResult {
281
- const onlineNodes = runs
282
- .map((r) => r.metadata?.onlineNodes)
283
- .filter((v): v is number => typeof v === "number");
284
-
285
- const utilizations = runs
286
- .map((r) => r.metadata?.executorUtilization)
287
- .filter((v): v is number => typeof v === "number");
287
+ const metadata = run.metadata;
288
288
 
289
289
  return {
290
- avgOnlineNodes:
291
- onlineNodes.length > 0
292
- ? Math.round(
293
- onlineNodes.reduce((a, b) => a + b, 0) / onlineNodes.length
294
- )
295
- : 0,
296
- avgUtilization:
297
- utilizations.length > 0
298
- ? Math.round(
299
- utilizations.reduce((a, b) => a + b, 0) / utilizations.length
300
- )
301
- : 0,
302
- minOnlineNodes: onlineNodes.length > 0 ? Math.min(...onlineNodes) : 0,
290
+ avgOnlineNodes: mergeAverage(
291
+ existing?.avgOnlineNodes,
292
+ metadata?.onlineNodes,
293
+ ),
294
+ avgUtilization: mergeAverage(
295
+ existing?.avgUtilization,
296
+ metadata?.executorUtilization,
297
+ ),
298
+ minOnlineNodes: mergeMinMax(
299
+ existing?.minOnlineNodes,
300
+ metadata?.onlineNodes,
301
+ ),
303
302
  };
304
303
  }
305
304
  }
@@ -77,7 +77,7 @@ describe("QueueInfoCollector", () => {
77
77
  });
78
78
 
79
79
  it("should aggregate correctly", () => {
80
- const runs: Parameters<typeof collector.aggregateResult>[0] = [
80
+ const runs: Parameters<typeof collector.mergeResult>[1][] = [
81
81
  {
82
82
  status: "healthy" as const,
83
83
  latencyMs: 100,
@@ -104,10 +104,11 @@ describe("QueueInfoCollector", () => {
104
104
  },
105
105
  ];
106
106
 
107
- const aggregated = collector.aggregateResult(runs);
107
+ let aggregated = collector.mergeResult(undefined, runs[0]);
108
+ aggregated = collector.mergeResult(aggregated, runs[1]);
108
109
 
109
- expect(aggregated.avgQueueLength).toBe(4);
110
- expect(aggregated.maxQueueLength).toBe(5);
111
- expect(aggregated.avgWaitTime).toBe(15000);
110
+ expect(aggregated.avgQueueLength.avg).toBe(4);
111
+ expect(aggregated.maxQueueLength.max).toBe(5);
112
+ expect(aggregated.avgWaitTime.avg).toBe(15000);
112
113
  });
113
114
  });
@@ -4,6 +4,12 @@ import {
4
4
  type HealthCheckRunForAggregation,
5
5
  type CollectorResult,
6
6
  type CollectorStrategy,
7
+ mergeAverage,
8
+ mergeMinMax,
9
+ VersionedAggregated,
10
+ aggregatedAverage,
11
+ aggregatedMinMax,
12
+ type InferAggregatedResult,
7
13
  } from "@checkstack/backend-api";
8
14
  import { healthResultNumber } from "@checkstack/healthcheck-common";
9
15
  import { pluginMetadata } from "../plugin-metadata";
@@ -52,24 +58,26 @@ const queueInfoResultSchema = z.object({
52
58
 
53
59
  export type QueueInfoResult = z.infer<typeof queueInfoResultSchema>;
54
60
 
55
- const queueInfoAggregatedSchema = z.object({
56
- avgQueueLength: healthResultNumber({
61
+ // Aggregated result fields definition
62
+ const queueInfoAggregatedFields = {
63
+ avgQueueLength: aggregatedAverage({
57
64
  "x-chart-type": "line",
58
65
  "x-chart-label": "Avg Queue Length",
59
66
  }),
60
- maxQueueLength: healthResultNumber({
67
+ maxQueueLength: aggregatedMinMax({
61
68
  "x-chart-type": "line",
62
69
  "x-chart-label": "Max Queue Length",
63
70
  }),
64
- avgWaitTime: healthResultNumber({
71
+ avgWaitTime: aggregatedAverage({
65
72
  "x-chart-type": "line",
66
73
  "x-chart-label": "Avg Wait Time",
67
74
  "x-chart-unit": "ms",
68
75
  }),
69
- });
76
+ };
70
77
 
71
- export type QueueInfoAggregatedResult = z.infer<
72
- typeof queueInfoAggregatedSchema
78
+ // Type inferred from field definitions
79
+ export type QueueInfoAggregatedResult = InferAggregatedResult<
80
+ typeof queueInfoAggregatedFields
73
81
  >;
74
82
 
75
83
  // ============================================================================
@@ -80,15 +88,12 @@ export type QueueInfoAggregatedResult = z.infer<
80
88
  * Collector for Jenkins build queue.
81
89
  * Monitors queue length and wait times.
82
90
  */
83
- export class QueueInfoCollector
84
- implements
85
- CollectorStrategy<
86
- JenkinsTransportClient,
87
- QueueInfoConfig,
88
- QueueInfoResult,
89
- QueueInfoAggregatedResult
90
- >
91
- {
91
+ export class QueueInfoCollector implements CollectorStrategy<
92
+ JenkinsTransportClient,
93
+ QueueInfoConfig,
94
+ QueueInfoResult,
95
+ QueueInfoAggregatedResult
96
+ > {
92
97
  id = "queue-info";
93
98
  displayName = "Queue Info";
94
99
  description = "Monitor Jenkins build queue length and wait times";
@@ -97,9 +102,9 @@ export class QueueInfoCollector
97
102
 
98
103
  config = new Versioned({ version: 1, schema: queueInfoConfigSchema });
99
104
  result = new Versioned({ version: 1, schema: queueInfoResultSchema });
100
- aggregatedResult = new Versioned({
105
+ aggregatedResult = new VersionedAggregated({
101
106
  version: 1,
102
- schema: queueInfoAggregatedSchema,
107
+ fields: queueInfoAggregatedFields,
103
108
  });
104
109
 
105
110
  async execute({
@@ -187,29 +192,22 @@ export class QueueInfoCollector
187
192
  };
188
193
  }
189
194
 
190
- aggregateResult(
191
- runs: HealthCheckRunForAggregation<QueueInfoResult>[]
195
+ mergeResult(
196
+ existing: QueueInfoAggregatedResult | undefined,
197
+ run: HealthCheckRunForAggregation<QueueInfoResult>,
192
198
  ): QueueInfoAggregatedResult {
193
- const queueLengths = runs
194
- .map((r) => r.metadata?.queueLength)
195
- .filter((v): v is number => typeof v === "number");
196
-
197
- const waitTimes = runs
198
- .map((r) => r.metadata?.avgWaitingMs)
199
- .filter((v): v is number => typeof v === "number");
199
+ const metadata = run.metadata;
200
200
 
201
201
  return {
202
- avgQueueLength:
203
- queueLengths.length > 0
204
- ? Math.round(
205
- queueLengths.reduce((a, b) => a + b, 0) / queueLengths.length
206
- )
207
- : 0,
208
- maxQueueLength: queueLengths.length > 0 ? Math.max(...queueLengths) : 0,
209
- avgWaitTime:
210
- waitTimes.length > 0
211
- ? Math.round(waitTimes.reduce((a, b) => a + b, 0) / waitTimes.length)
212
- : 0,
202
+ avgQueueLength: mergeAverage(
203
+ existing?.avgQueueLength,
204
+ metadata?.queueLength,
205
+ ),
206
+ maxQueueLength: mergeMinMax(
207
+ existing?.maxQueueLength,
208
+ metadata?.queueLength,
209
+ ),
210
+ avgWaitTime: mergeAverage(existing?.avgWaitTime, metadata?.avgWaitingMs),
213
211
  };
214
212
  }
215
213
  }
@@ -9,7 +9,7 @@ describe("ServerInfoCollector", () => {
9
9
  const collector = new ServerInfoCollector();
10
10
 
11
11
  const createMockClient = (
12
- response: JenkinsResponse
12
+ response: JenkinsResponse,
13
13
  ): JenkinsTransportClient => ({
14
14
  exec: async () => response,
15
15
  });
@@ -57,7 +57,7 @@ describe("ServerInfoCollector", () => {
57
57
  });
58
58
 
59
59
  it("should aggregate results correctly", () => {
60
- const runs: Parameters<typeof collector.aggregateResult>[0] = [
60
+ const runs: Parameters<typeof collector.mergeResult>[1][] = [
61
61
  {
62
62
  status: "healthy" as const,
63
63
  latencyMs: 100,
@@ -82,9 +82,10 @@ describe("ServerInfoCollector", () => {
82
82
  },
83
83
  ];
84
84
 
85
- const aggregated = collector.aggregateResult(runs);
85
+ let aggregated = collector.mergeResult(undefined, runs[0]);
86
+ aggregated = collector.mergeResult(aggregated, runs[1]);
86
87
 
87
- expect(aggregated.avgExecutors).toBe(5);
88
- expect(aggregated.avgTotalJobs).toBe(11);
88
+ expect(aggregated.avgExecutors.avg).toBe(5);
89
+ expect(aggregated.avgTotalJobs.avg).toBe(11);
89
90
  });
90
91
  });
@@ -4,6 +4,10 @@ import {
4
4
  type HealthCheckRunForAggregation,
5
5
  type CollectorResult,
6
6
  type CollectorStrategy,
7
+ mergeAverage,
8
+ VersionedAggregated,
9
+ aggregatedAverage,
10
+ type InferAggregatedResult,
7
11
  } from "@checkstack/backend-api";
8
12
  import {
9
13
  healthResultNumber,
@@ -54,19 +58,21 @@ const serverInfoResultSchema = z.object({
54
58
 
55
59
  export type ServerInfoResult = z.infer<typeof serverInfoResultSchema>;
56
60
 
57
- const serverInfoAggregatedSchema = z.object({
58
- avgExecutors: healthResultNumber({
61
+ // Aggregated result fields definition
62
+ const serverInfoAggregatedFields = {
63
+ avgExecutors: aggregatedAverage({
59
64
  "x-chart-type": "line",
60
65
  "x-chart-label": "Avg Executors",
61
66
  }),
62
- avgTotalJobs: healthResultNumber({
67
+ avgTotalJobs: aggregatedAverage({
63
68
  "x-chart-type": "line",
64
69
  "x-chart-label": "Avg Jobs",
65
70
  }),
66
- });
71
+ };
67
72
 
68
- export type ServerInfoAggregatedResult = z.infer<
69
- typeof serverInfoAggregatedSchema
73
+ // Type inferred from field definitions
74
+ export type ServerInfoAggregatedResult = InferAggregatedResult<
75
+ typeof serverInfoAggregatedFields
70
76
  >;
71
77
 
72
78
  // ============================================================================
@@ -77,15 +83,12 @@ export type ServerInfoAggregatedResult = z.infer<
77
83
  * Built-in collector for Jenkins server information.
78
84
  * Fetches basic server health metrics via /api/json.
79
85
  */
80
- export class ServerInfoCollector
81
- implements
82
- CollectorStrategy<
83
- JenkinsTransportClient,
84
- ServerInfoConfig,
85
- ServerInfoResult,
86
- ServerInfoAggregatedResult
87
- >
88
- {
86
+ export class ServerInfoCollector implements CollectorStrategy<
87
+ JenkinsTransportClient,
88
+ ServerInfoConfig,
89
+ ServerInfoResult,
90
+ ServerInfoAggregatedResult
91
+ > {
89
92
  id = "server-info";
90
93
  displayName = "Server Info";
91
94
  description = "Collects Jenkins server information and health metrics";
@@ -94,9 +97,9 @@ export class ServerInfoCollector
94
97
 
95
98
  config = new Versioned({ version: 1, schema: serverInfoConfigSchema });
96
99
  result = new Versioned({ version: 1, schema: serverInfoResultSchema });
97
- aggregatedResult = new Versioned({
100
+ aggregatedResult = new VersionedAggregated({
98
101
  version: 1,
99
- schema: serverInfoAggregatedSchema,
102
+ fields: serverInfoAggregatedFields,
100
103
  });
101
104
 
102
105
  async execute({
@@ -144,26 +147,18 @@ export class ServerInfoCollector
144
147
  };
145
148
  }
146
149
 
147
- aggregateResult(
148
- runs: HealthCheckRunForAggregation<ServerInfoResult>[]
150
+ mergeResult(
151
+ existing: ServerInfoAggregatedResult | undefined,
152
+ run: HealthCheckRunForAggregation<ServerInfoResult>,
149
153
  ): ServerInfoAggregatedResult {
150
- const executors = runs
151
- .map((r) => r.metadata?.numExecutors)
152
- .filter((v): v is number => typeof v === "number");
153
-
154
- const jobs = runs
155
- .map((r) => r.metadata?.totalJobs)
156
- .filter((v): v is number => typeof v === "number");
154
+ const metadata = run.metadata;
157
155
 
158
156
  return {
159
- avgExecutors:
160
- executors.length > 0
161
- ? Math.round(executors.reduce((a, b) => a + b, 0) / executors.length)
162
- : 0,
163
- avgTotalJobs:
164
- jobs.length > 0
165
- ? Math.round(jobs.reduce((a, b) => a + b, 0) / jobs.length)
166
- : 0,
157
+ avgExecutors: mergeAverage(
158
+ existing?.avgExecutors,
159
+ metadata?.numExecutors,
160
+ ),
161
+ avgTotalJobs: mergeAverage(existing?.avgTotalJobs, metadata?.totalJobs),
167
162
  };
168
163
  }
169
164
  }
@@ -41,7 +41,7 @@ describe("JenkinsHealthCheckStrategy", () => {
41
41
  status: 200,
42
42
  statusText: "OK",
43
43
  headers: { "X-Jenkins": "2.426.1" },
44
- })
44
+ }),
45
45
  );
46
46
 
47
47
  const connectedClient = await strategy.createClient({
@@ -66,7 +66,7 @@ describe("JenkinsHealthCheckStrategy", () => {
66
66
  it("should include query parameters in request", async () => {
67
67
  let capturedUrl = "";
68
68
  spyOn(globalThis, "fetch").mockImplementation((async (
69
- url: RequestInfo | URL
69
+ url: RequestInfo | URL,
70
70
  ) => {
71
71
  capturedUrl = url.toString();
72
72
  return new Response(JSON.stringify({}), { status: 200 });
@@ -94,7 +94,7 @@ describe("JenkinsHealthCheckStrategy", () => {
94
94
  let capturedHeaders: Record<string, string> | undefined;
95
95
  spyOn(globalThis, "fetch").mockImplementation((async (
96
96
  _url: RequestInfo | URL,
97
- options?: RequestInit
97
+ options?: RequestInit,
98
98
  ) => {
99
99
  capturedHeaders = options?.headers as Record<string, string>;
100
100
  return new Response(JSON.stringify({}), { status: 200 });
@@ -112,7 +112,7 @@ describe("JenkinsHealthCheckStrategy", () => {
112
112
  expect(capturedHeaders?.["Authorization"]).toContain("Basic ");
113
113
  const decoded = Buffer.from(
114
114
  capturedHeaders?.["Authorization"]?.replace("Basic ", "") || "",
115
- "base64"
115
+ "base64",
116
116
  ).toString();
117
117
  expect(decoded).toBe("admin:api-token-123");
118
118
 
@@ -121,7 +121,7 @@ describe("JenkinsHealthCheckStrategy", () => {
121
121
 
122
122
  it("should return error for HTTP error response", async () => {
123
123
  spyOn(globalThis, "fetch").mockResolvedValue(
124
- new Response(null, { status: 401, statusText: "Unauthorized" })
124
+ new Response(null, { status: 401, statusText: "Unauthorized" }),
125
125
  );
126
126
 
127
127
  const connectedClient = await strategy.createClient({
@@ -160,9 +160,9 @@ describe("JenkinsHealthCheckStrategy", () => {
160
160
  });
161
161
  });
162
162
 
163
- describe("aggregateResult", () => {
163
+ describe("mergeResult", () => {
164
164
  it("should calculate success rate from runs", () => {
165
- const runs: Parameters<typeof strategy.aggregateResult>[0] = [
165
+ const runs: Parameters<typeof strategy.mergeResult>[1][] = [
166
166
  {
167
167
  status: "healthy" as const,
168
168
  latencyMs: 100,
@@ -180,19 +180,26 @@ describe("JenkinsHealthCheckStrategy", () => {
180
180
  },
181
181
  ];
182
182
 
183
- const aggregated = strategy.aggregateResult(runs);
183
+ let aggregated = strategy.mergeResult(undefined, runs[0]);
184
+ aggregated = strategy.mergeResult(aggregated, runs[1]);
185
+ aggregated = strategy.mergeResult(aggregated, runs[2]);
184
186
 
185
- expect(aggregated.successRate).toBe(67); // 2/3
186
- expect(aggregated.avgResponseTimeMs).toBe(175); // (150+200)/2
187
- expect(aggregated.errorCount).toBe(1);
187
+ expect(aggregated.successRate.rate).toBe(67); // 2/3
188
+ expect(aggregated.avgResponseTimeMs.avg).toBe(175); // (150+200)/2
189
+ expect(aggregated.errorCount.count).toBe(1);
188
190
  });
189
191
 
190
- it("should handle empty runs", () => {
191
- const aggregated = strategy.aggregateResult([]);
192
-
193
- expect(aggregated.successRate).toBe(0);
194
- expect(aggregated.avgResponseTimeMs).toBe(0);
195
- expect(aggregated.errorCount).toBe(0);
192
+ it("should handle single run", () => {
193
+ const run = {
194
+ status: "healthy" as const,
195
+ latencyMs: 100,
196
+ metadata: { connected: true, responseTimeMs: 150 },
197
+ };
198
+ const aggregated = strategy.mergeResult(undefined, run);
199
+
200
+ expect(aggregated.successRate.rate).toBe(100);
201
+ expect(aggregated.avgResponseTimeMs.avg).toBe(150);
202
+ expect(aggregated.errorCount.count).toBe(0);
196
203
  });
197
204
  });
198
205
  });
package/src/strategy.ts CHANGED
@@ -2,10 +2,18 @@ import {
2
2
  HealthCheckStrategy,
3
3
  HealthCheckRunForAggregation,
4
4
  Versioned,
5
+ VersionedAggregated,
6
+ aggregatedAverage,
7
+ aggregatedRate,
8
+ aggregatedCounter,
9
+ mergeAverage,
10
+ mergeRate,
11
+ mergeCounter,
5
12
  z,
6
13
  configString,
7
14
  configNumber,
8
15
  type ConnectedClient,
16
+ type InferAggregatedResult,
9
17
  } from "@checkstack/backend-api";
10
18
  import {
11
19
  healthResultNumber,
@@ -33,10 +41,10 @@ export const jenkinsConfigSchema = z.object({
33
41
  .url()
34
42
  .describe("Jenkins server URL (e.g., https://jenkins.example.com)"),
35
43
  username: configString({}).describe(
36
- "Jenkins username for API authentication"
44
+ "Jenkins username for API authentication",
37
45
  ),
38
46
  apiToken: configString({ "x-secret": true }).describe(
39
- "Jenkins API token (generate from User > Configure > API Token)"
47
+ "Jenkins API token (generate from User > Configure > API Token)",
40
48
  ),
41
49
  timeout: configNumber({})
42
50
  .int()
@@ -66,39 +74,38 @@ const jenkinsResultSchema = healthResultSchema({
66
74
 
67
75
  type JenkinsResult = z.infer<typeof jenkinsResultSchema>;
68
76
 
69
- /** Aggregated metadata for buckets */
70
- const jenkinsAggregatedSchema = healthResultSchema({
71
- successRate: healthResultNumber({
77
+ /** Aggregated field definitions for bucket merging */
78
+ const jenkinsAggregatedFields = {
79
+ successRate: aggregatedRate({
72
80
  "x-chart-type": "gauge",
73
81
  "x-chart-label": "Success Rate",
74
82
  "x-chart-unit": "%",
75
83
  }),
76
- avgResponseTimeMs: healthResultNumber({
84
+ avgResponseTimeMs: aggregatedAverage({
77
85
  "x-chart-type": "line",
78
86
  "x-chart-label": "Avg Response Time",
79
87
  "x-chart-unit": "ms",
80
88
  }),
81
- errorCount: healthResultNumber({
89
+ errorCount: aggregatedCounter({
82
90
  "x-chart-type": "counter",
83
91
  "x-chart-label": "Errors",
84
92
  }),
85
- });
93
+ };
86
94
 
87
- type JenkinsAggregatedResult = z.infer<typeof jenkinsAggregatedSchema>;
95
+ type JenkinsAggregatedResult = InferAggregatedResult<
96
+ typeof jenkinsAggregatedFields
97
+ >;
88
98
 
89
99
  // ============================================================================
90
100
  // STRATEGY
91
101
  // ============================================================================
92
102
 
93
- export class JenkinsHealthCheckStrategy
94
- implements
95
- HealthCheckStrategy<
96
- JenkinsConfig,
97
- JenkinsTransportClient,
98
- JenkinsResult,
99
- JenkinsAggregatedResult
100
- >
101
- {
103
+ export class JenkinsHealthCheckStrategy implements HealthCheckStrategy<
104
+ JenkinsConfig,
105
+ JenkinsTransportClient,
106
+ JenkinsResult,
107
+ typeof jenkinsAggregatedFields
108
+ > {
102
109
  id = "jenkins";
103
110
  displayName = "Jenkins Health Check";
104
111
  description = "Monitor Jenkins CI/CD server health and job status";
@@ -113,23 +120,23 @@ export class JenkinsHealthCheckStrategy
113
120
  schema: jenkinsResultSchema,
114
121
  });
115
122
 
116
- aggregatedResult: Versioned<JenkinsAggregatedResult> = new Versioned({
123
+ aggregatedResult = new VersionedAggregated({
117
124
  version: 1,
118
- schema: jenkinsAggregatedSchema,
125
+ fields: jenkinsAggregatedFields,
119
126
  });
120
127
 
121
128
  /**
122
129
  * Create a Jenkins transport client for API requests.
123
130
  */
124
131
  async createClient(
125
- config: JenkinsConfig
132
+ config: JenkinsConfig,
126
133
  ): Promise<ConnectedClient<JenkinsTransportClient>> {
127
134
  const validatedConfig = this.config.validate(config);
128
135
  const baseUrl = validatedConfig.baseUrl.replace(/\/$/, ""); // Remove trailing slash
129
136
 
130
137
  // Create Basic Auth header
131
138
  const authHeader = `Basic ${Buffer.from(
132
- `${validatedConfig.username}:${validatedConfig.apiToken}`
139
+ `${validatedConfig.username}:${validatedConfig.apiToken}`,
133
140
  ).toString("base64")}`;
134
141
 
135
142
  const client: JenkinsTransportClient = {
@@ -144,7 +151,7 @@ export class JenkinsHealthCheckStrategy
144
151
  const controller = new AbortController();
145
152
  const timeoutId = setTimeout(
146
153
  () => controller.abort(),
147
- validatedConfig.timeout
154
+ validatedConfig.timeout,
148
155
  );
149
156
 
150
157
  try {
@@ -200,31 +207,23 @@ export class JenkinsHealthCheckStrategy
200
207
  };
201
208
  }
202
209
 
203
- aggregateResult(
204
- runs: HealthCheckRunForAggregation<JenkinsResult>[]
210
+ mergeResult(
211
+ existing: JenkinsAggregatedResult | undefined,
212
+ run: HealthCheckRunForAggregation<JenkinsResult>,
205
213
  ): JenkinsAggregatedResult {
206
- const validRuns = runs.filter((r) => r.metadata);
214
+ const metadata = run.metadata;
207
215
 
208
- if (validRuns.length === 0) {
209
- return { successRate: 0, avgResponseTimeMs: 0, errorCount: 0 };
210
- }
216
+ const avgResponseTimeMs = mergeAverage(
217
+ existing?.avgResponseTimeMs,
218
+ metadata?.responseTimeMs,
219
+ );
211
220
 
212
- const responseTimes = validRuns
213
- .map((r) => r.metadata?.responseTimeMs)
214
- .filter((t): t is number => typeof t === "number");
221
+ const isSuccess = metadata?.connected ?? false;
222
+ const successRate = mergeRate(existing?.successRate, isSuccess);
215
223
 
216
- const successCount = validRuns.filter((r) => r.metadata?.connected).length;
217
- const errorCount = validRuns.filter((r) => r.metadata?.error).length;
224
+ const hasError = metadata?.error !== undefined;
225
+ const errorCount = mergeCounter(existing?.errorCount, hasError);
218
226
 
219
- return {
220
- successRate: Math.round((successCount / validRuns.length) * 100),
221
- avgResponseTimeMs:
222
- responseTimes.length > 0
223
- ? Math.round(
224
- responseTimes.reduce((a, b) => a + b, 0) / responseTimes.length
225
- )
226
- : 0,
227
- errorCount,
228
- };
227
+ return { successRate, avgResponseTimeMs, errorCount };
229
228
  }
230
229
  }