@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
@@ -2,11 +2,14 @@ import type {
2
2
  AlertChannel,
3
3
  AlertComparison,
4
4
  AlertEvaluation,
5
+ AlertEvaluationOutcome,
5
6
  AlertEvaluationRow,
6
7
  AlertEvaluationsListResponse,
8
+ AlertLastEvaluation,
7
9
  AlertQuery,
8
10
  AlertQueryAI,
9
11
  AlertQueryLogs,
12
+ AlertQueryMetrics,
10
13
  AlertQuerySpans,
11
14
  AlertQueryUsage,
12
15
  AlertRule,
@@ -18,16 +21,40 @@ import type {
18
21
  LogSeverity,
19
22
  } from "@obsunified/types";
20
23
  import { EVIDENCE_REFERENCE_CONTRACT } from "@obsunified/types";
24
+ import { isValidEmailAddress, MAX_EMAIL_RECIPIENTS } from "./alert-email";
21
25
  import { alertEvidenceReferences } from "./evidence-references";
22
26
  import { randomHex } from "./hash";
27
+ import {
28
+ evaluateMetricQuery,
29
+ MAX_METRIC_WINDOW_MINS,
30
+ validateMetricQuery,
31
+ } from "./metric-alerts";
32
+
33
+ /** A rule's value, and whether a metrics rule found no fresh data. */
34
+ export interface AlertRuleEvaluation {
35
+ value: number;
36
+ noData: boolean;
37
+ }
38
+
23
39
  import type { SqlDb } from "./sql-db";
24
40
  import { assertSafeWebhookUrl, WebhookUrlError } from "./webhook-url";
25
41
 
26
42
  export interface AlertsStoreOptions {
27
43
  /** Permit webhook targets on loopback / private / link-local hosts. */
28
44
  allowPrivateWebhookTargets?: boolean;
45
+ /**
46
+ * Whether this collector can send email (a transport is bound as
47
+ * `env.ALERT_EMAIL`). Rules with an email channel are refused otherwise.
48
+ */
49
+ emailDeliveryAvailable?: boolean;
29
50
  }
30
51
 
52
+ /** Upper bound for `pendingSecs` (one day). */
53
+ export const MAX_PENDING_SECS = 86_400;
54
+
55
+ export const EMAIL_UNAVAILABLE_MESSAGE =
56
+ "email channels need SMTP configured on the collector (ALERT_SMTP_HOST and ALERT_SMTP_FROM on the Node collector); the Cloudflare Worker collector cannot send email";
57
+
31
58
  const parseJsonField = <T>(value: unknown): T => {
32
59
  if (typeof value === "string") return JSON.parse(value) as T;
33
60
  return value as T;
@@ -47,6 +74,8 @@ const rowToRule = (row: AlertRuleRow, state?: AlertStateRow): AlertRule => ({
47
74
  createdAt: row.created_at,
48
75
  updatedAt: row.updated_at,
49
76
  analysisId: row.analysis_id ?? null,
77
+ pendingSecs: Number(row.pending_secs ?? 0),
78
+ pendingSince: state?.pending_since ?? null,
50
79
  currentState: state?.current_state,
51
80
  lastStateChange: state?.last_state_change ?? null,
52
81
  });
@@ -59,13 +88,39 @@ const rowToEvaluation = (row: AlertEvaluationRow): AlertEvaluation => ({
59
88
  value: row.value,
60
89
  state: row.state,
61
90
  notified: row.notified === 1,
91
+ outcome: row.outcome ?? "value",
92
+ error: row.error_message ?? null,
62
93
  });
63
94
 
95
+ const MAX_ERROR_MESSAGE = 500;
96
+
97
+ /**
98
+ * What an `error` evaluation keeps of the failure: URLs (which can carry
99
+ * credentials or secret hook paths) replaced, one line, truncated.
100
+ */
101
+ export const sanitizeEvaluationError = (message: string): string => {
102
+ const cleaned = message
103
+ .replace(/[a-z][a-z0-9+.-]*:\/\/[^\s"'<>]+/gi, "<url>")
104
+ .replace(/\s+/g, " ")
105
+ .trim();
106
+ return cleaned.length > MAX_ERROR_MESSAGE
107
+ ? `${cleaned.slice(0, MAX_ERROR_MESSAGE - 1)}…`
108
+ : cleaned;
109
+ };
110
+
111
+ interface LastEvaluationRow {
112
+ rule_id: string;
113
+ evaluated_at: string;
114
+ value: number;
115
+ outcome?: AlertEvaluationOutcome | null;
116
+ error_message?: string | null;
117
+ }
118
+
64
119
  const windowCutoffIso = (windowMins: number): string =>
65
120
  new Date(Date.now() - windowMins * 60 * 1000).toISOString();
66
121
 
67
- const SIGNALS: AlertSignal[] = ["spans", "logs", "usage", "ai"];
68
- const COMPARISONS: AlertComparison[] = [">", ">=", "<", "<="];
122
+ const SIGNALS: AlertSignal[] = ["spans", "logs", "usage", "ai", "metrics"];
123
+ const COMPARISONS: AlertComparison[] = [">", ">=", "<", "<=", "==", "!="];
69
124
 
70
125
  export function compareValue(
71
126
  value: number,
@@ -81,6 +136,10 @@ export function compareValue(
81
136
  return value < threshold;
82
137
  case "<=":
83
138
  return value <= threshold;
139
+ case "==":
140
+ return value === threshold;
141
+ case "!=":
142
+ return value !== threshold;
84
143
  }
85
144
  }
86
145
 
@@ -100,11 +159,51 @@ export class AlertsStore {
100
159
  throw new Error("windowMins must be >= 1");
101
160
  if (!COMPARISONS.includes(input.comparison))
102
161
  throw new Error(`comparison must be one of: ${COMPARISONS.join(", ")}`);
162
+ if (
163
+ input.pendingSecs !== undefined &&
164
+ (!Number.isInteger(input.pendingSecs) ||
165
+ input.pendingSecs < 0 ||
166
+ input.pendingSecs > MAX_PENDING_SECS)
167
+ )
168
+ throw new Error(
169
+ `pendingSecs must be an integer from 0 to ${MAX_PENDING_SECS}`,
170
+ );
171
+ if (input.signal === "metrics") {
172
+ validateMetricQuery(input.query);
173
+ if (input.windowMins > MAX_METRIC_WINDOW_MINS)
174
+ throw new Error(
175
+ `windowMins must be <= ${MAX_METRIC_WINDOW_MINS} for the metrics signal`,
176
+ );
177
+ }
103
178
  if (!Array.isArray(input.channels) || input.channels.length === 0)
104
179
  throw new Error("at least one channel is required");
105
180
  for (const ch of input.channels) {
181
+ if (ch.type === "email") {
182
+ if (!Array.isArray(ch.to) || ch.to.length === 0)
183
+ throw new Error("email channel.to must list at least one address");
184
+ if (ch.to.length > MAX_EMAIL_RECIPIENTS)
185
+ throw new Error(
186
+ `email channel.to may list at most ${MAX_EMAIL_RECIPIENTS} addresses`,
187
+ );
188
+ for (const addr of ch.to) {
189
+ if (typeof addr !== "string" || !isValidEmailAddress(addr))
190
+ throw new Error(
191
+ `email channel.to has an invalid address: ${JSON.stringify(addr)}`,
192
+ );
193
+ }
194
+ const extra = Object.keys(ch).filter((k) => k !== "type" && k !== "to");
195
+ if (extra.length > 0)
196
+ throw new Error(
197
+ `email channel accepts only "type" and "to" (SMTP settings come from the collector environment); got: ${extra.join(", ")}`,
198
+ );
199
+ if (!this.options.emailDeliveryAvailable)
200
+ throw new Error(EMAIL_UNAVAILABLE_MESSAGE);
201
+ continue;
202
+ }
106
203
  if (ch.type !== "webhook")
107
- throw new Error(`unknown channel type: ${ch.type}`);
204
+ throw new Error(
205
+ `unknown channel type: ${(ch as { type?: unknown }).type}`,
206
+ );
108
207
  if (!ch.url || typeof ch.url !== "string")
109
208
  throw new Error("channel.url must be http(s)://");
110
209
  try {
@@ -134,7 +233,75 @@ export class AlertsStore {
134
233
  const stateByRule = new Map<string, AlertStateRow>(
135
234
  (stateRs.results ?? []).map((s) => [s.rule_id, s]),
136
235
  );
137
- return rows.map((r) => rowToRule(r, stateByRule.get(r.id)));
236
+ const lastByRule = await this.lastEvaluations(projectId);
237
+ return rows.map((r) => {
238
+ const state = stateByRule.get(r.id);
239
+ const last = lastByRule.get(r.id);
240
+ return {
241
+ ...rowToRule(r, state),
242
+ notificationDelivered: state
243
+ ? state.notified === undefined || state.notified === null
244
+ ? true
245
+ : Number(state.notified) === 1
246
+ : null,
247
+ lastEvaluation: last
248
+ ? {
249
+ evaluatedAt: last.evaluated_at,
250
+ outcome: last.outcome ?? "value",
251
+ value: Number(last.value),
252
+ error:
253
+ (last.outcome ?? "value") === "error"
254
+ ? (last.error_message ?? null)
255
+ : null,
256
+ }
257
+ : null,
258
+ };
259
+ });
260
+ }
261
+
262
+ /**
263
+ * The newest evaluation of every rule in the project, one query. Falls
264
+ * back to the pre-057/059 column set on older databases.
265
+ */
266
+ private async lastEvaluations(
267
+ projectId: string,
268
+ ): Promise<Map<string, LastEvaluationRow>> {
269
+ const run = (columns: string) =>
270
+ this.db
271
+ .prepare(
272
+ `SELECT e.rule_id, e.evaluated_at, e.value${columns}
273
+ FROM alert_evaluations e
274
+ WHERE e.project_id = ?
275
+ AND e.evaluated_at = (
276
+ SELECT MAX(e2.evaluated_at) FROM alert_evaluations e2
277
+ WHERE e2.rule_id = e.rule_id
278
+ )`,
279
+ )
280
+ .bind(projectId)
281
+ .all<LastEvaluationRow>();
282
+ let rs: { results: LastEvaluationRow[] };
283
+ try {
284
+ rs = await run(", e.outcome, e.error_message");
285
+ } catch (err) {
286
+ if (
287
+ !isMissingColumn(err, "error_message") &&
288
+ !isMissingColumn(err, "outcome")
289
+ )
290
+ throw err;
291
+ try {
292
+ rs = await run(", e.outcome");
293
+ } catch (inner) {
294
+ if (!isMissingColumn(inner, "outcome")) throw inner;
295
+ rs = await run("");
296
+ }
297
+ }
298
+ const out = new Map<string, LastEvaluationRow>();
299
+ // Same-millisecond ties: prefer an error, which is the one to surface.
300
+ for (const row of rs.results ?? []) {
301
+ const seen = out.get(row.rule_id);
302
+ if (!seen || row.outcome === "error") out.set(row.rule_id, row);
303
+ }
304
+ return out;
138
305
  }
139
306
 
140
307
  async getRule(id: string, projectId: string): Promise<AlertRule | null> {
@@ -157,27 +324,41 @@ export class AlertsStore {
157
324
  this.validateInput(input);
158
325
  const id = randomHex(16);
159
326
  const now = new Date().toISOString();
160
- await this.db
161
- .prepare(
162
- `INSERT INTO alert_rules (id, project_id, name, signal, query_json, threshold, window_mins, comparison, channels_json, enabled, created_at, updated_at, analysis_id)
327
+ const pendingSecs = input.pendingSecs ?? 0;
328
+ const values = [
329
+ id,
330
+ projectId,
331
+ input.name.trim(),
332
+ input.signal,
333
+ JSON.stringify(input.query),
334
+ input.threshold,
335
+ input.windowMins,
336
+ input.comparison,
337
+ JSON.stringify(input.channels),
338
+ input.enabled === false ? 0 : 1,
339
+ now,
340
+ now,
341
+ input.analysisId ?? null,
342
+ ];
343
+ await this.withPendingColumnFallback(
344
+ pendingSecs,
345
+ () =>
346
+ this.db
347
+ .prepare(
348
+ `INSERT INTO alert_rules (id, project_id, name, signal, query_json, threshold, window_mins, comparison, channels_json, enabled, created_at, updated_at, analysis_id, pending_secs)
349
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
350
+ )
351
+ .bind(...values, pendingSecs)
352
+ .run(),
353
+ () =>
354
+ this.db
355
+ .prepare(
356
+ `INSERT INTO alert_rules (id, project_id, name, signal, query_json, threshold, window_mins, comparison, channels_json, enabled, created_at, updated_at, analysis_id)
163
357
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
164
- )
165
- .bind(
166
- id,
167
- projectId,
168
- input.name.trim(),
169
- input.signal,
170
- JSON.stringify(input.query),
171
- input.threshold,
172
- input.windowMins,
173
- input.comparison,
174
- JSON.stringify(input.channels),
175
- input.enabled === false ? 0 : 1,
176
- now,
177
- now,
178
- input.analysisId ?? null,
179
- )
180
- .run();
358
+ )
359
+ .bind(...values)
360
+ .run(),
361
+ );
181
362
 
182
363
  const created = await this.getRule(id, projectId);
183
364
  if (!created) throw new Error("Failed to create rule");
@@ -203,36 +384,121 @@ export class AlertsStore {
203
384
  enabled: patch.enabled ?? existing.enabled,
204
385
  analysisId:
205
386
  "analysisId" in patch ? patch.analysisId : existing.analysisId,
387
+ pendingSecs: patch.pendingSecs ?? existing.pendingSecs,
206
388
  };
207
389
  this.validateInput(merged);
208
390
 
209
391
  const now = new Date().toISOString();
210
- await this.db
211
- .prepare(
212
- `UPDATE alert_rules SET
392
+ const pendingSecs = merged.pendingSecs ?? 0;
393
+ const values = [
394
+ merged.name.trim(),
395
+ merged.signal,
396
+ JSON.stringify(merged.query),
397
+ merged.threshold,
398
+ merged.windowMins,
399
+ merged.comparison,
400
+ JSON.stringify(merged.channels),
401
+ merged.enabled === false ? 0 : 1,
402
+ now,
403
+ merged.analysisId ?? null,
404
+ ];
405
+ await this.withPendingColumnFallback(
406
+ pendingSecs,
407
+ () =>
408
+ this.db
409
+ .prepare(
410
+ `UPDATE alert_rules SET
411
+ name = ?, signal = ?, query_json = ?, threshold = ?, window_mins = ?,
412
+ comparison = ?, channels_json = ?, enabled = ?, updated_at = ?, analysis_id = ?,
413
+ pending_secs = ?
414
+ WHERE id = ? AND project_id = ?`,
415
+ )
416
+ .bind(...values, pendingSecs, id, projectId)
417
+ .run(),
418
+ () =>
419
+ this.db
420
+ .prepare(
421
+ `UPDATE alert_rules SET
213
422
  name = ?, signal = ?, query_json = ?, threshold = ?, window_mins = ?,
214
423
  comparison = ?, channels_json = ?, enabled = ?, updated_at = ?, analysis_id = ?
215
424
  WHERE id = ? AND project_id = ?`,
216
- )
217
- .bind(
218
- merged.name.trim(),
219
- merged.signal,
220
- JSON.stringify(merged.query),
221
- merged.threshold,
222
- merged.windowMins,
223
- merged.comparison,
224
- JSON.stringify(merged.channels),
225
- merged.enabled === false ? 0 : 1,
226
- now,
227
- merged.analysisId ?? null,
228
- id,
229
- projectId,
230
- )
231
- .run();
425
+ )
426
+ .bind(...values, id, projectId)
427
+ .run(),
428
+ );
429
+
430
+ // A pending period was measured against the old definition; after a
431
+ // change to what or whether the rule evaluates, it starts over.
432
+ const evaluationChanged =
433
+ merged.signal !== existing.signal ||
434
+ JSON.stringify(merged.query) !== JSON.stringify(existing.query) ||
435
+ merged.threshold !== existing.threshold ||
436
+ merged.windowMins !== existing.windowMins ||
437
+ merged.comparison !== existing.comparison ||
438
+ (merged.enabled !== false) !== existing.enabled ||
439
+ (merged.analysisId ?? null) !== (existing.analysisId ?? null) ||
440
+ pendingSecs !== (existing.pendingSecs ?? 0);
441
+ if (evaluationChanged) await this.resetPending(id, projectId);
232
442
 
233
443
  return this.getRule(id, projectId);
234
444
  }
235
445
 
446
+ /**
447
+ * Clears a rule's pending period. Tolerates databases that have not
448
+ * applied 055/058 (there is nothing to clear on them).
449
+ */
450
+ async resetPending(ruleId: string, projectId: string): Promise<void> {
451
+ try {
452
+ await this.db
453
+ .prepare(
454
+ `UPDATE alert_state SET pending_since = NULL, pending_seen_at = NULL
455
+ WHERE rule_id = ? AND project_id = ?`,
456
+ )
457
+ .bind(ruleId, projectId)
458
+ .run();
459
+ } catch (err) {
460
+ if (!isMissingColumn(err, "pending_seen_at")) {
461
+ if (isMissingColumn(err, "pending_since")) return;
462
+ throw err;
463
+ }
464
+ try {
465
+ await this.db
466
+ .prepare(
467
+ `UPDATE alert_state SET pending_since = NULL
468
+ WHERE rule_id = ? AND project_id = ?`,
469
+ )
470
+ .bind(ruleId, projectId)
471
+ .run();
472
+ } catch (inner) {
473
+ if (!isMissingColumn(inner, "pending_since")) throw inner;
474
+ }
475
+ }
476
+ }
477
+
478
+ /**
479
+ * Runs `current`; on a database that has not applied migration 054 (no
480
+ * `alert_rules.pending_secs`) a rule without a pending duration is
481
+ * written with the legacy statement so rolling deploys keep working. A
482
+ * rule that needs the column is refused with a clear message instead of
483
+ * silently losing its pending duration.
484
+ */
485
+ private async withPendingColumnFallback<T>(
486
+ pendingSecs: number,
487
+ current: () => Promise<T>,
488
+ legacy: () => Promise<T>,
489
+ ): Promise<T> {
490
+ try {
491
+ return await current();
492
+ } catch (err) {
493
+ if (!isMissingColumn(err, "pending_secs")) throw err;
494
+ if (pendingSecs > 0)
495
+ throw new Error(
496
+ "pendingSecs needs database migration 054 (alert_rules.pending_secs); apply migrations and retry",
497
+ );
498
+ return legacy();
499
+ }
500
+ }
501
+
236
502
  async deleteRule(id: string, projectId: string): Promise<boolean> {
237
503
  // Only a rule the caller's project owns takes its state and
238
504
  // evaluations with it; a miss must not touch another project's rows.
@@ -352,30 +618,98 @@ export class AlertsStore {
352
618
  }
353
619
  }
354
620
 
355
- async recordEvaluation(
621
+ /**
622
+ * Record (`{ since, seenAt }`) or clear (`null`) the pending period of a
623
+ * rule that has a pending duration (migrations 055, 058): when the
624
+ * condition started holding, and the last evaluation that saw it hold.
625
+ * A rule without a state row gets one in state `ok` since its creation,
626
+ * already delivered (nothing is owed for a rule that never changed
627
+ * state).
628
+ */
629
+ async setPending(
356
630
  ruleId: string,
357
631
  projectId: string,
358
- value: number,
359
- state: AlertState,
360
- notified: boolean,
632
+ pending: { since: string; seenAt: string } | null,
633
+ ruleCreatedAt: string,
361
634
  ): Promise<void> {
362
635
  await this.db
363
636
  .prepare(
364
- `INSERT INTO alert_evaluations (id, rule_id, project_id, evaluated_at, value, state, notified)
365
- VALUES (?, ?, ?, ?, ?, ?, ?)`,
637
+ `INSERT INTO alert_state (rule_id, project_id, current_state, last_state_change, notified, pending_since, pending_seen_at)
638
+ VALUES (?, ?, 'ok', ?, 1, ?, ?)
639
+ ON CONFLICT(rule_id) DO UPDATE SET
640
+ pending_since = excluded.pending_since,
641
+ pending_seen_at = excluded.pending_seen_at`,
366
642
  )
367
643
  .bind(
368
- randomHex(16),
369
644
  ruleId,
370
645
  projectId,
371
- new Date().toISOString(),
372
- value,
373
- state,
374
- notified ? 1 : 0,
646
+ ruleCreatedAt,
647
+ pending?.since ?? null,
648
+ pending?.seenAt ?? null,
375
649
  )
376
650
  .run();
377
651
  }
378
652
 
653
+ async recordEvaluation(
654
+ ruleId: string,
655
+ projectId: string,
656
+ value: number,
657
+ state: AlertState,
658
+ notified: boolean,
659
+ outcome: AlertEvaluationOutcome = "value",
660
+ errorMessage?: string,
661
+ ): Promise<void> {
662
+ const error =
663
+ outcome === "error" && errorMessage
664
+ ? sanitizeEvaluationError(errorMessage)
665
+ : null;
666
+ const values = [
667
+ randomHex(16),
668
+ ruleId,
669
+ projectId,
670
+ new Date().toISOString(),
671
+ value,
672
+ state,
673
+ notified ? 1 : 0,
674
+ ];
675
+ try {
676
+ await this.db
677
+ .prepare(
678
+ `INSERT INTO alert_evaluations (id, rule_id, project_id, evaluated_at, value, state, notified, outcome, error_message)
679
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
680
+ )
681
+ .bind(...values, outcome, error)
682
+ .run();
683
+ return;
684
+ } catch (err) {
685
+ if (!isMissingColumn(err, "error_message")) {
686
+ if (!isMissingColumn(err, "outcome")) throw err;
687
+ } else {
688
+ // Pre-059: keep the outcome without the message.
689
+ try {
690
+ await this.db
691
+ .prepare(
692
+ `INSERT INTO alert_evaluations (id, rule_id, project_id, evaluated_at, value, state, notified, outcome)
693
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
694
+ )
695
+ .bind(...values, outcome)
696
+ .run();
697
+ return;
698
+ } catch (inner) {
699
+ if (!isMissingColumn(inner, "outcome")) throw inner;
700
+ }
701
+ }
702
+ // Pre-057 database: the row is still worth keeping without its outcome.
703
+ await this.db
704
+ .prepare(
705
+ `INSERT INTO alert_evaluations (id, rule_id, project_id, evaluated_at, value, state, notified)
706
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
707
+ )
708
+ .bind(...values)
709
+ .run();
710
+ }
711
+ }
712
+
379
713
  async listEvaluations(params: {
380
714
  ruleId: string;
381
715
  projectId?: string;
@@ -408,7 +742,26 @@ export class AlertsStore {
408
742
 
409
743
  // ── Evaluators (count-over-window per signal) ──
410
744
 
411
- async evaluateRule(rule: AlertRule): Promise<number> {
745
+ async evaluateRule(rule: AlertRule, now: Date = new Date()): Promise<number> {
746
+ return (await this.evaluateRuleWithStatus(rule, now)).value;
747
+ }
748
+
749
+ /** Like `evaluateRule`, plus whether a metrics rule found no fresh data. */
750
+ async evaluateRuleWithStatus(
751
+ rule: AlertRule,
752
+ now: Date = new Date(),
753
+ ): Promise<AlertRuleEvaluation> {
754
+ if (rule.signal === "metrics" && !rule.analysisId)
755
+ return evaluateMetricQuery(this.db, rule.query as AlertQueryMetrics, {
756
+ projectId: rule.projectId,
757
+ windowMins: rule.windowMins,
758
+ ruleCreatedAt: rule.createdAt,
759
+ now,
760
+ });
761
+ return { value: await this.evaluateCountRule(rule), noData: false };
762
+ }
763
+
764
+ private async evaluateCountRule(rule: AlertRule): Promise<number> {
412
765
  // Stage 6 — analysis-bound rule. Read the analysis's latest
413
766
  // primary value instead of running the rule's raw query. Falls
414
767
  // through to the legacy path if no result has landed yet so the
@@ -433,6 +786,14 @@ export class AlertsStore {
433
786
  return this.evaluateUsageRule(rule);
434
787
  case "ai":
435
788
  return this.evaluateAIRule(rule);
789
+ case "metrics":
790
+ // Handled by evaluateRuleWithStatus; unreachable here.
791
+ throw new Error(
792
+ "metrics rules evaluate through evaluateRuleWithStatus",
793
+ );
794
+ default:
795
+ // A stored signal this collector does not know must not read as 0.
796
+ throw new Error(`unknown alert signal: ${String(rule.signal)}`);
436
797
  }
437
798
  }
438
799
 
@@ -550,10 +911,13 @@ export class AlertsStore {
550
911
  * notified"; Postgres: `column "notified" of relation "alert_state" does not
551
912
  * exist` (42703). Anything else is a real failure.
552
913
  */
553
- const isMissingNotifiedColumn = (err: unknown): boolean => {
914
+ const isMissingNotifiedColumn = (err: unknown): boolean =>
915
+ isMissingColumn(err, "notified");
916
+
917
+ const isMissingColumn = (err: unknown, column: string): boolean => {
554
918
  const message = err instanceof Error ? err.message : String(err);
555
919
  return (
556
- /notified/i.test(message) &&
920
+ message.toLowerCase().includes(column) &&
557
921
  /no such column|has no column|does not exist/i.test(message)
558
922
  );
559
923
  };
@@ -118,7 +118,7 @@ describe("AnalysesStore.getLatestResult evidence references", () => {
118
118
  "analysis:investigate.latency:tail_offenders:0:span:trace-1:span-1",
119
119
  entityKind: "span",
120
120
  entityId: "trace-1:span-1",
121
- route: "#/traces/trace-1#span=span-1",
121
+ route: "#/traces?trace=trace-1&span=span-1",
122
122
  source: "analysis.payload.evidence.tail_offenders",
123
123
  confidence: 0.95,
124
124
  citations: [
@@ -126,7 +126,7 @@ describe("AnalysesStore.getLatestResult evidence references", () => {
126
126
  label: "trace trace-1",
127
127
  entityKind: "trace",
128
128
  entityId: "trace-1",
129
- route: "#/traces/trace-1",
129
+ route: "#/traces?trace=trace-1",
130
130
  },
131
131
  ],
132
132
  }),
@@ -134,7 +134,7 @@ describe("AnalysesStore.getLatestResult evidence references", () => {
134
134
  evidenceId: "analysis:investigate.latency:tail_offenders:1:service:api",
135
135
  entityKind: "service",
136
136
  entityId: "api",
137
- route: "#/traces?service=api",
137
+ route: "#/service-map?service=api",
138
138
  source: "analysis.payload.evidence.tail_offenders",
139
139
  confidence: 0.7,
140
140
  }),
@@ -219,7 +219,7 @@ describe("AnalysesStore.getLatestResult evidence references", () => {
219
219
  "analysis:trace.instrumentation_gaps:instrumentation-gap:0:span:trace-gap:span-root",
220
220
  entityKind: "span",
221
221
  entityId: "trace-gap:span-root",
222
- route: "#/traces/trace-gap#span=span-root",
222
+ route: "#/traces?trace=trace-gap&span=span-root",
223
223
  source: "analysis.payload.instrumentationGaps",
224
224
  confidence: 0.9,
225
225
  citations: [
@@ -227,7 +227,7 @@ describe("AnalysesStore.getLatestResult evidence references", () => {
227
227
  label: "trace trace-gap",
228
228
  entityKind: "trace",
229
229
  entityId: "trace-gap",
230
- route: "#/traces/trace-gap",
230
+ route: "#/traces?trace=trace-gap",
231
231
  },
232
232
  ],
233
233
  suggestedNextPivots: expect.arrayContaining([
@@ -235,7 +235,7 @@ describe("AnalysesStore.getLatestResult evidence references", () => {
235
235
  label: "Open profiler setup docs",
236
236
  entityKind: "docs",
237
237
  entityId: "howto/ebpf",
238
- route: "#/docs/howto/ebpf",
238
+ route: "#/evidence?kind=docs&id=howto%2Febpf",
239
239
  }),
240
240
  ]),
241
241
  });