@checkstack/healthcheck-dns-backend 0.1.13 → 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,59 @@
1
1
  # @checkstack/healthcheck-dns-backend
2
2
 
3
+ ## 0.2.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.1.14
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.1.13
4
58
 
5
59
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-dns-backend",
3
- "version": "0.1.13",
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": "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
  }
@@ -4,7 +4,9 @@ import type { DnsTransportClient } from "./transport-client";
4
4
 
5
5
  describe("LookupCollector", () => {
6
6
  const createMockClient = (
7
- response: { values: string[]; error?: string } = { values: ["192.168.1.1"] }
7
+ response: { values: string[]; error?: string } = {
8
+ values: ["192.168.1.1"],
9
+ },
8
10
  ): DnsTransportClient => ({
9
11
  exec: mock(() => Promise.resolve(response)),
10
12
  });
@@ -59,7 +61,7 @@ describe("LookupCollector", () => {
59
61
  });
60
62
  });
61
63
 
62
- describe("aggregateResult", () => {
64
+ describe("mergeResult", () => {
63
65
  it("should calculate average resolution time", () => {
64
66
  const collector = new LookupCollector();
65
67
  const runs = [
@@ -89,10 +91,12 @@ describe("LookupCollector", () => {
89
91
  },
90
92
  ];
91
93
 
92
- const aggregated = collector.aggregateResult(runs);
94
+ // Merge runs incrementally
95
+ let aggregated = collector.mergeResult(undefined, runs[0]);
96
+ aggregated = collector.mergeResult(aggregated, runs[1]);
93
97
 
94
- expect(aggregated.avgResolutionTimeMs).toBe(75);
95
- expect(aggregated.successRate).toBe(100);
98
+ expect(aggregated.avgResolutionTimeMs.avg).toBe(75);
99
+ expect(aggregated.successRate.rate).toBe(100);
96
100
  });
97
101
 
98
102
  it("should calculate success rate correctly", () => {
@@ -120,9 +124,11 @@ describe("LookupCollector", () => {
120
124
  },
121
125
  ];
122
126
 
123
- const aggregated = collector.aggregateResult(runs);
127
+ // Merge runs incrementally
128
+ let aggregated = collector.mergeResult(undefined, runs[0]);
129
+ aggregated = collector.mergeResult(aggregated, runs[1]);
124
130
 
125
- expect(aggregated.successRate).toBe(50);
131
+ expect(aggregated.successRate.rate).toBe(50);
126
132
  });
127
133
  });
128
134
 
@@ -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,
@@ -50,20 +56,24 @@ const lookupResultSchema = healthResultSchema({
50
56
 
51
57
  export type LookupResult = z.infer<typeof lookupResultSchema>;
52
58
 
53
- const lookupAggregatedSchema = healthResultSchema({
54
- avgResolutionTimeMs: healthResultNumber({
59
+ // Aggregated result fields definition
60
+ const lookupAggregatedFields = {
61
+ avgResolutionTimeMs: aggregatedAverage({
55
62
  "x-chart-type": "line",
56
63
  "x-chart-label": "Avg Resolution Time",
57
64
  "x-chart-unit": "ms",
58
65
  }),
59
- successRate: healthResultNumber({
66
+ successRate: aggregatedRate({
60
67
  "x-chart-type": "gauge",
61
68
  "x-chart-label": "Success Rate",
62
69
  "x-chart-unit": "%",
63
70
  }),
64
- });
71
+ };
65
72
 
66
- export type LookupAggregatedResult = z.infer<typeof lookupAggregatedSchema>;
73
+ // Type inferred from field definitions
74
+ export type LookupAggregatedResult = InferAggregatedResult<
75
+ typeof lookupAggregatedFields
76
+ >;
67
77
 
68
78
  // ============================================================================
69
79
  // LOOKUP COLLECTOR
@@ -73,15 +83,12 @@ export type LookupAggregatedResult = z.infer<typeof lookupAggregatedSchema>;
73
83
  * Built-in DNS lookup collector.
74
84
  * Resolves DNS records and checks results.
75
85
  */
76
- export class LookupCollector
77
- implements
78
- CollectorStrategy<
79
- DnsTransportClient,
80
- LookupConfig,
81
- LookupResult,
82
- LookupAggregatedResult
83
- >
84
- {
86
+ export class LookupCollector implements CollectorStrategy<
87
+ DnsTransportClient,
88
+ LookupConfig,
89
+ LookupResult,
90
+ LookupAggregatedResult
91
+ > {
85
92
  id = "lookup";
86
93
  displayName = "DNS Lookup";
87
94
  description = "Resolve DNS records and check the results";
@@ -92,9 +99,9 @@ export class LookupCollector
92
99
 
93
100
  config = new Versioned({ version: 1, schema: lookupConfigSchema });
94
101
  result = new Versioned({ version: 1, schema: lookupResultSchema });
95
- aggregatedResult = new Versioned({
102
+ aggregatedResult = new VersionedAggregated({
96
103
  version: 1,
97
- schema: lookupAggregatedSchema,
104
+ fields: lookupAggregatedFields,
98
105
  });
99
106
 
100
107
  async execute({
@@ -124,28 +131,21 @@ export class LookupCollector
124
131
  };
125
132
  }
126
133
 
127
- aggregateResult(
128
- runs: HealthCheckRunForAggregation<LookupResult>[]
134
+ mergeResult(
135
+ existing: LookupAggregatedResult | undefined,
136
+ run: HealthCheckRunForAggregation<LookupResult>,
129
137
  ): LookupAggregatedResult {
130
- const times = runs
131
- .map((r) => r.metadata?.resolutionTimeMs)
132
- .filter((v): v is number => typeof v === "number");
133
-
134
- const recordCounts = runs
135
- .map((r) => r.metadata?.recordCount)
136
- .filter((v): v is number => typeof v === "number");
138
+ const metadata = run.metadata;
137
139
 
138
- const successCount = recordCounts.filter((c) => c > 0).length;
140
+ // Merge success rate (recordCount > 0 means success)
141
+ const isSuccess = (metadata?.recordCount ?? 0) > 0;
139
142
 
140
143
  return {
141
- avgResolutionTimeMs:
142
- times.length > 0
143
- ? Math.round(times.reduce((a, b) => a + b, 0) / times.length)
144
- : 0,
145
- successRate:
146
- recordCounts.length > 0
147
- ? Math.round((successCount / recordCounts.length) * 100)
148
- : 0,
144
+ avgResolutionTimeMs: mergeAverage(
145
+ existing?.avgResolutionTimeMs,
146
+ metadata?.resolutionTimeMs,
147
+ ),
148
+ successRate: mergeRate(existing?.successRate, isSuccess),
149
149
  };
150
150
  }
151
151
  }
@@ -15,7 +15,7 @@ describe("DnsHealthCheckStrategy", () => {
15
15
  resolveMx?: { priority: number; exchange: string }[] | Error;
16
16
  resolveTxt?: string[][] | Error;
17
17
  resolveNs?: string[] | Error;
18
- } = {}
18
+ } = {},
19
19
  ): ResolverFactory => {
20
20
  return () =>
21
21
  ({
@@ -23,34 +23,34 @@ describe("DnsHealthCheckStrategy", () => {
23
23
  resolve4: mock(() =>
24
24
  config.resolve4 instanceof Error
25
25
  ? Promise.reject(config.resolve4)
26
- : Promise.resolve(config.resolve4 ?? [])
26
+ : Promise.resolve(config.resolve4 ?? []),
27
27
  ),
28
28
  resolve6: mock(() =>
29
29
  config.resolve6 instanceof Error
30
30
  ? Promise.reject(config.resolve6)
31
- : Promise.resolve(config.resolve6 ?? [])
31
+ : Promise.resolve(config.resolve6 ?? []),
32
32
  ),
33
33
  resolveCname: mock(() =>
34
34
  config.resolveCname instanceof Error
35
35
  ? Promise.reject(config.resolveCname)
36
- : Promise.resolve(config.resolveCname ?? [])
36
+ : Promise.resolve(config.resolveCname ?? []),
37
37
  ),
38
38
  resolveMx: mock(() =>
39
39
  config.resolveMx instanceof Error
40
40
  ? Promise.reject(config.resolveMx)
41
- : Promise.resolve(config.resolveMx ?? [])
41
+ : Promise.resolve(config.resolveMx ?? []),
42
42
  ),
43
43
  resolveTxt: mock(() =>
44
44
  config.resolveTxt instanceof Error
45
45
  ? Promise.reject(config.resolveTxt)
46
- : Promise.resolve(config.resolveTxt ?? [])
46
+ : Promise.resolve(config.resolveTxt ?? []),
47
47
  ),
48
48
  resolveNs: mock(() =>
49
49
  config.resolveNs instanceof Error
50
50
  ? Promise.reject(config.resolveNs)
51
- : Promise.resolve(config.resolveNs ?? [])
51
+ : Promise.resolve(config.resolveNs ?? []),
52
52
  ),
53
- } as DnsResolver);
53
+ }) as DnsResolver;
54
54
  };
55
55
 
56
56
  describe("createClient", () => {
@@ -102,7 +102,7 @@ describe("DnsHealthCheckStrategy", () => {
102
102
  describe("client.exec", () => {
103
103
  it("should return resolved values for successful A record resolution", async () => {
104
104
  const strategy = new DnsHealthCheckStrategy(
105
- createMockResolver({ resolve4: ["1.2.3.4", "5.6.7.8"] })
105
+ createMockResolver({ resolve4: ["1.2.3.4", "5.6.7.8"] }),
106
106
  );
107
107
  const connectedClient = await strategy.createClient({ timeout: 5000 });
108
108
 
@@ -118,7 +118,7 @@ describe("DnsHealthCheckStrategy", () => {
118
118
 
119
119
  it("should return error for DNS error", async () => {
120
120
  const strategy = new DnsHealthCheckStrategy(
121
- createMockResolver({ resolve4: new Error("NXDOMAIN") })
121
+ createMockResolver({ resolve4: new Error("NXDOMAIN") }),
122
122
  );
123
123
  const connectedClient = await strategy.createClient({ timeout: 5000 });
124
124
 
@@ -139,7 +139,7 @@ describe("DnsHealthCheckStrategy", () => {
139
139
  { priority: 0, exchange: "mail1.example.com" },
140
140
  { priority: 10, exchange: "mail2.example.com" },
141
141
  ],
142
- })
142
+ }),
143
143
  );
144
144
  const connectedClient = await strategy.createClient({ timeout: 5000 });
145
145
 
@@ -154,7 +154,7 @@ describe("DnsHealthCheckStrategy", () => {
154
154
  });
155
155
  });
156
156
 
157
- describe("aggregateResult", () => {
157
+ describe("mergeResult", () => {
158
158
  it("should calculate averages correctly", () => {
159
159
  const strategy = new DnsHealthCheckStrategy();
160
160
  const runs = [
@@ -184,11 +184,13 @@ describe("DnsHealthCheckStrategy", () => {
184
184
  },
185
185
  ];
186
186
 
187
- const aggregated = strategy.aggregateResult(runs);
187
+ // Merge runs incrementally
188
+ let aggregated = strategy.mergeResult(undefined, runs[0]);
189
+ aggregated = strategy.mergeResult(aggregated, runs[1]);
188
190
 
189
- expect(aggregated.avgResolutionTime).toBe(15);
190
- expect(aggregated.failureCount).toBe(0);
191
- expect(aggregated.errorCount).toBe(0);
191
+ expect(aggregated.avgResolutionTime.avg).toBe(15);
192
+ expect(aggregated.failureCount.count).toBe(0);
193
+ expect(aggregated.errorCount.count).toBe(0);
192
194
  });
193
195
 
194
196
  it("should count failures and errors", () => {
@@ -221,10 +223,12 @@ describe("DnsHealthCheckStrategy", () => {
221
223
  },
222
224
  ];
223
225
 
224
- const aggregated = strategy.aggregateResult(runs);
226
+ // Merge runs incrementally
227
+ let aggregated = strategy.mergeResult(undefined, runs[0]);
228
+ aggregated = strategy.mergeResult(aggregated, runs[1]);
225
229
 
226
- expect(aggregated.errorCount).toBe(1);
227
- expect(aggregated.failureCount).toBe(2);
230
+ expect(aggregated.errorCount.count).toBe(1);
231
+ expect(aggregated.failureCount.count).toBe(2);
228
232
  });
229
233
  });
230
234
  });
package/src/strategy.ts CHANGED
@@ -3,8 +3,14 @@ import {
3
3
  HealthCheckStrategy,
4
4
  HealthCheckRunForAggregation,
5
5
  Versioned,
6
+ VersionedAggregated,
7
+ aggregatedCounter,
8
+ aggregatedAverage,
9
+ mergeAverage,
10
+ mergeCounter,
6
11
  z,
7
12
  type ConnectedClient,
13
+ type InferAggregatedResult,
8
14
  } from "@checkstack/backend-api";
9
15
  import {
10
16
  healthResultNumber,
@@ -70,26 +76,24 @@ const dnsResultSchema = healthResultSchema({
70
76
 
71
77
  type DnsResult = z.infer<typeof dnsResultSchema>;
72
78
 
73
- /**
74
- * Aggregated metadata for buckets.
75
- */
76
- const dnsAggregatedSchema = healthResultSchema({
77
- avgResolutionTime: healthResultNumber({
79
+ /** Aggregated field definitions for bucket merging */
80
+ const dnsAggregatedFields = {
81
+ avgResolutionTime: aggregatedAverage({
78
82
  "x-chart-type": "line",
79
83
  "x-chart-label": "Avg Resolution Time",
80
84
  "x-chart-unit": "ms",
81
85
  }),
82
- failureCount: healthResultNumber({
86
+ failureCount: aggregatedCounter({
83
87
  "x-chart-type": "counter",
84
88
  "x-chart-label": "Failures",
85
89
  }),
86
- errorCount: healthResultNumber({
90
+ errorCount: aggregatedCounter({
87
91
  "x-chart-type": "counter",
88
92
  "x-chart-label": "Errors",
89
93
  }),
90
- });
94
+ };
91
95
 
92
- type DnsAggregatedResult = z.infer<typeof dnsAggregatedSchema>;
96
+ type DnsAggregatedResult = InferAggregatedResult<typeof dnsAggregatedFields>;
93
97
 
94
98
  // ============================================================================
95
99
  // RESOLVER INTERFACE (for testability)
@@ -101,7 +105,7 @@ export interface DnsResolver {
101
105
  resolve6(hostname: string): Promise<string[]>;
102
106
  resolveCname(hostname: string): Promise<string[]>;
103
107
  resolveMx(
104
- hostname: string
108
+ hostname: string,
105
109
  ): Promise<{ priority: number; exchange: string }[]>;
106
110
  resolveTxt(hostname: string): Promise<string[][]>;
107
111
  resolveNs(hostname: string): Promise<string[]>;
@@ -116,15 +120,12 @@ const defaultResolverFactory: ResolverFactory = () => new dns.Resolver();
116
120
  // STRATEGY
117
121
  // ============================================================================
118
122
 
119
- export class DnsHealthCheckStrategy
120
- implements
121
- HealthCheckStrategy<
122
- DnsConfig,
123
- DnsTransportClient,
124
- DnsResult,
125
- DnsAggregatedResult
126
- >
127
- {
123
+ export class DnsHealthCheckStrategy implements HealthCheckStrategy<
124
+ DnsConfig,
125
+ DnsTransportClient,
126
+ DnsResult,
127
+ typeof dnsAggregatedFields
128
+ > {
128
129
  id = "dns";
129
130
  displayName = "DNS Health Check";
130
131
  description = "DNS record resolution with response validation";
@@ -164,44 +165,36 @@ export class DnsHealthCheckStrategy
164
165
  ],
165
166
  });
166
167
 
167
- aggregatedResult: Versioned<DnsAggregatedResult> = new Versioned({
168
+ aggregatedResult = new VersionedAggregated({
168
169
  version: 1,
169
- schema: dnsAggregatedSchema,
170
+ fields: dnsAggregatedFields,
170
171
  });
171
172
 
172
- aggregateResult(
173
- runs: HealthCheckRunForAggregation<DnsResult>[]
173
+ mergeResult(
174
+ existing: DnsAggregatedResult | undefined,
175
+ run: HealthCheckRunForAggregation<DnsResult>,
174
176
  ): DnsAggregatedResult {
175
- const validRuns = runs.filter((r) => r.metadata);
176
-
177
- if (validRuns.length === 0) {
178
- return { avgResolutionTime: 0, failureCount: 0, errorCount: 0 };
179
- }
180
-
181
- const resolutionTimes = validRuns
182
- .map((r) => r.metadata?.resolutionTimeMs)
183
- .filter((t): t is number => typeof t === "number");
177
+ const metadata = run.metadata;
184
178
 
185
- const avgResolutionTime =
186
- resolutionTimes.length > 0
187
- ? Math.round(
188
- resolutionTimes.reduce((a, b) => a + b, 0) / resolutionTimes.length
189
- )
190
- : 0;
179
+ // Merge resolution time average
180
+ const avgResolutionTime = mergeAverage(
181
+ existing?.avgResolutionTime,
182
+ metadata?.resolutionTimeMs,
183
+ );
191
184
 
192
- const failureCount = validRuns.filter(
193
- (r) => r.metadata?.recordCount === 0
194
- ).length;
185
+ // Merge failure count
186
+ const isFailure = metadata?.recordCount === 0;
187
+ const failureCount = mergeCounter(existing?.failureCount, isFailure);
195
188
 
196
- const errorCount = validRuns.filter(
197
- (r) => r.metadata?.error !== undefined
198
- ).length;
189
+ // Merge error count
190
+ const hasError = metadata?.error !== undefined;
191
+ const errorCount = mergeCounter(existing?.errorCount, hasError);
199
192
 
200
193
  return { avgResolutionTime, failureCount, errorCount };
201
194
  }
202
195
 
203
196
  async createClient(
204
- config: DnsConfig
197
+ config: DnsConfig,
205
198
  ): Promise<ConnectedClient<DnsTransportClient>> {
206
199
  const validatedConfig = this.config.validate(config);
207
200
  const resolver = this.resolverFactory();
@@ -214,14 +207,17 @@ export class DnsHealthCheckStrategy
214
207
  exec: async (request: DnsLookupRequest): Promise<DnsLookupResult> => {
215
208
  const timeout = validatedConfig.timeout;
216
209
  const timeoutPromise = new Promise<never>((_, reject) =>
217
- setTimeout(() => reject(new Error("DNS resolution timeout")), timeout)
210
+ setTimeout(
211
+ () => reject(new Error("DNS resolution timeout")),
212
+ timeout,
213
+ ),
218
214
  );
219
215
 
220
216
  try {
221
217
  const resolvePromise = this.resolveRecords(
222
218
  resolver,
223
219
  request.hostname,
224
- request.recordType
220
+ request.recordType,
225
221
  );
226
222
 
227
223
  const values = await Promise.race([resolvePromise, timeoutPromise]);
@@ -246,7 +242,7 @@ export class DnsHealthCheckStrategy
246
242
  private async resolveRecords(
247
243
  resolver: DnsResolver,
248
244
  hostname: string,
249
- recordType: "A" | "AAAA" | "CNAME" | "MX" | "TXT" | "NS"
245
+ recordType: "A" | "AAAA" | "CNAME" | "MX" | "TXT" | "NS",
250
246
  ): Promise<string[]> {
251
247
  switch (recordType) {
252
248
  case "A": {