@obsunified/collector 2.3.0 → 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 (34) hide show
  1. package/dist/auth.d.ts +2 -1
  2. package/dist/index.d.ts +264 -62
  3. package/dist/index.js +850 -61
  4. package/dist/index.js.map +1 -1
  5. package/dist/{ingest-auth-BmP5-f96.d.ts → ingest-auth-BTowieER.d.ts} +66 -2
  6. package/package.json +2 -2
  7. package/src/framework/env.ts +9 -1
  8. package/src/index.ts +14 -0
  9. package/src/lib/alert-email.ts +104 -0
  10. package/src/lib/alerts-store.test.ts +116 -2
  11. package/src/lib/alerts-store.ts +421 -57
  12. package/src/lib/metric-alerts.test.ts +769 -0
  13. package/src/lib/metric-alerts.ts +640 -0
  14. package/src/lib/metrics-store.ts +9 -2
  15. package/src/lib/sql-db-postgres.integration.test.ts +247 -0
  16. package/src/lib/store/trace-queries.ts +1 -1
  17. package/src/migrations/054_alert_rules_pending_secs.sql +11 -0
  18. package/src/migrations/055_alert_state_pending_since.sql +12 -0
  19. package/src/migrations/056_metric_point_series_received.sql +9 -0
  20. package/src/migrations/057_alert_evaluations_outcome.sql +11 -0
  21. package/src/migrations/058_alert_state_pending_seen_at.sql +12 -0
  22. package/src/migrations/059_alert_evaluations_error.sql +11 -0
  23. package/src/migrations-postgres/054_alert_rules_pending_secs.sql +7 -0
  24. package/src/migrations-postgres/055_alert_state_pending_since.sql +6 -0
  25. package/src/migrations-postgres/056_metric_point_series_received.sql +7 -0
  26. package/src/migrations-postgres/057_alert_evaluations_outcome.sql +6 -0
  27. package/src/migrations-postgres/058_alert_state_pending_seen_at.sql +6 -0
  28. package/src/migrations-postgres/059_alert_evaluations_error.sql +6 -0
  29. package/src/migrations-postgres/README.md +13 -0
  30. package/src/plugins/alerts-evaluator-pending.test.ts +322 -0
  31. package/src/plugins/alerts-evaluator-retention.test.ts +2 -0
  32. package/src/plugins/alerts-evaluator.test.ts +10 -2
  33. package/src/plugins/alerts-evaluator.ts +239 -40
  34. package/src/plugins/alerts-routes.ts +17 -2
@@ -0,0 +1,640 @@
1
+ /**
2
+ * `metrics` alert signal: validation and evaluation of an
3
+ * `AlertQueryMetrics` against `metric_series` / `metric_point`.
4
+ *
5
+ * Evaluation, per selector (numerator, then the optional denominator):
6
+ *
7
+ * 1. Series: `metric_series` rows of the project whose name is one of the
8
+ * selector's names (and whose `service_name` matches, when given), kept
9
+ * when every listed data-point / resource attribute equals the rule's
10
+ * value. Attribute values are compared as strings, so `200` matches a
11
+ * stored `"200"` and vice versa.
12
+ * 2. Points: the series' points whose `received_at` (the collector's clock,
13
+ * as for every other alert signal) is inside the rule window, ordered by
14
+ * producer time (`ts_ns`). The number read is `field` (`value`, or a
15
+ * histogram/summary `count` / `sum`); points without it are skipped.
16
+ * 3. Staleness: for `last`, `min`, `max`, `avg` and `sum` a series takes
17
+ * part only when its newest point is at most `staleSecs` (default 300)
18
+ * old, so a series that stopped reporting (a renamed host, a replaced
19
+ * pod) no longer adds its last value. `increase` / `rate` keep every
20
+ * series with points in the window: growth that happened is real even
21
+ * if the series stopped afterwards.
22
+ * 4. Each series is reduced on its own (`reduceSeries`), and the per-series
23
+ * results are summed. A series with no usable point adds nothing.
24
+ *
25
+ * When no series takes part (in the numerator or the denominator) the
26
+ * result is "no data": the value is 0 and `noData` is true. The rule's
27
+ * `noData` policy decides what that means (`zero`: evaluate 0 as usual;
28
+ * `keep`: leave the alert state as it is).
29
+ *
30
+ * `age_seconds` ignores the window: it is the seconds since the newest
31
+ * `received_at` of any matching series. When no matching series has any
32
+ * stored point (never reported, or everything aged out of retention) it is
33
+ * the seconds since the rule was created, a finite number that grows until
34
+ * the threshold is crossed, so "never reported" fires after the same grace
35
+ * period as "stopped reporting".
36
+ *
37
+ * `age_seconds` is never "no data".
38
+ *
39
+ * Ratio: with a `denominator`, value = numerator / denominator when the
40
+ * denominator is > 0 and >= minDenominator (default 0), else 0.
41
+ *
42
+ * Cost bounds: at most `MAX_MATCHED_SERIES` series and `MAX_LOADED_POINTS`
43
+ * points per selector; past either the evaluation fails with an error (the
44
+ * evaluator records it as an `error` evaluation) rather than returning a
45
+ * value computed from part of the data. Metric rule windows are at most
46
+ * `MAX_METRIC_WINDOW_MINS`.
47
+ */
48
+
49
+ import type {
50
+ AlertMetricAggregation,
51
+ AlertMetricAttributeValue,
52
+ AlertMetricField,
53
+ AlertMetricSelector,
54
+ AlertQueryMetrics,
55
+ } from "@obsunified/types";
56
+ import type { SqlDb } from "./sql-db";
57
+
58
+ export const METRIC_AGGREGATIONS: readonly AlertMetricAggregation[] = [
59
+ "last",
60
+ "min",
61
+ "max",
62
+ "avg",
63
+ "sum",
64
+ "increase",
65
+ "rate",
66
+ "age_seconds",
67
+ ];
68
+
69
+ export const METRIC_FIELDS: readonly AlertMetricField[] = [
70
+ "value",
71
+ "count",
72
+ "sum",
73
+ ];
74
+
75
+ /** OTLP `AggregationTemporality`. */
76
+ const TEMPORALITY_DELTA = 1;
77
+
78
+ const MAX_METRIC_NAMES = 20;
79
+ const MAX_ATTRIBUTE_KEYS = 32;
80
+ /** A rule matching more series than this is refused at evaluation time. */
81
+ export const MAX_MATCHED_SERIES = 1000;
82
+ /** Points one selector may load per evaluation before it is refused. */
83
+ export const MAX_LOADED_POINTS = 50_000;
84
+ /** Longest window (`windowMins`) a metrics rule may use: one day. */
85
+ export const MAX_METRIC_WINDOW_MINS = 1440;
86
+ export const DEFAULT_STALE_SECS = 300;
87
+ export const MAX_STALE_SECS = 86_400;
88
+ export const METRIC_NO_DATA_POLICIES = ["zero", "keep"] as const;
89
+ /** Keeps each statement under D1's ~100 bound-parameter limit. */
90
+ const ID_CHUNK = 50;
91
+
92
+ const SELECTOR_KEYS = [
93
+ "metric",
94
+ "attributes",
95
+ "resourceAttributes",
96
+ "serviceName",
97
+ "field",
98
+ ];
99
+ const QUERY_KEYS = [
100
+ ...SELECTOR_KEYS,
101
+ "aggregation",
102
+ "denominator",
103
+ "minDenominator",
104
+ "staleSecs",
105
+ "noData",
106
+ ];
107
+ const DENOMINATOR_KEYS = [...SELECTOR_KEYS, "aggregation"];
108
+
109
+ const isPlainObject = (v: unknown): v is Record<string, unknown> =>
110
+ typeof v === "object" && v !== null && !Array.isArray(v);
111
+
112
+ const validateAttributes = (value: unknown, path: string): void => {
113
+ if (value === undefined) return;
114
+ if (!isPlainObject(value))
115
+ throw new Error(`${path} must be an object of attribute: value pairs`);
116
+ const entries = Object.entries(value);
117
+ if (entries.length > MAX_ATTRIBUTE_KEYS)
118
+ throw new Error(`${path} may list at most ${MAX_ATTRIBUTE_KEYS} keys`);
119
+ for (const [key, v] of entries) {
120
+ if (!key.trim()) throw new Error(`${path} has an empty attribute name`);
121
+ const ok =
122
+ typeof v === "string" ||
123
+ typeof v === "boolean" ||
124
+ (typeof v === "number" && Number.isFinite(v));
125
+ if (!ok)
126
+ throw new Error(
127
+ `${path}.${key} must be a string, finite number or boolean`,
128
+ );
129
+ }
130
+ };
131
+
132
+ const validateSelector = (
133
+ q: Record<string, unknown>,
134
+ path: string,
135
+ allowedKeys: string[],
136
+ ): void => {
137
+ for (const key of Object.keys(q)) {
138
+ if (!allowedKeys.includes(key))
139
+ throw new Error(
140
+ `${path}.${key} is not a metrics query field (allowed: ${allowedKeys.join(", ")})`,
141
+ );
142
+ }
143
+ const metric = q.metric;
144
+ if (typeof metric === "string") {
145
+ if (!metric.trim()) throw new Error(`${path}.metric must not be empty`);
146
+ } else if (Array.isArray(metric)) {
147
+ if (metric.length === 0)
148
+ throw new Error(`${path}.metric must name at least one metric`);
149
+ if (metric.length > MAX_METRIC_NAMES)
150
+ throw new Error(
151
+ `${path}.metric may name at most ${MAX_METRIC_NAMES} metrics`,
152
+ );
153
+ for (const m of metric) {
154
+ if (typeof m !== "string" || !m.trim())
155
+ throw new Error(`${path}.metric entries must be non-empty strings`);
156
+ }
157
+ } else {
158
+ throw new Error(
159
+ `${path}.metric is required: a metric name or a list of names`,
160
+ );
161
+ }
162
+ validateAttributes(q.attributes, `${path}.attributes`);
163
+ validateAttributes(q.resourceAttributes, `${path}.resourceAttributes`);
164
+ if (
165
+ q.serviceName !== undefined &&
166
+ (typeof q.serviceName !== "string" || !q.serviceName.trim())
167
+ )
168
+ throw new Error(`${path}.serviceName must be a non-empty string`);
169
+ if (
170
+ q.field !== undefined &&
171
+ !METRIC_FIELDS.includes(q.field as AlertMetricField)
172
+ )
173
+ throw new Error(
174
+ `${path}.field must be one of: ${METRIC_FIELDS.join(", ")}`,
175
+ );
176
+ };
177
+
178
+ /** Throws an Error with a user-facing message when `query` is malformed. */
179
+ export function validateMetricQuery(query: unknown): void {
180
+ if (!isPlainObject(query))
181
+ throw new Error("query must be an object for the metrics signal");
182
+ validateSelector(query, "query", QUERY_KEYS);
183
+ const aggregation = query.aggregation;
184
+ if (!METRIC_AGGREGATIONS.includes(aggregation as AlertMetricAggregation))
185
+ throw new Error(
186
+ `query.aggregation must be one of: ${METRIC_AGGREGATIONS.join(", ")}`,
187
+ );
188
+ if (
189
+ query.staleSecs !== undefined &&
190
+ (!Number.isInteger(query.staleSecs) ||
191
+ (query.staleSecs as number) < 1 ||
192
+ (query.staleSecs as number) > MAX_STALE_SECS)
193
+ )
194
+ throw new Error(
195
+ `query.staleSecs must be an integer from 1 to ${MAX_STALE_SECS}`,
196
+ );
197
+ if (
198
+ query.noData !== undefined &&
199
+ !METRIC_NO_DATA_POLICIES.includes(query.noData as "zero" | "keep")
200
+ )
201
+ throw new Error(
202
+ `query.noData must be one of: ${METRIC_NO_DATA_POLICIES.join(", ")}`,
203
+ );
204
+ if (query.denominator === undefined) {
205
+ if (query.minDenominator !== undefined)
206
+ throw new Error("query.minDenominator requires query.denominator");
207
+ return;
208
+ }
209
+ if (aggregation === "age_seconds")
210
+ throw new Error("query.denominator cannot be used with age_seconds");
211
+ if (!isPlainObject(query.denominator))
212
+ throw new Error("query.denominator must be an object");
213
+ validateSelector(query.denominator, "query.denominator", DENOMINATOR_KEYS);
214
+ const denAgg = query.denominator.aggregation;
215
+ if (denAgg !== undefined) {
216
+ if (
217
+ denAgg === "age_seconds" ||
218
+ !METRIC_AGGREGATIONS.includes(denAgg as AlertMetricAggregation)
219
+ )
220
+ throw new Error(
221
+ `query.denominator.aggregation must be one of: ${METRIC_AGGREGATIONS.filter((a) => a !== "age_seconds").join(", ")}`,
222
+ );
223
+ }
224
+ if (
225
+ query.minDenominator !== undefined &&
226
+ (typeof query.minDenominator !== "number" ||
227
+ !Number.isFinite(query.minDenominator) ||
228
+ query.minDenominator < 0)
229
+ )
230
+ throw new Error("query.minDenominator must be a finite number >= 0");
231
+ }
232
+
233
+ // ── Pure reduction ─────────────────────────────────────────────────────────
234
+
235
+ export interface MetricPointSample {
236
+ tsNs: string;
237
+ startTsNs: string | null;
238
+ value: number | null;
239
+ count: number | null;
240
+ sum: number | null;
241
+ receivedAt: string;
242
+ }
243
+
244
+ export interface SeriesReduceOptions {
245
+ /**
246
+ * The series has no history before the window (its `metric_series` row
247
+ * was created inside it): a cumulative counter's first point then counts
248
+ * as growth from 0 instead of being the baseline.
249
+ */
250
+ bornInWindow?: boolean;
251
+ aggregation: Exclude<AlertMetricAggregation, "age_seconds">;
252
+ field: AlertMetricField;
253
+ /** `metric_series.temporality`: 1 delta, 2 cumulative, null for gauges. */
254
+ temporality: number | null;
255
+ /** Start of the window in Unix nanoseconds. */
256
+ windowStartNs: bigint;
257
+ windowSecs: number;
258
+ }
259
+
260
+ const toBigInt = (ns: string | null): bigint | null => {
261
+ if (!ns) return null;
262
+ try {
263
+ return BigInt(ns);
264
+ } catch {
265
+ return null;
266
+ }
267
+ };
268
+
269
+ const readField = (p: MetricPointSample, field: AlertMetricField) => {
270
+ const raw = field === "count" ? p.count : field === "sum" ? p.sum : p.value;
271
+ const n = typeof raw === "string" ? Number(raw) : raw;
272
+ return typeof n === "number" && Number.isFinite(n) ? n : null;
273
+ };
274
+
275
+ /** Orders by producer time, then receive time (stable for equal ts_ns). */
276
+ export const sortPoints = (points: MetricPointSample[]): MetricPointSample[] =>
277
+ [...points].sort((a, b) => {
278
+ const ta = toBigInt(a.tsNs) ?? 0n;
279
+ const tb = toBigInt(b.tsNs) ?? 0n;
280
+ if (ta !== tb) return ta < tb ? -1 : 1;
281
+ return a.receivedAt < b.receivedAt
282
+ ? -1
283
+ : a.receivedAt > b.receivedAt
284
+ ? 1
285
+ : 0;
286
+ });
287
+
288
+ /**
289
+ * Counter growth over consecutive cumulative points (Prometheus `increase`
290
+ * without extrapolation). A drop, or a change of the point's start time,
291
+ * is a counter reset: the new value counts in full, as growth from 0. The
292
+ * first point is decided by its OTLP start time when it has one: a start
293
+ * inside the window means the counter began there, so its value counts from
294
+ * 0; a start before the window means part of the value grew before the
295
+ * window, so the point is the baseline (even for a series obs-unified first
296
+ * sees now: a long-running counter reaching a fresh database must not read
297
+ * as a burst). Only without a start time does `bornInWindow` decide (no
298
+ * history before the window: count from 0).
299
+ */
300
+ const cumulativeIncrease = (
301
+ points: Array<{ v: number; start: bigint | null }>,
302
+ windowStartNs: bigint,
303
+ bornInWindow: boolean,
304
+ ): number => {
305
+ if (points.length === 0) return 0;
306
+ const first = points[0];
307
+ const countsFirst =
308
+ first.start !== null ? first.start >= windowStartNs : bornInWindow;
309
+ let total = countsFirst ? first.v : 0;
310
+ for (let i = 1; i < points.length; i++) {
311
+ const prev = points[i - 1];
312
+ const cur = points[i];
313
+ const restarted =
314
+ cur.start !== null && prev.start !== null && cur.start !== prev.start;
315
+ if (restarted || cur.v < prev.v) total += cur.v;
316
+ else total += cur.v - prev.v;
317
+ }
318
+ return total;
319
+ };
320
+
321
+ /**
322
+ * Reduces one series' window points to a number, or null when the series
323
+ * has no point carrying `field` in the window.
324
+ */
325
+ export function reduceSeries(
326
+ points: MetricPointSample[],
327
+ opts: SeriesReduceOptions,
328
+ ): number | null {
329
+ const usable: Array<{ v: number; start: bigint | null }> = [];
330
+ for (const p of sortPoints(points)) {
331
+ const v = readField(p, opts.field);
332
+ if (v !== null) usable.push({ v, start: toBigInt(p.startTsNs) });
333
+ }
334
+ if (usable.length === 0) return null;
335
+ const values = usable.map((u) => u.v);
336
+ switch (opts.aggregation) {
337
+ case "last":
338
+ return values[values.length - 1];
339
+ case "min":
340
+ return values.reduce((a, b) => (b < a ? b : a));
341
+ case "max":
342
+ return values.reduce((a, b) => (b > a ? b : a));
343
+ case "sum":
344
+ return values.reduce((a, b) => a + b, 0);
345
+ case "avg":
346
+ return values.reduce((a, b) => a + b, 0) / values.length;
347
+ case "increase":
348
+ case "rate": {
349
+ const increase =
350
+ opts.temporality === TEMPORALITY_DELTA
351
+ ? values.reduce((a, b) => a + b, 0)
352
+ : cumulativeIncrease(
353
+ usable,
354
+ opts.windowStartNs,
355
+ opts.bornInWindow ?? false,
356
+ );
357
+ return opts.aggregation === "rate"
358
+ ? increase / opts.windowSecs
359
+ : increase;
360
+ }
361
+ }
362
+ }
363
+
364
+ /** numerator / denominator when denominator > 0 and >= minDenominator, else 0. */
365
+ export const metricRatio = (
366
+ numerator: number,
367
+ denominator: number,
368
+ minDenominator: number,
369
+ ): number =>
370
+ denominator > 0 && denominator >= minDenominator
371
+ ? numerator / denominator
372
+ : 0;
373
+
374
+ // ── Database evaluation ────────────────────────────────────────────────────
375
+
376
+ interface SeriesRow {
377
+ id: string;
378
+ temporality: number | string | null;
379
+ created_at: string;
380
+ attributes_json: unknown;
381
+ resource_attrs_json: unknown;
382
+ }
383
+
384
+ interface PointRow {
385
+ series_id: string;
386
+ ts_ns: string;
387
+ start_ts_ns: string | null;
388
+ value: number | null;
389
+ count: number | null;
390
+ sum: number | null;
391
+ received_at: string;
392
+ }
393
+
394
+ const parseAttrs = (raw: unknown): Record<string, unknown> => {
395
+ if (raw === null || raw === undefined) return {};
396
+ if (typeof raw === "string") {
397
+ try {
398
+ const parsed = JSON.parse(raw);
399
+ return isPlainObject(parsed) ? parsed : {};
400
+ } catch {
401
+ return {};
402
+ }
403
+ }
404
+ return isPlainObject(raw) ? raw : {};
405
+ };
406
+
407
+ const attrsMatch = (
408
+ stored: Record<string, unknown>,
409
+ wanted: Record<string, AlertMetricAttributeValue> | undefined,
410
+ ): boolean => {
411
+ if (!wanted) return true;
412
+ for (const [key, expected] of Object.entries(wanted)) {
413
+ const actual = stored[key];
414
+ if (actual === undefined || actual === null) return false;
415
+ if (typeof actual === "object") return false;
416
+ if (String(actual) !== String(expected)) return false;
417
+ }
418
+ return true;
419
+ };
420
+
421
+ const metricNames = (selector: AlertMetricSelector): string[] => [
422
+ ...new Set(
423
+ (Array.isArray(selector.metric) ? selector.metric : [selector.metric]).map(
424
+ (m) => m.trim(),
425
+ ),
426
+ ),
427
+ ];
428
+
429
+ const chunks = <T>(items: T[], size: number): T[][] => {
430
+ const out: T[][] = [];
431
+ for (let i = 0; i < items.length; i += size)
432
+ out.push(items.slice(i, i + size));
433
+ return out;
434
+ };
435
+
436
+ async function matchingSeries(
437
+ db: SqlDb,
438
+ projectId: string,
439
+ selector: AlertMetricSelector,
440
+ ): Promise<
441
+ Array<{ id: string; temporality: number | null; createdAt: string }>
442
+ > {
443
+ const names = metricNames(selector);
444
+ let sql = `SELECT id, temporality, created_at, attributes_json, resource_attrs_json
445
+ FROM metric_series
446
+ WHERE project_id = ? AND name IN (${names.map(() => "?").join(", ")})`;
447
+ const binds: unknown[] = [projectId, ...names];
448
+ if (selector.serviceName) {
449
+ sql += ` AND service_name = ?`;
450
+ binds.push(selector.serviceName);
451
+ }
452
+ const rs = await db
453
+ .prepare(sql)
454
+ .bind(...binds)
455
+ .all<SeriesRow>();
456
+ const matched = (rs.results ?? []).filter(
457
+ (row) =>
458
+ attrsMatch(parseAttrs(row.attributes_json), selector.attributes) &&
459
+ attrsMatch(
460
+ parseAttrs(row.resource_attrs_json),
461
+ selector.resourceAttributes,
462
+ ),
463
+ );
464
+ if (matched.length > MAX_MATCHED_SERIES)
465
+ throw new Error(
466
+ `metrics query matches ${matched.length} series (limit ${MAX_MATCHED_SERIES}); narrow it with attributes or serviceName`,
467
+ );
468
+ return matched.map((row) => ({
469
+ id: row.id,
470
+ createdAt: row.created_at,
471
+ temporality:
472
+ row.temporality === null || row.temporality === undefined
473
+ ? null
474
+ : Number(row.temporality),
475
+ }));
476
+ }
477
+
478
+ export interface MetricEvalContext {
479
+ projectId: string;
480
+ windowMins: number;
481
+ /** The rule's `createdAt`; the reference for "never reported". */
482
+ ruleCreatedAt: string;
483
+ now: Date;
484
+ /** Staleness bound for gauge-style aggregations; default 300. */
485
+ staleSecs?: number;
486
+ /** Point cap per selector; default MAX_LOADED_POINTS (tests lower it). */
487
+ maxLoadedPoints?: number;
488
+ }
489
+
490
+ /** Aggregations whose series must be fresh (see step 3 above). */
491
+ const STALENESS_APPLIES = new Set<AlertMetricAggregation>([
492
+ "last",
493
+ "min",
494
+ "max",
495
+ "avg",
496
+ "sum",
497
+ ]);
498
+
499
+ export interface MetricEvaluation {
500
+ value: number;
501
+ /** No series took part; see the module comment. Never true for age_seconds. */
502
+ noData: boolean;
503
+ }
504
+
505
+ async function evaluateSelector(
506
+ db: SqlDb,
507
+ selector: AlertMetricSelector,
508
+ aggregation: Exclude<AlertMetricAggregation, "age_seconds">,
509
+ ctx: MetricEvalContext,
510
+ ): Promise<{ value: number; contributing: number }> {
511
+ const series = await matchingSeries(db, ctx.projectId, selector);
512
+ if (series.length === 0) return { value: 0, contributing: 0 };
513
+ const windowStartMs = ctx.now.getTime() - ctx.windowMins * 60_000;
514
+ const cutoff = new Date(windowStartMs).toISOString();
515
+ const bySeries = new Map<string, MetricPointSample[]>();
516
+ let loaded = 0;
517
+ for (const chunk of chunks(series, ID_CHUNK)) {
518
+ // One more row than the remaining budget tells "at the cap" from
519
+ // "over it" without loading the rest.
520
+ const cap = ctx.maxLoadedPoints ?? MAX_LOADED_POINTS;
521
+ const budget = cap - loaded;
522
+ const rs = await db
523
+ .prepare(
524
+ `SELECT series_id, ts_ns, start_ts_ns, value, count, sum, received_at
525
+ FROM metric_point
526
+ WHERE project_id = ? AND series_id IN (${chunk.map(() => "?").join(", ")})
527
+ AND received_at >= ?
528
+ LIMIT ?`,
529
+ )
530
+ .bind(ctx.projectId, ...chunk.map((s) => s.id), cutoff, budget + 1)
531
+ .all<PointRow>();
532
+ const rows = rs.results ?? [];
533
+ loaded += rows.length;
534
+ if (loaded > cap)
535
+ throw new Error(
536
+ `metrics query loads more than ${cap} points in its window; shorten windowMins or narrow the query`,
537
+ );
538
+ for (const row of rows) {
539
+ const list = bySeries.get(row.series_id) ?? [];
540
+ list.push({
541
+ tsNs: String(row.ts_ns),
542
+ startTsNs: row.start_ts_ns === null ? null : String(row.start_ts_ns),
543
+ value: row.value,
544
+ count: row.count,
545
+ sum: row.sum,
546
+ receivedAt: row.received_at,
547
+ });
548
+ bySeries.set(row.series_id, list);
549
+ }
550
+ }
551
+ const field = selector.field ?? "value";
552
+ const windowStartNs = BigInt(windowStartMs) * 1_000_000n;
553
+ const freshCutoff = new Date(
554
+ ctx.now.getTime() - (ctx.staleSecs ?? DEFAULT_STALE_SECS) * 1000,
555
+ ).toISOString();
556
+ let total = 0;
557
+ let contributing = 0;
558
+ for (const s of series) {
559
+ const points = bySeries.get(s.id) ?? [];
560
+ if (STALENESS_APPLIES.has(aggregation)) {
561
+ const newest = points.reduce<string | null>(
562
+ (max, p) => (max === null || p.receivedAt > max ? p.receivedAt : max),
563
+ null,
564
+ );
565
+ if (newest === null || newest < freshCutoff) continue;
566
+ }
567
+ const reduced = reduceSeries(points, {
568
+ aggregation,
569
+ field,
570
+ temporality: s.temporality,
571
+ windowStartNs,
572
+ windowSecs: ctx.windowMins * 60,
573
+ bornInWindow: s.createdAt >= cutoff,
574
+ });
575
+ if (reduced !== null) {
576
+ total += reduced;
577
+ contributing += 1;
578
+ }
579
+ }
580
+ return { value: total, contributing };
581
+ }
582
+
583
+ async function ageSeconds(
584
+ db: SqlDb,
585
+ selector: AlertMetricSelector,
586
+ ctx: MetricEvalContext,
587
+ ): Promise<number> {
588
+ const series = await matchingSeries(db, ctx.projectId, selector);
589
+ let newest: string | null = null;
590
+ for (const chunk of chunks(series, ID_CHUNK)) {
591
+ const row = await db
592
+ .prepare(
593
+ `SELECT MAX(received_at) AS newest FROM metric_point
594
+ WHERE project_id = ? AND series_id IN (${chunk.map(() => "?").join(", ")})`,
595
+ )
596
+ .bind(ctx.projectId, ...chunk.map((s) => s.id))
597
+ .first<{ newest: string | null }>();
598
+ if (row?.newest && (newest === null || row.newest > newest))
599
+ newest = row.newest;
600
+ }
601
+ const reference = Date.parse(newest ?? ctx.ruleCreatedAt);
602
+ if (!Number.isFinite(reference)) return 0;
603
+ return Math.max(0, (ctx.now.getTime() - reference) / 1000);
604
+ }
605
+
606
+ /** Evaluates a validated metrics query to a finite number. */
607
+ export async function evaluateMetricQuery(
608
+ db: SqlDb,
609
+ query: AlertQueryMetrics,
610
+ ctx: MetricEvalContext,
611
+ ): Promise<MetricEvaluation> {
612
+ if (query.aggregation === "age_seconds")
613
+ return { value: await ageSeconds(db, query, ctx), noData: false };
614
+ const withStale = { ...ctx, staleSecs: query.staleSecs ?? ctx.staleSecs };
615
+ const numerator = await evaluateSelector(
616
+ db,
617
+ query,
618
+ query.aggregation,
619
+ withStale,
620
+ );
621
+ if (!query.denominator)
622
+ return {
623
+ value: numerator.value,
624
+ noData: numerator.contributing === 0,
625
+ };
626
+ const denominator = await evaluateSelector(
627
+ db,
628
+ query.denominator,
629
+ query.denominator.aggregation ?? query.aggregation,
630
+ withStale,
631
+ );
632
+ return {
633
+ value: metricRatio(
634
+ numerator.value,
635
+ denominator.value,
636
+ query.minDenominator ?? 0,
637
+ ),
638
+ noData: numerator.contributing === 0 && denominator.contributing === 0,
639
+ };
640
+ }
@@ -82,7 +82,11 @@ export class MetricsStore {
82
82
  const { projectId, points, receivedAt, expiresAt } = opts;
83
83
  if (points.length === 0) return;
84
84
 
85
- const seriesByIdentity = await this.resolveSeries(projectId, points);
85
+ const seriesByIdentity = await this.resolveSeries(
86
+ projectId,
87
+ points,
88
+ receivedAt,
89
+ );
86
90
 
87
91
  const pointStmt = this.db.prepare(`
88
92
  INSERT INTO metric_point (
@@ -247,6 +251,7 @@ export class MetricsStore {
247
251
  private async resolveSeries(
248
252
  projectId: string,
249
253
  points: DecodedMetricPoint[],
254
+ receivedAt: string,
250
255
  ): Promise<Map<string, SeriesRow>> {
251
256
  const byIdentity = new Map<string, DecodedMetricPoint>();
252
257
  for (const p of points) {
@@ -285,7 +290,9 @@ export class MetricsStore {
285
290
  `);
286
291
  const toInsert: SqlStatement[] = [];
287
292
  const attemptedIds = new Set<string>();
288
- const createdAt = new Date().toISOString();
293
+ // The batch's receive time: a series' created_at tells the metrics
294
+ // alert evaluator whether it has history before a window.
295
+ const createdAt = receivedAt;
289
296
  for (const [identity, p] of byIdentity.entries()) {
290
297
  if (existing.has(identity)) continue;
291
298
  const id = crypto.randomUUID();