@checkstack/healthcheck-backend 0.4.1 → 0.5.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 +116 -0
- package/drizzle/0007_tense_misty_knight.sql +1 -0
- package/drizzle/meta/0007_snapshot.json +413 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/aggregation-utils.test.ts +644 -0
- package/src/aggregation-utils.ts +399 -0
- package/src/aggregation.test.ts +250 -144
- package/src/index.ts +13 -2
- package/src/queue-executor.ts +10 -3
- package/src/retention-job.ts +93 -61
- package/src/router.test.ts +14 -3
- package/src/router.ts +21 -16
- package/src/schema.ts +6 -4
- package/src/service.ts +298 -132
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
calculatePercentile,
|
|
4
|
+
calculateLatencyStats,
|
|
5
|
+
countStatuses,
|
|
6
|
+
extractLatencies,
|
|
7
|
+
mergeTieredBuckets,
|
|
8
|
+
combineBuckets,
|
|
9
|
+
reaggregateBuckets,
|
|
10
|
+
type NormalizedBucket,
|
|
11
|
+
} from "./aggregation-utils";
|
|
12
|
+
|
|
13
|
+
// Helper to create a NormalizedBucket for testing
|
|
14
|
+
function createBucket(params: {
|
|
15
|
+
startMs: number;
|
|
16
|
+
durationMs: number;
|
|
17
|
+
runCount?: number;
|
|
18
|
+
healthyCount?: number;
|
|
19
|
+
degradedCount?: number;
|
|
20
|
+
unhealthyCount?: number;
|
|
21
|
+
latencySumMs?: number;
|
|
22
|
+
minLatencyMs?: number;
|
|
23
|
+
maxLatencyMs?: number;
|
|
24
|
+
p95LatencyMs?: number;
|
|
25
|
+
sourceTier: "raw" | "hourly" | "daily";
|
|
26
|
+
}): NormalizedBucket {
|
|
27
|
+
return {
|
|
28
|
+
bucketStart: new Date(params.startMs),
|
|
29
|
+
bucketEndMs: params.startMs + params.durationMs,
|
|
30
|
+
runCount: params.runCount ?? 10,
|
|
31
|
+
healthyCount: params.healthyCount ?? 8,
|
|
32
|
+
degradedCount: params.degradedCount ?? 1,
|
|
33
|
+
unhealthyCount: params.unhealthyCount ?? 1,
|
|
34
|
+
latencySumMs: params.latencySumMs ?? 1000,
|
|
35
|
+
minLatencyMs: params.minLatencyMs ?? 50,
|
|
36
|
+
maxLatencyMs: params.maxLatencyMs ?? 200,
|
|
37
|
+
p95LatencyMs: params.p95LatencyMs ?? 180,
|
|
38
|
+
sourceTier: params.sourceTier,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("aggregation-utils", () => {
|
|
43
|
+
describe("calculatePercentile", () => {
|
|
44
|
+
it("calculates p95 for sorted array", () => {
|
|
45
|
+
const values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
|
46
|
+
expect(calculatePercentile(values, 95)).toBe(10);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("calculates p50 (median)", () => {
|
|
50
|
+
const values = [1, 2, 3, 4, 5];
|
|
51
|
+
expect(calculatePercentile(values, 50)).toBe(3);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("handles single value", () => {
|
|
55
|
+
expect(calculatePercentile([42], 95)).toBe(42);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("handles unsorted array", () => {
|
|
59
|
+
const values = [5, 1, 4, 2, 3];
|
|
60
|
+
expect(calculatePercentile(values, 50)).toBe(3);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("calculateLatencyStats", () => {
|
|
65
|
+
it("returns undefined stats for empty array", () => {
|
|
66
|
+
const stats = calculateLatencyStats([]);
|
|
67
|
+
expect(stats.avgLatencyMs).toBeUndefined();
|
|
68
|
+
expect(stats.minLatencyMs).toBeUndefined();
|
|
69
|
+
expect(stats.maxLatencyMs).toBeUndefined();
|
|
70
|
+
expect(stats.p95LatencyMs).toBeUndefined();
|
|
71
|
+
expect(stats.latencySumMs).toBeUndefined();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("calculates all stats correctly", () => {
|
|
75
|
+
const stats = calculateLatencyStats([100, 200, 300]);
|
|
76
|
+
expect(stats.latencySumMs).toBe(600);
|
|
77
|
+
expect(stats.avgLatencyMs).toBe(200);
|
|
78
|
+
expect(stats.minLatencyMs).toBe(100);
|
|
79
|
+
expect(stats.maxLatencyMs).toBe(300);
|
|
80
|
+
expect(stats.p95LatencyMs).toBe(300);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("handles single value", () => {
|
|
84
|
+
const stats = calculateLatencyStats([150]);
|
|
85
|
+
expect(stats.latencySumMs).toBe(150);
|
|
86
|
+
expect(stats.avgLatencyMs).toBe(150);
|
|
87
|
+
expect(stats.minLatencyMs).toBe(150);
|
|
88
|
+
expect(stats.maxLatencyMs).toBe(150);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("rounds average correctly", () => {
|
|
92
|
+
// 100 + 101 = 201, avg = 100.5 -> rounds to 101
|
|
93
|
+
const stats = calculateLatencyStats([100, 101]);
|
|
94
|
+
expect(stats.avgLatencyMs).toBe(101);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("countStatuses", () => {
|
|
99
|
+
it("counts all status types correctly", () => {
|
|
100
|
+
const runs = [
|
|
101
|
+
{ status: "healthy" as const },
|
|
102
|
+
{ status: "healthy" as const },
|
|
103
|
+
{ status: "degraded" as const },
|
|
104
|
+
{ status: "unhealthy" as const },
|
|
105
|
+
];
|
|
106
|
+
const counts = countStatuses(runs);
|
|
107
|
+
expect(counts.healthyCount).toBe(2);
|
|
108
|
+
expect(counts.degradedCount).toBe(1);
|
|
109
|
+
expect(counts.unhealthyCount).toBe(1);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("handles empty array", () => {
|
|
113
|
+
const counts = countStatuses([]);
|
|
114
|
+
expect(counts.healthyCount).toBe(0);
|
|
115
|
+
expect(counts.degradedCount).toBe(0);
|
|
116
|
+
expect(counts.unhealthyCount).toBe(0);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("handles all healthy", () => {
|
|
120
|
+
const runs = [
|
|
121
|
+
{ status: "healthy" as const },
|
|
122
|
+
{ status: "healthy" as const },
|
|
123
|
+
];
|
|
124
|
+
const counts = countStatuses(runs);
|
|
125
|
+
expect(counts.healthyCount).toBe(2);
|
|
126
|
+
expect(counts.degradedCount).toBe(0);
|
|
127
|
+
expect(counts.unhealthyCount).toBe(0);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("ignores unknown status values", () => {
|
|
131
|
+
const runs = [
|
|
132
|
+
{ status: "healthy" as const },
|
|
133
|
+
{ status: "unknown" }, // Unknown status
|
|
134
|
+
];
|
|
135
|
+
const counts = countStatuses(runs);
|
|
136
|
+
expect(counts.healthyCount).toBe(1);
|
|
137
|
+
expect(counts.degradedCount).toBe(0);
|
|
138
|
+
expect(counts.unhealthyCount).toBe(0);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("extractLatencies", () => {
|
|
143
|
+
it("extracts latencies and filters undefined", () => {
|
|
144
|
+
const runs = [
|
|
145
|
+
{ latencyMs: 100 },
|
|
146
|
+
{ latencyMs: undefined },
|
|
147
|
+
{ latencyMs: 200 },
|
|
148
|
+
];
|
|
149
|
+
const latencies = extractLatencies(runs);
|
|
150
|
+
expect(latencies).toEqual([100, 200]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("handles empty array", () => {
|
|
154
|
+
expect(extractLatencies([])).toEqual([]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("handles all undefined latencies", () => {
|
|
158
|
+
const runs = [{ latencyMs: undefined }, {}];
|
|
159
|
+
expect(extractLatencies(runs)).toEqual([]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("handles missing latencyMs property", () => {
|
|
163
|
+
const runs = [{}, { latencyMs: 100 }];
|
|
164
|
+
expect(extractLatencies(runs)).toEqual([100]);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("edge cases - data gaps and sparse data", () => {
|
|
169
|
+
it("handles sparse latency data (gaps)", () => {
|
|
170
|
+
// Simulate data where some runs failed and have no latency
|
|
171
|
+
const runs = [
|
|
172
|
+
{ latencyMs: 100 },
|
|
173
|
+
{ latencyMs: undefined }, // Failed run, no latency
|
|
174
|
+
{ latencyMs: undefined }, // Failed run, no latency
|
|
175
|
+
{ latencyMs: 200 },
|
|
176
|
+
];
|
|
177
|
+
const latencies = extractLatencies(runs);
|
|
178
|
+
const stats = calculateLatencyStats(latencies);
|
|
179
|
+
|
|
180
|
+
expect(latencies).toHaveLength(2);
|
|
181
|
+
expect(stats.avgLatencyMs).toBe(150);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("handles runs with varying metadata schemas", () => {
|
|
185
|
+
// Simulate schema migration - older runs may have different metadata structure
|
|
186
|
+
const runs = [
|
|
187
|
+
{ status: "healthy" as const, latencyMs: 100, metadata: {} },
|
|
188
|
+
{ status: "healthy" as const, latencyMs: 200 }, // No metadata (older schema)
|
|
189
|
+
{
|
|
190
|
+
status: "healthy" as const,
|
|
191
|
+
latencyMs: 300,
|
|
192
|
+
metadata: { newField: "value" },
|
|
193
|
+
}, // New schema
|
|
194
|
+
];
|
|
195
|
+
|
|
196
|
+
const counts = countStatuses(runs);
|
|
197
|
+
const latencies = extractLatencies(runs);
|
|
198
|
+
|
|
199
|
+
expect(counts.healthyCount).toBe(3);
|
|
200
|
+
expect(latencies).toEqual([100, 200, 300]);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// ===== Cross-Tier Aggregation Tests =====
|
|
205
|
+
|
|
206
|
+
describe("mergeTieredBuckets", () => {
|
|
207
|
+
const MINUTE = 60 * 1000;
|
|
208
|
+
const HOUR = 60 * MINUTE;
|
|
209
|
+
const DAY = 24 * HOUR;
|
|
210
|
+
|
|
211
|
+
it("returns empty array when all inputs are empty", () => {
|
|
212
|
+
const result = mergeTieredBuckets({
|
|
213
|
+
rawBuckets: [],
|
|
214
|
+
hourlyBuckets: [],
|
|
215
|
+
dailyBuckets: [],
|
|
216
|
+
});
|
|
217
|
+
expect(result).toEqual([]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("returns raw buckets when only raw data exists", () => {
|
|
221
|
+
const rawBuckets = [
|
|
222
|
+
createBucket({ startMs: 0, durationMs: MINUTE, sourceTier: "raw" }),
|
|
223
|
+
createBucket({
|
|
224
|
+
startMs: MINUTE,
|
|
225
|
+
durationMs: MINUTE,
|
|
226
|
+
sourceTier: "raw",
|
|
227
|
+
}),
|
|
228
|
+
];
|
|
229
|
+
|
|
230
|
+
const result = mergeTieredBuckets({
|
|
231
|
+
rawBuckets,
|
|
232
|
+
hourlyBuckets: [],
|
|
233
|
+
dailyBuckets: [],
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
expect(result).toHaveLength(2);
|
|
237
|
+
expect(result[0].sourceTier).toBe("raw");
|
|
238
|
+
expect(result[1].sourceTier).toBe("raw");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("prefers raw over hourly for overlapping periods", () => {
|
|
242
|
+
// Raw data from 0-2 minutes
|
|
243
|
+
const rawBuckets = [
|
|
244
|
+
createBucket({ startMs: 0, durationMs: MINUTE, sourceTier: "raw" }),
|
|
245
|
+
createBucket({
|
|
246
|
+
startMs: MINUTE,
|
|
247
|
+
durationMs: MINUTE,
|
|
248
|
+
sourceTier: "raw",
|
|
249
|
+
}),
|
|
250
|
+
];
|
|
251
|
+
|
|
252
|
+
// Hourly bucket covering 0-1 hour (overlaps with raw)
|
|
253
|
+
const hourlyBuckets = [
|
|
254
|
+
createBucket({ startMs: 0, durationMs: HOUR, sourceTier: "hourly" }),
|
|
255
|
+
];
|
|
256
|
+
|
|
257
|
+
const result = mergeTieredBuckets({
|
|
258
|
+
rawBuckets,
|
|
259
|
+
hourlyBuckets,
|
|
260
|
+
dailyBuckets: [],
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// Should have raw buckets, hourly is skipped due to overlap
|
|
264
|
+
expect(result).toHaveLength(2);
|
|
265
|
+
expect(result.every((b) => b.sourceTier === "raw")).toBe(true);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("uses hourly when no raw data exists for a period", () => {
|
|
269
|
+
// Raw data from 0-2 minutes
|
|
270
|
+
const rawBuckets = [
|
|
271
|
+
createBucket({ startMs: 0, durationMs: MINUTE, sourceTier: "raw" }),
|
|
272
|
+
];
|
|
273
|
+
|
|
274
|
+
// Hourly bucket for hour 2 (no overlap)
|
|
275
|
+
const hourlyBuckets = [
|
|
276
|
+
createBucket({
|
|
277
|
+
startMs: 2 * HOUR,
|
|
278
|
+
durationMs: HOUR,
|
|
279
|
+
sourceTier: "hourly",
|
|
280
|
+
}),
|
|
281
|
+
];
|
|
282
|
+
|
|
283
|
+
const result = mergeTieredBuckets({
|
|
284
|
+
rawBuckets,
|
|
285
|
+
hourlyBuckets,
|
|
286
|
+
dailyBuckets: [],
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
expect(result).toHaveLength(2);
|
|
290
|
+
expect(result[0].sourceTier).toBe("raw");
|
|
291
|
+
expect(result[1].sourceTier).toBe("hourly");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("uses daily when no raw or hourly data exists", () => {
|
|
295
|
+
const dailyBuckets = [
|
|
296
|
+
createBucket({ startMs: 0, durationMs: DAY, sourceTier: "daily" }),
|
|
297
|
+
createBucket({ startMs: DAY, durationMs: DAY, sourceTier: "daily" }),
|
|
298
|
+
];
|
|
299
|
+
|
|
300
|
+
const result = mergeTieredBuckets({
|
|
301
|
+
rawBuckets: [],
|
|
302
|
+
hourlyBuckets: [],
|
|
303
|
+
dailyBuckets,
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
expect(result).toHaveLength(2);
|
|
307
|
+
expect(result.every((b) => b.sourceTier === "daily")).toBe(true);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("handles mixed tiers for different time periods", () => {
|
|
311
|
+
// Day 1: only daily data
|
|
312
|
+
// Day 2, hour 1: hourly data
|
|
313
|
+
// Day 2, hour 2: raw data
|
|
314
|
+
const dailyBuckets = [
|
|
315
|
+
createBucket({ startMs: 0, durationMs: DAY, sourceTier: "daily" }),
|
|
316
|
+
];
|
|
317
|
+
|
|
318
|
+
const hourlyBuckets = [
|
|
319
|
+
createBucket({
|
|
320
|
+
startMs: DAY,
|
|
321
|
+
durationMs: HOUR,
|
|
322
|
+
sourceTier: "hourly",
|
|
323
|
+
}),
|
|
324
|
+
];
|
|
325
|
+
|
|
326
|
+
const rawBuckets = [
|
|
327
|
+
createBucket({
|
|
328
|
+
startMs: DAY + 2 * HOUR,
|
|
329
|
+
durationMs: MINUTE,
|
|
330
|
+
sourceTier: "raw",
|
|
331
|
+
}),
|
|
332
|
+
];
|
|
333
|
+
|
|
334
|
+
const result = mergeTieredBuckets({
|
|
335
|
+
rawBuckets,
|
|
336
|
+
hourlyBuckets,
|
|
337
|
+
dailyBuckets,
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
expect(result).toHaveLength(3);
|
|
341
|
+
expect(result[0].sourceTier).toBe("daily");
|
|
342
|
+
expect(result[1].sourceTier).toBe("hourly");
|
|
343
|
+
expect(result[2].sourceTier).toBe("raw");
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
describe("combineBuckets", () => {
|
|
348
|
+
const HOUR = 60 * 60 * 1000;
|
|
349
|
+
|
|
350
|
+
it("returns empty bucket for empty input", () => {
|
|
351
|
+
const targetStart = new Date(0);
|
|
352
|
+
const result = combineBuckets({
|
|
353
|
+
buckets: [],
|
|
354
|
+
targetBucketStart: targetStart,
|
|
355
|
+
targetBucketEndMs: HOUR,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
expect(result.runCount).toBe(0);
|
|
359
|
+
expect(result.healthyCount).toBe(0);
|
|
360
|
+
expect(result.latencySumMs).toBeUndefined();
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("sums counts correctly", () => {
|
|
364
|
+
const buckets: NormalizedBucket[] = [
|
|
365
|
+
createBucket({
|
|
366
|
+
startMs: 0,
|
|
367
|
+
durationMs: HOUR,
|
|
368
|
+
runCount: 10,
|
|
369
|
+
healthyCount: 8,
|
|
370
|
+
degradedCount: 1,
|
|
371
|
+
unhealthyCount: 1,
|
|
372
|
+
sourceTier: "raw",
|
|
373
|
+
}),
|
|
374
|
+
createBucket({
|
|
375
|
+
startMs: HOUR,
|
|
376
|
+
durationMs: HOUR,
|
|
377
|
+
runCount: 20,
|
|
378
|
+
healthyCount: 15,
|
|
379
|
+
degradedCount: 3,
|
|
380
|
+
unhealthyCount: 2,
|
|
381
|
+
sourceTier: "raw",
|
|
382
|
+
}),
|
|
383
|
+
];
|
|
384
|
+
|
|
385
|
+
const result = combineBuckets({
|
|
386
|
+
buckets,
|
|
387
|
+
targetBucketStart: new Date(0),
|
|
388
|
+
targetBucketEndMs: 2 * HOUR,
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
expect(result.runCount).toBe(30);
|
|
392
|
+
expect(result.healthyCount).toBe(23);
|
|
393
|
+
expect(result.degradedCount).toBe(4);
|
|
394
|
+
expect(result.unhealthyCount).toBe(3);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("sums latencySumMs for accurate averaging", () => {
|
|
398
|
+
const buckets: NormalizedBucket[] = [
|
|
399
|
+
createBucket({
|
|
400
|
+
startMs: 0,
|
|
401
|
+
durationMs: HOUR,
|
|
402
|
+
latencySumMs: 1000,
|
|
403
|
+
sourceTier: "raw",
|
|
404
|
+
}),
|
|
405
|
+
createBucket({
|
|
406
|
+
startMs: HOUR,
|
|
407
|
+
durationMs: HOUR,
|
|
408
|
+
latencySumMs: 2000,
|
|
409
|
+
sourceTier: "raw",
|
|
410
|
+
}),
|
|
411
|
+
];
|
|
412
|
+
|
|
413
|
+
const result = combineBuckets({
|
|
414
|
+
buckets,
|
|
415
|
+
targetBucketStart: new Date(0),
|
|
416
|
+
targetBucketEndMs: 2 * HOUR,
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
expect(result.latencySumMs).toBe(3000);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("takes min of minLatencyMs values", () => {
|
|
423
|
+
const buckets: NormalizedBucket[] = [
|
|
424
|
+
createBucket({
|
|
425
|
+
startMs: 0,
|
|
426
|
+
durationMs: HOUR,
|
|
427
|
+
minLatencyMs: 100,
|
|
428
|
+
sourceTier: "raw",
|
|
429
|
+
}),
|
|
430
|
+
createBucket({
|
|
431
|
+
startMs: HOUR,
|
|
432
|
+
durationMs: HOUR,
|
|
433
|
+
minLatencyMs: 50,
|
|
434
|
+
sourceTier: "raw",
|
|
435
|
+
}),
|
|
436
|
+
];
|
|
437
|
+
|
|
438
|
+
const result = combineBuckets({
|
|
439
|
+
buckets,
|
|
440
|
+
targetBucketStart: new Date(0),
|
|
441
|
+
targetBucketEndMs: 2 * HOUR,
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
expect(result.minLatencyMs).toBe(50);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("takes max of maxLatencyMs values", () => {
|
|
448
|
+
const buckets: NormalizedBucket[] = [
|
|
449
|
+
createBucket({
|
|
450
|
+
startMs: 0,
|
|
451
|
+
durationMs: HOUR,
|
|
452
|
+
maxLatencyMs: 200,
|
|
453
|
+
sourceTier: "raw",
|
|
454
|
+
}),
|
|
455
|
+
createBucket({
|
|
456
|
+
startMs: HOUR,
|
|
457
|
+
durationMs: HOUR,
|
|
458
|
+
maxLatencyMs: 300,
|
|
459
|
+
sourceTier: "raw",
|
|
460
|
+
}),
|
|
461
|
+
];
|
|
462
|
+
|
|
463
|
+
const result = combineBuckets({
|
|
464
|
+
buckets,
|
|
465
|
+
targetBucketStart: new Date(0),
|
|
466
|
+
targetBucketEndMs: 2 * HOUR,
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
expect(result.maxLatencyMs).toBe(300);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it("takes max of p95LatencyMs as upper-bound approximation", () => {
|
|
473
|
+
const buckets: NormalizedBucket[] = [
|
|
474
|
+
createBucket({
|
|
475
|
+
startMs: 0,
|
|
476
|
+
durationMs: HOUR,
|
|
477
|
+
p95LatencyMs: 150,
|
|
478
|
+
sourceTier: "raw",
|
|
479
|
+
}),
|
|
480
|
+
createBucket({
|
|
481
|
+
startMs: HOUR,
|
|
482
|
+
durationMs: HOUR,
|
|
483
|
+
p95LatencyMs: 250,
|
|
484
|
+
sourceTier: "raw",
|
|
485
|
+
}),
|
|
486
|
+
];
|
|
487
|
+
|
|
488
|
+
const result = combineBuckets({
|
|
489
|
+
buckets,
|
|
490
|
+
targetBucketStart: new Date(0),
|
|
491
|
+
targetBucketEndMs: 2 * HOUR,
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
expect(result.p95LatencyMs).toBe(250);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it("tracks lowest priority tier in combined result", () => {
|
|
498
|
+
const buckets: NormalizedBucket[] = [
|
|
499
|
+
createBucket({ startMs: 0, durationMs: HOUR, sourceTier: "raw" }),
|
|
500
|
+
createBucket({ startMs: HOUR, durationMs: HOUR, sourceTier: "hourly" }),
|
|
501
|
+
];
|
|
502
|
+
|
|
503
|
+
const result = combineBuckets({
|
|
504
|
+
buckets,
|
|
505
|
+
targetBucketStart: new Date(0),
|
|
506
|
+
targetBucketEndMs: 2 * HOUR,
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// hourly has lower priority (higher number) than raw
|
|
510
|
+
expect(result.sourceTier).toBe("hourly");
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
describe("reaggregateBuckets", () => {
|
|
515
|
+
const MINUTE = 60 * 1000;
|
|
516
|
+
const HOUR = 60 * MINUTE;
|
|
517
|
+
|
|
518
|
+
it("returns empty array for empty input", () => {
|
|
519
|
+
const result = reaggregateBuckets({
|
|
520
|
+
sourceBuckets: [],
|
|
521
|
+
targetIntervalMs: HOUR,
|
|
522
|
+
rangeStart: new Date(0),
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
expect(result).toEqual([]);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it("groups minute buckets into hourly target", () => {
|
|
529
|
+
// 3 minute-buckets in the first hour
|
|
530
|
+
const sourceBuckets: NormalizedBucket[] = [
|
|
531
|
+
createBucket({
|
|
532
|
+
startMs: 0,
|
|
533
|
+
durationMs: MINUTE,
|
|
534
|
+
runCount: 10,
|
|
535
|
+
sourceTier: "raw",
|
|
536
|
+
}),
|
|
537
|
+
createBucket({
|
|
538
|
+
startMs: MINUTE,
|
|
539
|
+
durationMs: MINUTE,
|
|
540
|
+
runCount: 10,
|
|
541
|
+
sourceTier: "raw",
|
|
542
|
+
}),
|
|
543
|
+
createBucket({
|
|
544
|
+
startMs: 2 * MINUTE,
|
|
545
|
+
durationMs: MINUTE,
|
|
546
|
+
runCount: 10,
|
|
547
|
+
sourceTier: "raw",
|
|
548
|
+
}),
|
|
549
|
+
];
|
|
550
|
+
|
|
551
|
+
const result = reaggregateBuckets({
|
|
552
|
+
sourceBuckets,
|
|
553
|
+
targetIntervalMs: HOUR,
|
|
554
|
+
rangeStart: new Date(0),
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
expect(result).toHaveLength(1);
|
|
558
|
+
expect(result[0].runCount).toBe(30);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it("creates separate target buckets for different intervals", () => {
|
|
562
|
+
// 2 buckets in hour 0, 1 bucket in hour 1
|
|
563
|
+
const sourceBuckets: NormalizedBucket[] = [
|
|
564
|
+
createBucket({
|
|
565
|
+
startMs: 0,
|
|
566
|
+
durationMs: MINUTE,
|
|
567
|
+
runCount: 10,
|
|
568
|
+
sourceTier: "raw",
|
|
569
|
+
}),
|
|
570
|
+
createBucket({
|
|
571
|
+
startMs: MINUTE,
|
|
572
|
+
durationMs: MINUTE,
|
|
573
|
+
runCount: 10,
|
|
574
|
+
sourceTier: "raw",
|
|
575
|
+
}),
|
|
576
|
+
createBucket({
|
|
577
|
+
startMs: HOUR + MINUTE,
|
|
578
|
+
durationMs: MINUTE,
|
|
579
|
+
runCount: 5,
|
|
580
|
+
sourceTier: "raw",
|
|
581
|
+
}),
|
|
582
|
+
];
|
|
583
|
+
|
|
584
|
+
const result = reaggregateBuckets({
|
|
585
|
+
sourceBuckets,
|
|
586
|
+
targetIntervalMs: HOUR,
|
|
587
|
+
rangeStart: new Date(0),
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
expect(result).toHaveLength(2);
|
|
591
|
+
expect(result[0].runCount).toBe(20); // Hour 0
|
|
592
|
+
expect(result[1].runCount).toBe(5); // Hour 1
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it("aligns buckets to rangeStart", () => {
|
|
596
|
+
const rangeStart = new Date(30 * MINUTE); // Start at minute 30
|
|
597
|
+
const sourceBuckets: NormalizedBucket[] = [
|
|
598
|
+
createBucket({
|
|
599
|
+
startMs: 30 * MINUTE,
|
|
600
|
+
durationMs: MINUTE,
|
|
601
|
+
sourceTier: "raw",
|
|
602
|
+
}),
|
|
603
|
+
createBucket({
|
|
604
|
+
startMs: 31 * MINUTE,
|
|
605
|
+
durationMs: MINUTE,
|
|
606
|
+
sourceTier: "raw",
|
|
607
|
+
}),
|
|
608
|
+
];
|
|
609
|
+
|
|
610
|
+
const result = reaggregateBuckets({
|
|
611
|
+
sourceBuckets,
|
|
612
|
+
targetIntervalMs: HOUR,
|
|
613
|
+
rangeStart,
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
expect(result).toHaveLength(1);
|
|
617
|
+
expect(result[0].bucketStart.getTime()).toBe(30 * MINUTE);
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it("returns buckets sorted by start time", () => {
|
|
621
|
+
// Input in reverse order
|
|
622
|
+
const sourceBuckets: NormalizedBucket[] = [
|
|
623
|
+
createBucket({
|
|
624
|
+
startMs: 2 * HOUR,
|
|
625
|
+
durationMs: MINUTE,
|
|
626
|
+
sourceTier: "raw",
|
|
627
|
+
}),
|
|
628
|
+
createBucket({ startMs: 0, durationMs: MINUTE, sourceTier: "raw" }),
|
|
629
|
+
createBucket({ startMs: HOUR, durationMs: MINUTE, sourceTier: "raw" }),
|
|
630
|
+
];
|
|
631
|
+
|
|
632
|
+
const result = reaggregateBuckets({
|
|
633
|
+
sourceBuckets,
|
|
634
|
+
targetIntervalMs: HOUR,
|
|
635
|
+
rangeStart: new Date(0),
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
expect(result).toHaveLength(3);
|
|
639
|
+
expect(result[0].bucketStart.getTime()).toBe(0);
|
|
640
|
+
expect(result[1].bucketStart.getTime()).toBe(HOUR);
|
|
641
|
+
expect(result[2].bucketStart.getTime()).toBe(2 * HOUR);
|
|
642
|
+
});
|
|
643
|
+
});
|
|
644
|
+
});
|