@obsunified/collector 2.2.3 → 2.4.3

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.
Files changed (58) hide show
  1. package/dist/auth.d.ts +2 -1
  2. package/dist/{chunk-ZSF5XP3B.js → chunk-RASSTGNB.js} +30 -22
  3. package/dist/chunk-RASSTGNB.js.map +1 -0
  4. package/dist/index.d.ts +264 -62
  5. package/dist/index.js +3390 -2350
  6. package/dist/index.js.map +1 -1
  7. package/dist/{ingest-auth-C5-8Mly5.d.ts → ingest-auth-BTowieER.d.ts} +96 -1
  8. package/dist/{openai-GSJZGFGL.js → openai-VDKVVZAV.js} +2 -2
  9. package/package.json +4 -2
  10. package/src/__tests__/autosre-closed-loop.test.ts +110 -0
  11. package/src/__tests__/db-parity.integration.test.ts +961 -0
  12. package/src/__tests__/internal-contracts-routes.test.ts +451 -0
  13. package/src/framework/env.ts +113 -0
  14. package/src/index.ts +27 -4
  15. package/src/lib/ai-store/evaluations.test.ts +3 -3
  16. package/src/lib/alert-email.ts +104 -0
  17. package/src/lib/alerts-store.test.ts +116 -2
  18. package/src/lib/alerts-store.ts +421 -57
  19. package/src/lib/analyses-store.test.ts +6 -6
  20. package/src/lib/db-parity.ts +883 -0
  21. package/src/lib/eval-cases-store.test.ts +1 -1
  22. package/src/lib/eval-cases-store.ts +16 -4
  23. package/src/lib/evidence-references.ts +30 -22
  24. package/src/lib/metric-alerts.test.ts +769 -0
  25. package/src/lib/metric-alerts.ts +640 -0
  26. package/src/lib/metrics-store.ts +9 -2
  27. package/src/lib/sql-db-postgres.integration.test.ts +247 -0
  28. package/src/lib/stage6.test.ts +1 -1
  29. package/src/lib/store/trace-queries.ts +1 -1
  30. package/src/migrations/054_alert_rules_pending_secs.sql +11 -0
  31. package/src/migrations/055_alert_state_pending_since.sql +12 -0
  32. package/src/migrations/056_metric_point_series_received.sql +9 -0
  33. package/src/migrations/057_alert_evaluations_outcome.sql +11 -0
  34. package/src/migrations/058_alert_state_pending_seen_at.sql +12 -0
  35. package/src/migrations/059_alert_evaluations_error.sql +11 -0
  36. package/src/migrations/parity.test.ts +105 -3
  37. package/src/migrations-postgres/054_alert_rules_pending_secs.sql +7 -0
  38. package/src/migrations-postgres/055_alert_state_pending_since.sql +6 -0
  39. package/src/migrations-postgres/056_metric_point_series_received.sql +7 -0
  40. package/src/migrations-postgres/057_alert_evaluations_outcome.sql +6 -0
  41. package/src/migrations-postgres/058_alert_state_pending_seen_at.sql +6 -0
  42. package/src/migrations-postgres/059_alert_evaluations_error.sql +6 -0
  43. package/src/migrations-postgres/README.md +13 -0
  44. package/src/plugins/ai-receiver.ts +6 -1
  45. package/src/plugins/alerts-evaluator-pending.test.ts +322 -0
  46. package/src/plugins/alerts-evaluator-retention.test.ts +2 -0
  47. package/src/plugins/alerts-evaluator.test.ts +10 -2
  48. package/src/plugins/alerts-evaluator.ts +239 -40
  49. package/src/plugins/alerts-routes.ts +17 -2
  50. package/src/plugins/autosre-routes.ts +251 -140
  51. package/src/plugins/connected-routes/manifest.test.ts +114 -32
  52. package/src/plugins/connected-routes/manifest.ts +59 -76
  53. package/src/plugins/connected-routes.test.ts +10 -8
  54. package/src/plugins/connected-routes.ts +42 -12
  55. package/src/plugins/eval-cases-routes.ts +33 -10
  56. package/src/plugins/evidence-retrieval-routes.ts +60 -25
  57. package/dist/chunk-ZSF5XP3B.js.map +0 -1
  58. /package/dist/{openai-GSJZGFGL.js.map → openai-VDKVVZAV.js.map} +0 -0
@@ -0,0 +1,769 @@
1
+ /**
2
+ * `metrics` alert signal: the per-series reductions, query validation, and
3
+ * evaluation against the real migrated SQLite schema with points written by
4
+ * the OTLP metrics ingest path (`MetricsStore.ingestBatch`).
5
+ */
6
+
7
+ import type { AlertQueryMetrics, AlertRuleInput } from "@obsunified/types";
8
+ import { describe, expect, it } from "vitest";
9
+ import type { DecodedMetricPoint } from "../otlp/decode";
10
+ import { AlertsStore } from "./alerts-store";
11
+ import {
12
+ evaluateMetricQuery,
13
+ type MetricPointSample,
14
+ metricRatio,
15
+ reduceSeries,
16
+ validateMetricQuery,
17
+ } from "./metric-alerts";
18
+ import { MetricsStore } from "./metrics-store";
19
+ import { ProjectsStore } from "./projects-store";
20
+ import { createMigratedSqliteDb } from "./test-utils/node-sqlite-db";
21
+
22
+ const NOW = new Date("2026-10-31T12:00:00.000Z");
23
+ const MIN = 60_000;
24
+ const ns = (ms: number) => (BigInt(ms) * 1_000_000n).toString();
25
+ const at = (minsAgo: number) => NOW.getTime() - minsAgo * MIN;
26
+
27
+ const pt = (
28
+ minsAgo: number,
29
+ value: number | null,
30
+ over: Partial<MetricPointSample> = {},
31
+ ): MetricPointSample => ({
32
+ tsNs: ns(at(minsAgo)),
33
+ startTsNs: ns(at(600)),
34
+ value,
35
+ count: null,
36
+ sum: null,
37
+ receivedAt: new Date(at(minsAgo)).toISOString(),
38
+ ...over,
39
+ });
40
+
41
+ const reduce = (
42
+ points: MetricPointSample[],
43
+ aggregation: Parameters<typeof reduceSeries>[1]["aggregation"],
44
+ over: Partial<Parameters<typeof reduceSeries>[1]> = {},
45
+ ) =>
46
+ reduceSeries(points, {
47
+ aggregation,
48
+ field: "value",
49
+ temporality: 2,
50
+ windowStartNs: BigInt(at(5)) * 1_000_000n,
51
+ windowSecs: 300,
52
+ ...over,
53
+ });
54
+
55
+ describe("reduceSeries", () => {
56
+ // Deliberately out of order: reductions follow producer time.
57
+ const gauge = [pt(1, 7), pt(4, 3), pt(2, 5)];
58
+
59
+ it("last / min / max / avg / sum", () => {
60
+ expect(reduce(gauge, "last", { temporality: null })).toBe(7);
61
+ expect(reduce(gauge, "min", { temporality: null })).toBe(3);
62
+ expect(reduce(gauge, "max", { temporality: null })).toBe(7);
63
+ expect(reduce(gauge, "avg", { temporality: null })).toBe(5);
64
+ expect(reduce(gauge, "sum", { temporality: null })).toBe(15);
65
+ });
66
+
67
+ it("returns null for a series without usable points", () => {
68
+ expect(reduce([], "last")).toBeNull();
69
+ expect(reduce([pt(1, null)], "sum")).toBeNull();
70
+ });
71
+
72
+ it("cumulative increase sums positive deltas", () => {
73
+ expect(reduce([pt(4, 10), pt(3, 12), pt(1, 15)], "increase")).toBe(5);
74
+ // A single point of a long-running counter is only the baseline.
75
+ expect(reduce([pt(1, 15)], "increase")).toBe(0);
76
+ });
77
+
78
+ it("cumulative increase treats a drop as a counter reset", () => {
79
+ // 10 → 14 (+4), reset to 2 (+2), → 5 (+3)
80
+ expect(reduce([pt(4, 10), pt(3, 14), pt(2, 2), pt(1, 5)], "increase")).toBe(
81
+ 9,
82
+ );
83
+ });
84
+
85
+ it("cumulative increase treats a new start time as a reset", () => {
86
+ const restart = ns(at(2.5));
87
+ expect(
88
+ reduce(
89
+ [
90
+ pt(4, 10),
91
+ pt(3, 14),
92
+ pt(2, 20, { startTsNs: restart }),
93
+ pt(1, 21, { startTsNs: restart }),
94
+ ],
95
+ "increase",
96
+ ),
97
+ ).toBe(4 + 20 + 1);
98
+ });
99
+
100
+ it("counts the first point in full when the counter started in the window", () => {
101
+ const born = ns(at(3));
102
+ expect(
103
+ reduce(
104
+ [pt(2, 1, { startTsNs: born }), pt(1, 3, { startTsNs: born })],
105
+ "increase",
106
+ ),
107
+ ).toBe(3);
108
+ });
109
+
110
+ it("delta increase sums the values; rate divides by the window", () => {
111
+ const deltas = [pt(4, 2), pt(3, 0), pt(1, 4)];
112
+ expect(reduce(deltas, "increase", { temporality: 1 })).toBe(6);
113
+ expect(reduce(deltas, "rate", { temporality: 1 })).toBeCloseTo(6 / 300);
114
+ expect(reduce([pt(4, 10), pt(1, 40)], "rate")).toBeCloseTo(30 / 300);
115
+ });
116
+
117
+ it("reads histogram count and sum", () => {
118
+ const hist = [
119
+ pt(3, null, { count: 10, sum: 2.5 }),
120
+ pt(1, null, { count: 16, sum: 4 }),
121
+ ];
122
+ expect(reduce(hist, "increase", { field: "count" })).toBe(6);
123
+ expect(reduce(hist, "increase", { field: "sum" })).toBe(1.5);
124
+ expect(reduce(hist, "last", { field: "value" })).toBeNull();
125
+ });
126
+ });
127
+
128
+ describe("metricRatio", () => {
129
+ it("divides by the real denominator, only when it is > 0 and >= minDenominator", () => {
130
+ expect(metricRatio(1, 10, 5)).toBe(0.1);
131
+ expect(metricRatio(4, 4, 5)).toBe(0);
132
+ // No attempts: no ratio, even with minDenominator 0.
133
+ expect(metricRatio(3, 0, 0)).toBe(0);
134
+ expect(metricRatio(3, -1, 0)).toBe(0);
135
+ // Fractional denominators (rates) are not clamped to 1.
136
+ expect(metricRatio(0.1, 0.5, 0)).toBeCloseTo(0.2);
137
+ });
138
+ });
139
+
140
+ describe("reduceSeries first point of a cumulative series", () => {
141
+ const twoPoints = (start: string | null) => [
142
+ pt(3, 2, { startTsNs: start }),
143
+ pt(1, 5, { startTsNs: start }),
144
+ ];
145
+
146
+ it("counts from 0 when the start time is inside the window", () => {
147
+ const inWindow = ns(at(4));
148
+ expect(reduce(twoPoints(inWindow), "increase")).toBe(5);
149
+ expect(
150
+ reduce(twoPoints(inWindow), "increase", { bornInWindow: true }),
151
+ ).toBe(5);
152
+ });
153
+
154
+ it("uses the first point as the baseline when the start time is before the window, even for a new series", () => {
155
+ // e.g. a long-running counter reaching a fresh database: not a burst.
156
+ const longRunningProcess = ns(at(600));
157
+ expect(reduce(twoPoints(longRunningProcess), "increase")).toBe(3);
158
+ expect(
159
+ reduce(twoPoints(longRunningProcess), "increase", {
160
+ bornInWindow: true,
161
+ }),
162
+ ).toBe(3);
163
+ });
164
+
165
+ it("without a start time, counts from 0 only for a series born in the window", () => {
166
+ expect(reduce(twoPoints(null), "increase")).toBe(3);
167
+ expect(reduce(twoPoints(null), "increase", { bornInWindow: true })).toBe(5);
168
+ });
169
+
170
+ it("leaves delta series alone", () => {
171
+ expect(
172
+ reduce(twoPoints(null), "increase", {
173
+ bornInWindow: true,
174
+ temporality: 1,
175
+ }),
176
+ ).toBe(7);
177
+ });
178
+ });
179
+
180
+ describe("validateMetricQuery", () => {
181
+ const ok: AlertQueryMetrics = {
182
+ metric: ["a_total", "b_total"],
183
+ attributes: { route: "/x", code: 500, ok: false },
184
+ resourceAttributes: { "service.namespace": "voice" },
185
+ serviceName: "vbox",
186
+ field: "value",
187
+ aggregation: "increase",
188
+ denominator: { metric: "c_total", aggregation: "increase" },
189
+ minDenominator: 5,
190
+ };
191
+
192
+ it("accepts a full query", () => {
193
+ expect(() => validateMetricQuery(ok)).not.toThrow();
194
+ expect(() =>
195
+ validateMetricQuery({ metric: "up", aggregation: "age_seconds" }),
196
+ ).not.toThrow();
197
+ });
198
+
199
+ it.each([
200
+ [null, /query must be an object/],
201
+ [{ aggregation: "last" }, /query.metric is required/],
202
+ [{ metric: "", aggregation: "last" }, /query.metric must not be empty/],
203
+ [{ metric: [], aggregation: "last" }, /at least one metric/],
204
+ [{ metric: ["a", 3], aggregation: "last" }, /non-empty strings/],
205
+ [{ metric: "a" }, /query.aggregation must be one of/],
206
+ [{ metric: "a", aggregation: "p99" }, /query.aggregation must be one of/],
207
+ [{ metric: "a", aggregation: "last", field: "bucket" }, /query.field/],
208
+ [{ metric: "a", aggregation: "last", attributes: [] }, /query.attributes/],
209
+ [
210
+ { metric: "a", aggregation: "last", attributes: { x: { y: 1 } } },
211
+ /query.attributes.x must be a string/,
212
+ ],
213
+ [
214
+ { metric: "a", aggregation: "last", metrics: "typo" },
215
+ /query.metrics is not a metrics query field/,
216
+ ],
217
+ [
218
+ { metric: "a", aggregation: "last", minDenominator: 5 },
219
+ /minDenominator requires query.denominator/,
220
+ ],
221
+ [
222
+ {
223
+ metric: "a",
224
+ aggregation: "age_seconds",
225
+ denominator: { metric: "b" },
226
+ },
227
+ /cannot be used with age_seconds/,
228
+ ],
229
+ [
230
+ {
231
+ metric: "a",
232
+ aggregation: "increase",
233
+ denominator: { metric: "b", aggregation: "age_seconds" },
234
+ },
235
+ /query.denominator.aggregation must be one of/,
236
+ ],
237
+ [
238
+ { metric: "a", aggregation: "increase", denominator: { metric: "" } },
239
+ /query.denominator.metric must not be empty/,
240
+ ],
241
+ [
242
+ {
243
+ metric: "a",
244
+ aggregation: "increase",
245
+ denominator: { metric: "b" },
246
+ minDenominator: -1,
247
+ },
248
+ /minDenominator must be a finite number >= 0/,
249
+ ],
250
+ ])("rejects %j", (query, message) => {
251
+ expect(() => validateMetricQuery(query)).toThrow(message);
252
+ });
253
+ });
254
+
255
+ // ── Against the real SQLite schema ──────────────────────────────────────────
256
+
257
+ const decoded = (
258
+ name: string,
259
+ opts: {
260
+ type?: DecodedMetricPoint["type"];
261
+ temporality?: number | null;
262
+ attrs?: Record<string, unknown>;
263
+ resource?: Record<string, unknown>;
264
+ service?: string;
265
+ minsAgo: number;
266
+ value?: number | null;
267
+ count?: number | null;
268
+ /** Minutes before NOW the counter started; null: no start time. */
269
+ startMinsAgo?: number | null;
270
+ },
271
+ ): DecodedMetricPoint => {
272
+ const resource = { "service.name": opts.service ?? "vbox", ...opts.resource };
273
+ const attrs = opts.attrs ?? {};
274
+ return {
275
+ name,
276
+ description: null,
277
+ unit: null,
278
+ type: opts.type ?? "sum",
279
+ isMonotonic: (opts.type ?? "sum") === "sum" ? true : null,
280
+ temporality: opts.temporality === undefined ? 2 : opts.temporality,
281
+ scopeName: "test",
282
+ scopeVersion: null,
283
+ serviceName: opts.service ?? "vbox",
284
+ resourceAttrsJson: JSON.stringify(resource),
285
+ attributesJson: Object.keys(attrs).length ? JSON.stringify(attrs) : null,
286
+ identity: JSON.stringify([resource, name, attrs]),
287
+ tsNs: ns(at(opts.minsAgo)),
288
+ startTsNs:
289
+ opts.startMinsAgo === null ? null : ns(at(opts.startMinsAgo ?? 600)),
290
+ value: opts.value === undefined ? null : opts.value,
291
+ count: opts.count ?? null,
292
+ sum: null,
293
+ min: null,
294
+ max: null,
295
+ boundsJson: null,
296
+ bucketCountsJson: null,
297
+ extraJson: null,
298
+ exemplarsJson: null,
299
+ };
300
+ };
301
+
302
+ const setup = async () => {
303
+ const db = createMigratedSqliteDb();
304
+ const project = await new ProjectsStore(db).createProject({
305
+ name: "M",
306
+ slug: "m",
307
+ });
308
+ const metrics = new MetricsStore(db);
309
+ /** Each call is one export, received `minsAgo` before NOW. */
310
+ const ingest = async (minsAgo: number, points: DecodedMetricPoint[]) =>
311
+ metrics.ingestBatch({
312
+ projectId: project.id,
313
+ points,
314
+ receivedAt: new Date(at(minsAgo)).toISOString(),
315
+ expiresAt: new Date(NOW.getTime() + 24 * 60 * MIN).toISOString(),
316
+ });
317
+ const evaluateFull = (
318
+ query: AlertQueryMetrics,
319
+ windowMins = 5,
320
+ ruleCreatedAt = new Date(at(2)).toISOString(),
321
+ maxLoadedPoints?: number,
322
+ ) =>
323
+ evaluateMetricQuery(db, query, {
324
+ projectId: project.id,
325
+ windowMins,
326
+ ruleCreatedAt,
327
+ now: NOW,
328
+ maxLoadedPoints,
329
+ });
330
+ const evaluate = async (
331
+ query: AlertQueryMetrics,
332
+ windowMins = 5,
333
+ ruleCreatedAt = new Date(at(2)).toISOString(),
334
+ ) => (await evaluateFull(query, windowMins, ruleCreatedAt)).value;
335
+ return { db, project, ingest, evaluate, evaluateFull };
336
+ };
337
+
338
+ describe("evaluateMetricQuery (SQLite)", () => {
339
+ it("sums per-series increases across series and metric names, filtered by attributes", async () => {
340
+ const { db, ingest, evaluate } = await setup();
341
+ const series = (name: string, code: number, minsAgo: number, v: number) =>
342
+ decoded(name, { attrs: { code, route: "/call" }, minsAgo, value: v });
343
+ // History before the window (7 min ago): not counted, not a baseline,
344
+ // but it makes these series "not new".
345
+ await ingest(7, [
346
+ series("calls_in_total", 500, 7, 1),
347
+ series("calls_in_total", 200, 7, 40),
348
+ series("calls_out_total", 500, 7, 9),
349
+ ]);
350
+ await ingest(4, [
351
+ series("calls_in_total", 500, 4, 2),
352
+ series("calls_in_total", 200, 4, 50),
353
+ series("calls_out_total", 500, 4, 10),
354
+ ]);
355
+ await ingest(1, [
356
+ series("calls_in_total", 500, 1, 5),
357
+ series("calls_in_total", 200, 1, 80),
358
+ series("calls_out_total", 500, 1, 11),
359
+ ]);
360
+
361
+ expect(
362
+ await evaluate({
363
+ metric: ["calls_in_total", "calls_out_total"],
364
+ attributes: { code: "500" },
365
+ aggregation: "increase",
366
+ }),
367
+ ).toBe(3 + 1);
368
+ expect(
369
+ await evaluate({ metric: "calls_in_total", aggregation: "increase" }),
370
+ ).toBe(3 + 30);
371
+ expect(
372
+ await evaluate({
373
+ metric: "calls_in_total",
374
+ attributes: { code: 200, route: "/call" },
375
+ aggregation: "rate",
376
+ }),
377
+ ).toBeCloseTo(30 / 300);
378
+ // Filters that match nothing yield 0.
379
+ expect(
380
+ await evaluate({
381
+ metric: "calls_in_total",
382
+ attributes: { code: 404 },
383
+ aggregation: "increase",
384
+ }),
385
+ ).toBe(0);
386
+ expect(
387
+ await evaluate({
388
+ metric: "calls_in_total",
389
+ serviceName: "other",
390
+ aggregation: "increase",
391
+ }),
392
+ ).toBe(0);
393
+ db.close();
394
+ });
395
+
396
+ it("matches resource attributes and reads gauges and histogram counts", async () => {
397
+ const { db, ingest, evaluate } = await setup();
398
+ await ingest(9, [
399
+ decoded("turn_ms", { type: "histogram", minsAgo: 9, count: 4 }),
400
+ ]);
401
+ await ingest(3, [
402
+ decoded("pg_up", {
403
+ type: "gauge",
404
+ temporality: null,
405
+ resource: { "deployment.environment": "prod" },
406
+ minsAgo: 3,
407
+ value: 1,
408
+ }),
409
+ decoded("pg_up", {
410
+ type: "gauge",
411
+ temporality: null,
412
+ resource: { "deployment.environment": "staging" },
413
+ minsAgo: 3,
414
+ value: 1,
415
+ }),
416
+ decoded("turn_ms", {
417
+ type: "histogram",
418
+ minsAgo: 3,
419
+ count: 10,
420
+ }),
421
+ ]);
422
+ await ingest(1, [
423
+ decoded("pg_up", {
424
+ type: "gauge",
425
+ temporality: null,
426
+ resource: { "deployment.environment": "prod" },
427
+ minsAgo: 1,
428
+ value: 0,
429
+ }),
430
+ decoded("turn_ms", { type: "histogram", minsAgo: 1, count: 25 }),
431
+ ]);
432
+ expect(
433
+ await evaluate({
434
+ metric: "pg_up",
435
+ resourceAttributes: { "deployment.environment": "prod" },
436
+ aggregation: "last",
437
+ }),
438
+ ).toBe(0);
439
+ expect(await evaluate({ metric: "pg_up", aggregation: "max" })).toBe(2);
440
+ expect(
441
+ await evaluate({
442
+ metric: "turn_ms",
443
+ field: "count",
444
+ aggregation: "increase",
445
+ }),
446
+ ).toBe(15);
447
+ db.close();
448
+ });
449
+
450
+ it("handles counter resets and delta temporality from stored points", async () => {
451
+ const { db, ingest, evaluate } = await setup();
452
+ await ingest(7, [decoded("c_total", { minsAgo: 7, value: 99 })]);
453
+ await ingest(4, [decoded("c_total", { minsAgo: 4, value: 100 })]);
454
+ await ingest(3, [decoded("c_total", { minsAgo: 3, value: 104 })]);
455
+ // Process restart: new start time, counter back near zero.
456
+ await ingest(2, [
457
+ decoded("c_total", { minsAgo: 2, value: 3, startMinsAgo: 2.5 }),
458
+ ]);
459
+ await ingest(1, [
460
+ decoded("c_total", { minsAgo: 1, value: 5, startMinsAgo: 2.5 }),
461
+ ]);
462
+ const delta = (minsAgo: number, v: number) =>
463
+ decoded("d_total", { temporality: 1, minsAgo, value: v });
464
+ await ingest(3, [delta(3, 4)]);
465
+ await ingest(1, [delta(1, 6)]);
466
+ expect(await evaluate({ metric: "c_total", aggregation: "increase" })).toBe(
467
+ 4 + 3 + 2,
468
+ );
469
+ expect(await evaluate({ metric: "d_total", aggregation: "increase" })).toBe(
470
+ 10,
471
+ );
472
+ db.close();
473
+ });
474
+
475
+ it("computes an error ratio only above minDenominator", async () => {
476
+ const { db, ingest, evaluate } = await setup();
477
+ const attempts = (minsAgo: number, v: number) =>
478
+ decoded("attempts_total", { minsAgo, value: v });
479
+ const errors = (minsAgo: number, v: number) =>
480
+ decoded("errors_total", { minsAgo, value: v });
481
+ await ingest(8, [attempts(8, 100), errors(8, 7)]);
482
+ await ingest(4, [attempts(4, 100), errors(4, 7)]);
483
+ await ingest(1, [attempts(1, 104), errors(1, 9)]);
484
+ const query: AlertQueryMetrics = {
485
+ metric: "errors_total",
486
+ aggregation: "increase",
487
+ denominator: { metric: "attempts_total" },
488
+ minDenominator: 5,
489
+ };
490
+ // 4 attempts < 5: not enough traffic to judge.
491
+ expect(await evaluate(query)).toBe(0);
492
+ await ingest(0.5, [attempts(0.5, 110)]);
493
+ // 2 errors / 10 attempts
494
+ expect(await evaluate(query)).toBeCloseTo(0.2);
495
+ db.close();
496
+ });
497
+
498
+ it("age_seconds measures the newest point regardless of the window", async () => {
499
+ const { db, ingest, evaluate } = await setup();
500
+ await ingest(30, [
501
+ decoded("heartbeat", {
502
+ type: "gauge",
503
+ temporality: null,
504
+ minsAgo: 30,
505
+ value: 1,
506
+ }),
507
+ ]);
508
+ await ingest(20, [
509
+ decoded("heartbeat", {
510
+ type: "gauge",
511
+ temporality: null,
512
+ attrs: { node: "b" },
513
+ minsAgo: 20,
514
+ value: 1,
515
+ }),
516
+ ]);
517
+ expect(
518
+ await evaluate({ metric: "heartbeat", aggregation: "age_seconds" }, 1),
519
+ ).toBe(20 * 60);
520
+ expect(
521
+ await evaluate(
522
+ {
523
+ metric: "heartbeat",
524
+ attributes: { node: "b" },
525
+ aggregation: "age_seconds",
526
+ },
527
+ 1,
528
+ ),
529
+ ).toBe(20 * 60);
530
+ db.close();
531
+ });
532
+
533
+ it("age_seconds with no points at all counts from the rule's creation", async () => {
534
+ const { db, evaluate } = await setup();
535
+ const value = await evaluate(
536
+ { metric: "never_reported", aggregation: "age_seconds" },
537
+ 1,
538
+ new Date(at(3)).toISOString(),
539
+ );
540
+ expect(value).toBe(180);
541
+ expect(Number.isFinite(value)).toBe(true);
542
+ expect(JSON.parse(JSON.stringify({ value }))).toEqual({ value: 180 });
543
+ db.close();
544
+ });
545
+ });
546
+
547
+ describe("evaluateMetricQuery review fixes (SQLite)", () => {
548
+ const gauge = (name: string, host: string, minsAgo: number, value: number) =>
549
+ decoded(name, {
550
+ type: "gauge",
551
+ temporality: null,
552
+ resource: { "host.name": host },
553
+ minsAgo,
554
+ value,
555
+ });
556
+
557
+ it("decides a new series' first point by its start time", async () => {
558
+ const { db, ingest, evaluate } = await setup();
559
+ const errors = (
560
+ code: number,
561
+ minsAgo: number,
562
+ v: number,
563
+ startMinsAgo: number | null,
564
+ ) =>
565
+ decoded("errors_total", {
566
+ attrs: { code },
567
+ minsAgo,
568
+ value: v,
569
+ startMinsAgo,
570
+ });
571
+ // All three series are first seen 3 minutes ago (fresh database):
572
+ // 503: started 600 min ago, already at 30: baseline, +1
573
+ // 500: started 4 min ago (inside the window): counts 2 from 0, +1
574
+ // 502: no start time, born in the window: counts 1 from 0, +1
575
+ await ingest(3, [
576
+ errors(503, 3, 30, 600),
577
+ errors(500, 3, 2, 4),
578
+ errors(502, 3, 1, null),
579
+ ]);
580
+ await ingest(1, [
581
+ errors(503, 1, 31, 600),
582
+ errors(500, 1, 3, 4),
583
+ errors(502, 1, 2, null),
584
+ ]);
585
+ const of = (code: number) =>
586
+ evaluate({
587
+ metric: "errors_total",
588
+ attributes: { code },
589
+ aggregation: "increase",
590
+ });
591
+ expect(await of(503)).toBe(1);
592
+ expect(await of(500)).toBe(3);
593
+ expect(await of(502)).toBe(2);
594
+ db.close();
595
+ });
596
+
597
+ it("does not page an error ratio when long-running counters reach a fresh database", async () => {
598
+ const { db, ingest, evaluate } = await setup();
599
+ const c = (name: string, minsAgo: number, v: number) =>
600
+ decoded(name, { minsAgo, value: v, startMinsAgo: 6000 });
601
+ await ingest(3, [c("attempts_total", 3, 500), c("errors_total", 3, 30)]);
602
+ await ingest(1, [c("attempts_total", 1, 510), c("errors_total", 1, 30)]);
603
+ expect(
604
+ await evaluate({
605
+ metric: "errors_total",
606
+ aggregation: "increase",
607
+ denominator: { metric: "attempts_total" },
608
+ minDenominator: 5,
609
+ }),
610
+ ).toBe(0);
611
+ db.close();
612
+ });
613
+
614
+ it("drops series whose newest point is older than staleSecs", async () => {
615
+ const { db, ingest, evaluate } = await setup();
616
+ // host a stopped 8 minutes ago; host b replaced it.
617
+ await ingest(8, [gauge("active_calls", "a", 8, 4)]);
618
+ await ingest(1, [gauge("active_calls", "b", 1, 4)]);
619
+ expect(
620
+ await evaluate({ metric: "active_calls", aggregation: "last" }, 10),
621
+ ).toBe(4);
622
+ expect(
623
+ await evaluate(
624
+ { metric: "active_calls", aggregation: "last", staleSecs: 600 },
625
+ 10,
626
+ ),
627
+ ).toBe(8);
628
+ db.close();
629
+ });
630
+
631
+ it("reports no data when no series is fresh, but never for age_seconds", async () => {
632
+ const { db, ingest, evaluateFull } = await setup();
633
+ await ingest(8, [gauge("pg_up", "a", 8, 1)]);
634
+ expect(
635
+ await evaluateFull({ metric: "pg_up", aggregation: "last" }, 10),
636
+ ).toEqual({ value: 0, noData: true });
637
+ expect(
638
+ await evaluateFull({ metric: "nothing", aggregation: "increase" }),
639
+ ).toEqual({ value: 0, noData: true });
640
+ expect(
641
+ await evaluateFull({ metric: "pg_up", aggregation: "age_seconds" }, 1),
642
+ ).toEqual({ value: 480, noData: false });
643
+ expect(
644
+ await evaluateFull({ metric: "nothing", aggregation: "age_seconds" }, 1),
645
+ ).toMatchObject({ noData: false });
646
+ db.close();
647
+ });
648
+
649
+ it("a ratio has data when either side does", async () => {
650
+ const { db, ingest, evaluateFull } = await setup();
651
+ const attempts = (minsAgo: number, v: number) =>
652
+ decoded("attempts_total", { minsAgo, value: v });
653
+ await ingest(9, [attempts(9, 10)]);
654
+ await ingest(2, [attempts(2, 20)]);
655
+ expect(
656
+ await evaluateFull({
657
+ metric: "errors_total",
658
+ aggregation: "increase",
659
+ denominator: { metric: "attempts_total" },
660
+ }),
661
+ ).toEqual({ value: 0, noData: false });
662
+ db.close();
663
+ });
664
+
665
+ it("fails instead of computing a partial value past the point cap", async () => {
666
+ const { db, ingest, evaluateFull } = await setup();
667
+ for (const minsAgo of [4, 3, 2, 1]) {
668
+ await ingest(minsAgo, [gauge("busy", "a", minsAgo, 1)]);
669
+ }
670
+ expect(
671
+ (
672
+ await evaluateFull(
673
+ { metric: "busy", aggregation: "sum" },
674
+ 5,
675
+ undefined,
676
+ 4,
677
+ )
678
+ ).value,
679
+ ).toBe(4);
680
+ await expect(
681
+ evaluateFull({ metric: "busy", aggregation: "sum" }, 5, undefined, 3),
682
+ ).rejects.toThrow(/loads more than 3 points/);
683
+ db.close();
684
+ });
685
+ });
686
+
687
+ describe("AlertsStore with metrics rules (SQLite)", () => {
688
+ const input = (over: Partial<AlertRuleInput> = {}): AlertRuleInput => ({
689
+ name: "pg down",
690
+ signal: "metrics",
691
+ query: { metric: "pg_up", aggregation: "last" },
692
+ threshold: 0,
693
+ windowMins: 1,
694
+ comparison: "==",
695
+ channels: [{ type: "webhook", url: "https://hooks.example.com/a" }],
696
+ pendingSecs: 30,
697
+ ...over,
698
+ });
699
+
700
+ it("stores, lists and evaluates a metrics rule with pendingSecs", async () => {
701
+ const { db, project, ingest } = await setup();
702
+ const store = new AlertsStore(db);
703
+ const rule = await store.createRule(project.id, input());
704
+ expect(rule).toMatchObject({
705
+ signal: "metrics",
706
+ comparison: "==",
707
+ pendingSecs: 30,
708
+ pendingSince: null,
709
+ query: { metric: "pg_up", aggregation: "last" },
710
+ });
711
+ await ingest(0.5, [
712
+ decoded("pg_up", {
713
+ type: "gauge",
714
+ temporality: null,
715
+ minsAgo: 0.5,
716
+ value: 0,
717
+ }),
718
+ ]);
719
+ expect(await store.evaluateRule(rule, NOW)).toBe(0);
720
+ expect(await store.evaluateRuleWithStatus(rule, NOW)).toEqual({
721
+ value: 0,
722
+ noData: false,
723
+ });
724
+ const updated = await store.updateRule(rule.id, project.id, {
725
+ pendingSecs: 0,
726
+ });
727
+ expect(updated?.pendingSecs).toBe(0);
728
+ expect((await store.listRules(project.id))[0].pendingSecs).toBe(0);
729
+ db.close();
730
+ });
731
+
732
+ it("rejects malformed metric queries and pending durations", async () => {
733
+ const { db, project } = await setup();
734
+ const store = new AlertsStore(db);
735
+ await expect(
736
+ store.createRule(project.id, input({ query: { metric: "x" } as never })),
737
+ ).rejects.toThrow(/query.aggregation must be one of/);
738
+ await expect(
739
+ store.createRule(project.id, input({ pendingSecs: 1.5 })),
740
+ ).rejects.toThrow(/pendingSecs must be an integer/);
741
+ await expect(
742
+ store.createRule(project.id, input({ pendingSecs: 90_000 })),
743
+ ).rejects.toThrow(/pendingSecs must be an integer from 0 to 86400/);
744
+ await expect(
745
+ store.createRule(project.id, input({ windowMins: 1441 })),
746
+ ).rejects.toThrow(/windowMins must be <= 1440 for the metrics signal/);
747
+ await expect(
748
+ store.createRule(
749
+ project.id,
750
+ input({
751
+ query: { metric: "x", aggregation: "last", staleSecs: 0 },
752
+ }),
753
+ ),
754
+ ).rejects.toThrow(/staleSecs must be an integer from 1 to 86400/);
755
+ await expect(
756
+ store.createRule(
757
+ project.id,
758
+ input({
759
+ query: {
760
+ metric: "x",
761
+ aggregation: "last",
762
+ noData: "ignore",
763
+ } as never,
764
+ }),
765
+ ),
766
+ ).rejects.toThrow(/noData must be one of: zero, keep/);
767
+ db.close();
768
+ });
769
+ });