@checkstack/healthcheck-dns-backend 0.1.13 → 0.1.14

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,35 @@
1
1
  # @checkstack/healthcheck-dns-backend
2
2
 
3
+ ## 0.1.14
4
+
5
+ ### Patch Changes
6
+
7
+ - 48c2080: Migrate aggregation from batch to incremental (`mergeResult`)
8
+
9
+ ### Breaking Changes (Internal)
10
+
11
+ - Replaced `aggregateResult(runs[])` with `mergeResult(existing, run)` interface across all HealthCheckStrategy and CollectorStrategy implementations
12
+
13
+ ### New Features
14
+
15
+ - Added incremental aggregation utilities in `@checkstack/backend-api`:
16
+ - `mergeCounter()` - track occurrences
17
+ - `mergeAverage()` - track sum/count, compute avg
18
+ - `mergeRate()` - track success/total, compute %
19
+ - `mergeMinMax()` - track min/max values
20
+ - Exported Zod schemas for internal state: `averageStateSchema`, `rateStateSchema`, `minMaxStateSchema`, `counterStateSchema`
21
+
22
+ ### Improvements
23
+
24
+ - Enables O(1) storage overhead by maintaining incremental aggregation state
25
+ - Prepares for real-time hourly aggregation without batch accumulation
26
+
27
+ - Updated dependencies [f676e11]
28
+ - Updated dependencies [48c2080]
29
+ - @checkstack/common@0.6.2
30
+ - @checkstack/backend-api@0.6.0
31
+ - @checkstack/healthcheck-common@0.8.2
32
+
3
33
  ## 0.1.13
4
34
 
5
35
  ### 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.1.14",
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,7 +91,9 @@ 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
98
  expect(aggregated.avgResolutionTimeMs).toBe(75);
95
99
  expect(aggregated.successRate).toBe(100);
@@ -120,7 +124,9 @@ 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
131
  expect(aggregated.successRate).toBe(50);
126
132
  });
@@ -4,6 +4,12 @@ import {
4
4
  type HealthCheckRunForAggregation,
5
5
  type CollectorResult,
6
6
  type CollectorStrategy,
7
+ mergeAverage,
8
+ mergeRate,
9
+ averageStateSchema,
10
+ rateStateSchema,
11
+ type AverageState,
12
+ type RateState,
7
13
  } from "@checkstack/backend-api";
8
14
  import {
9
15
  healthResultNumber,
@@ -50,7 +56,8 @@ const lookupResultSchema = healthResultSchema({
50
56
 
51
57
  export type LookupResult = z.infer<typeof lookupResultSchema>;
52
58
 
53
- const lookupAggregatedSchema = healthResultSchema({
59
+ // UI-visible aggregated fields (for charts)
60
+ const lookupAggregatedDisplaySchema = healthResultSchema({
54
61
  avgResolutionTimeMs: healthResultNumber({
55
62
  "x-chart-type": "line",
56
63
  "x-chart-label": "Avg Resolution Time",
@@ -63,6 +70,17 @@ const lookupAggregatedSchema = healthResultSchema({
63
70
  }),
64
71
  });
65
72
 
73
+ // Internal state for incremental aggregation (not shown in charts)
74
+ const lookupAggregatedInternalSchema = z.object({
75
+ _resolutionTime: averageStateSchema.optional(),
76
+ _success: rateStateSchema.optional(),
77
+ });
78
+
79
+ // Combined schema for storage
80
+ const lookupAggregatedSchema = lookupAggregatedDisplaySchema.merge(
81
+ lookupAggregatedInternalSchema,
82
+ );
83
+
66
84
  export type LookupAggregatedResult = z.infer<typeof lookupAggregatedSchema>;
67
85
 
68
86
  // ============================================================================
@@ -73,15 +91,12 @@ export type LookupAggregatedResult = z.infer<typeof lookupAggregatedSchema>;
73
91
  * Built-in DNS lookup collector.
74
92
  * Resolves DNS records and checks results.
75
93
  */
76
- export class LookupCollector
77
- implements
78
- CollectorStrategy<
79
- DnsTransportClient,
80
- LookupConfig,
81
- LookupResult,
82
- LookupAggregatedResult
83
- >
84
- {
94
+ export class LookupCollector implements CollectorStrategy<
95
+ DnsTransportClient,
96
+ LookupConfig,
97
+ LookupResult,
98
+ LookupAggregatedResult
99
+ > {
85
100
  id = "lookup";
86
101
  displayName = "DNS Lookup";
87
102
  description = "Resolve DNS records and check the results";
@@ -124,28 +139,30 @@ export class LookupCollector
124
139
  };
125
140
  }
126
141
 
127
- aggregateResult(
128
- runs: HealthCheckRunForAggregation<LookupResult>[]
142
+ mergeResult(
143
+ existing: LookupAggregatedResult | undefined,
144
+ run: HealthCheckRunForAggregation<LookupResult>,
129
145
  ): LookupAggregatedResult {
130
- const times = runs
131
- .map((r) => r.metadata?.resolutionTimeMs)
132
- .filter((v): v is number => typeof v === "number");
146
+ const metadata = run.metadata;
133
147
 
134
- const recordCounts = runs
135
- .map((r) => r.metadata?.recordCount)
136
- .filter((v): v is number => typeof v === "number");
148
+ // Merge resolution time average
149
+ const resolutionTimeState = mergeAverage(
150
+ existing?._resolutionTime as AverageState | undefined,
151
+ metadata?.resolutionTimeMs,
152
+ );
137
153
 
138
- const successCount = recordCounts.filter((c) => c > 0).length;
154
+ // Merge success rate (recordCount > 0 means success)
155
+ const isSuccess = (metadata?.recordCount ?? 0) > 0;
156
+ const successState = mergeRate(
157
+ existing?._success as RateState | undefined,
158
+ isSuccess,
159
+ );
139
160
 
140
161
  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,
162
+ avgResolutionTimeMs: resolutionTimeState.avg,
163
+ successRate: successState.rate,
164
+ _resolutionTime: resolutionTimeState,
165
+ _success: successState,
149
166
  };
150
167
  }
151
168
  }
@@ -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,7 +184,9 @@ 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
191
  expect(aggregated.avgResolutionTime).toBe(15);
190
192
  expect(aggregated.failureCount).toBe(0);
@@ -221,7 +223,9 @@ 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
230
  expect(aggregated.errorCount).toBe(1);
227
231
  expect(aggregated.failureCount).toBe(2);
package/src/strategy.ts CHANGED
@@ -5,6 +5,12 @@ import {
5
5
  Versioned,
6
6
  z,
7
7
  type ConnectedClient,
8
+ mergeCounter,
9
+ mergeAverage,
10
+ averageStateSchema,
11
+ counterStateSchema,
12
+ type CounterState,
13
+ type AverageState,
8
14
  } from "@checkstack/backend-api";
9
15
  import {
10
16
  healthResultNumber,
@@ -73,7 +79,8 @@ type DnsResult = z.infer<typeof dnsResultSchema>;
73
79
  /**
74
80
  * Aggregated metadata for buckets.
75
81
  */
76
- const dnsAggregatedSchema = healthResultSchema({
82
+ // UI-visible aggregated fields (for charts)
83
+ const dnsAggregatedDisplaySchema = healthResultSchema({
77
84
  avgResolutionTime: healthResultNumber({
78
85
  "x-chart-type": "line",
79
86
  "x-chart-label": "Avg Resolution Time",
@@ -89,6 +96,18 @@ const dnsAggregatedSchema = healthResultSchema({
89
96
  }),
90
97
  });
91
98
 
99
+ // Internal state for incremental aggregation (not shown in charts)
100
+ const dnsAggregatedInternalSchema = z.object({
101
+ _resolutionTime: averageStateSchema.optional(),
102
+ _failures: counterStateSchema.optional(),
103
+ _errors: counterStateSchema.optional(),
104
+ });
105
+
106
+ // Combined schema for storage
107
+ const dnsAggregatedSchema = dnsAggregatedDisplaySchema.merge(
108
+ dnsAggregatedInternalSchema,
109
+ );
110
+
92
111
  type DnsAggregatedResult = z.infer<typeof dnsAggregatedSchema>;
93
112
 
94
113
  // ============================================================================
@@ -101,7 +120,7 @@ export interface DnsResolver {
101
120
  resolve6(hostname: string): Promise<string[]>;
102
121
  resolveCname(hostname: string): Promise<string[]>;
103
122
  resolveMx(
104
- hostname: string
123
+ hostname: string,
105
124
  ): Promise<{ priority: number; exchange: string }[]>;
106
125
  resolveTxt(hostname: string): Promise<string[][]>;
107
126
  resolveNs(hostname: string): Promise<string[]>;
@@ -116,15 +135,12 @@ const defaultResolverFactory: ResolverFactory = () => new dns.Resolver();
116
135
  // STRATEGY
117
136
  // ============================================================================
118
137
 
119
- export class DnsHealthCheckStrategy
120
- implements
121
- HealthCheckStrategy<
122
- DnsConfig,
123
- DnsTransportClient,
124
- DnsResult,
125
- DnsAggregatedResult
126
- >
127
- {
138
+ export class DnsHealthCheckStrategy implements HealthCheckStrategy<
139
+ DnsConfig,
140
+ DnsTransportClient,
141
+ DnsResult,
142
+ DnsAggregatedResult
143
+ > {
128
144
  id = "dns";
129
145
  displayName = "DNS Health Check";
130
146
  description = "DNS record resolution with response validation";
@@ -169,39 +185,44 @@ export class DnsHealthCheckStrategy
169
185
  schema: dnsAggregatedSchema,
170
186
  });
171
187
 
172
- aggregateResult(
173
- runs: HealthCheckRunForAggregation<DnsResult>[]
188
+ mergeResult(
189
+ existing: DnsAggregatedResult | undefined,
190
+ run: HealthCheckRunForAggregation<DnsResult>,
174
191
  ): DnsAggregatedResult {
175
- const validRuns = runs.filter((r) => r.metadata);
176
-
177
- if (validRuns.length === 0) {
178
- return { avgResolutionTime: 0, failureCount: 0, errorCount: 0 };
179
- }
192
+ const metadata = run.metadata;
193
+
194
+ // Merge resolution time average
195
+ const resolutionTimeState = mergeAverage(
196
+ existing?._resolutionTime as AverageState | undefined,
197
+ metadata?.resolutionTimeMs,
198
+ );
199
+
200
+ // Merge failure count (recordCount === 0 means failure)
201
+ const isFailure = metadata?.recordCount === 0;
202
+ const failureState = mergeCounter(
203
+ existing?._failures as CounterState | undefined,
204
+ isFailure,
205
+ );
206
+
207
+ // Merge error count
208
+ const hasError = metadata?.error !== undefined;
209
+ const errorState = mergeCounter(
210
+ existing?._errors as CounterState | undefined,
211
+ hasError,
212
+ );
180
213
 
181
- const resolutionTimes = validRuns
182
- .map((r) => r.metadata?.resolutionTimeMs)
183
- .filter((t): t is number => typeof t === "number");
184
-
185
- const avgResolutionTime =
186
- resolutionTimes.length > 0
187
- ? Math.round(
188
- resolutionTimes.reduce((a, b) => a + b, 0) / resolutionTimes.length
189
- )
190
- : 0;
191
-
192
- const failureCount = validRuns.filter(
193
- (r) => r.metadata?.recordCount === 0
194
- ).length;
195
-
196
- const errorCount = validRuns.filter(
197
- (r) => r.metadata?.error !== undefined
198
- ).length;
199
-
200
- return { avgResolutionTime, failureCount, errorCount };
214
+ return {
215
+ avgResolutionTime: resolutionTimeState.avg,
216
+ failureCount: failureState.count,
217
+ errorCount: errorState.count,
218
+ _resolutionTime: resolutionTimeState,
219
+ _failures: failureState,
220
+ _errors: errorState,
221
+ };
201
222
  }
202
223
 
203
224
  async createClient(
204
- config: DnsConfig
225
+ config: DnsConfig,
205
226
  ): Promise<ConnectedClient<DnsTransportClient>> {
206
227
  const validatedConfig = this.config.validate(config);
207
228
  const resolver = this.resolverFactory();
@@ -214,14 +235,17 @@ export class DnsHealthCheckStrategy
214
235
  exec: async (request: DnsLookupRequest): Promise<DnsLookupResult> => {
215
236
  const timeout = validatedConfig.timeout;
216
237
  const timeoutPromise = new Promise<never>((_, reject) =>
217
- setTimeout(() => reject(new Error("DNS resolution timeout")), timeout)
238
+ setTimeout(
239
+ () => reject(new Error("DNS resolution timeout")),
240
+ timeout,
241
+ ),
218
242
  );
219
243
 
220
244
  try {
221
245
  const resolvePromise = this.resolveRecords(
222
246
  resolver,
223
247
  request.hostname,
224
- request.recordType
248
+ request.recordType,
225
249
  );
226
250
 
227
251
  const values = await Promise.race([resolvePromise, timeoutPromise]);
@@ -246,7 +270,7 @@ export class DnsHealthCheckStrategy
246
270
  private async resolveRecords(
247
271
  resolver: DnsResolver,
248
272
  hostname: string,
249
- recordType: "A" | "AAAA" | "CNAME" | "MX" | "TXT" | "NS"
273
+ recordType: "A" | "AAAA" | "CNAME" | "MX" | "TXT" | "NS",
250
274
  ): Promise<string[]> {
251
275
  switch (recordType) {
252
276
  case "A": {