@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
package/dist/index.js CHANGED
@@ -5798,7 +5798,11 @@ var MetricsStore = class {
5798
5798
  async ingestBatch(opts) {
5799
5799
  const { projectId, points, receivedAt, expiresAt } = opts;
5800
5800
  if (points.length === 0) return;
5801
- const seriesByIdentity = await this.resolveSeries(projectId, points);
5801
+ const seriesByIdentity = await this.resolveSeries(
5802
+ projectId,
5803
+ points,
5804
+ receivedAt
5805
+ );
5802
5806
  const pointStmt = this.db.prepare(`
5803
5807
  INSERT INTO metric_point (
5804
5808
  id, series_id, project_id, ts_ns, start_ts_ns,
@@ -5917,7 +5921,7 @@ var MetricsStore = class {
5917
5921
  * Ensures a `metric_series` row exists for every unique identity in the
5918
5922
  * batch. Returns a map of identity → series row for point linking.
5919
5923
  */
5920
- async resolveSeries(projectId, points) {
5924
+ async resolveSeries(projectId, points, receivedAt) {
5921
5925
  const byIdentity = /* @__PURE__ */ new Map();
5922
5926
  for (const p of points) {
5923
5927
  if (!byIdentity.has(p.identity)) byIdentity.set(p.identity, p);
@@ -5944,7 +5948,7 @@ var MetricsStore = class {
5944
5948
  `);
5945
5949
  const toInsert = [];
5946
5950
  const attemptedIds = /* @__PURE__ */ new Set();
5947
- const createdAt = (/* @__PURE__ */ new Date()).toISOString();
5951
+ const createdAt = receivedAt;
5948
5952
  for (const [identity, p] of byIdentity.entries()) {
5949
5953
  if (existing.has(identity)) continue;
5950
5954
  const id = crypto.randomUUID();
@@ -7085,7 +7089,7 @@ async function selectTraceCandidates(db, options) {
7085
7089
  }
7086
7090
  if (options.search) {
7087
7091
  const term = `%${options.search}%`;
7088
- whereClause += " AND (span_name LIKE ? OR status_message LIKE ? OR attributes_json LIKE ? OR events_json LIKE ?)";
7092
+ whereClause += " AND (span_name LIKE ? OR status_message LIKE ? OR CAST(attributes_json AS TEXT) LIKE ? OR CAST(events_json AS TEXT) LIKE ?)";
7089
7093
  binds.push(term, term, term, term);
7090
7094
  }
7091
7095
  const having = options.status === "error" ? "HAVING SUM(CASE WHEN status_code = 2 THEN 1 ELSE 0 END) > 0" : options.status === "ok" ? "HAVING SUM(CASE WHEN status_code = 2 THEN 1 ELSE 0 END) = 0" : "";
@@ -12872,8 +12876,426 @@ var aiSpanPayloadsProcessorPlugin = {
12872
12876
  }
12873
12877
  };
12874
12878
 
12879
+ // src/lib/alert-email.ts
12880
+ var MAX_EMAIL_RECIPIENTS = 20;
12881
+ var EMAIL_RE = /^[^\s@<>,;:"()[\]\\]+@[^\s@<>,;:"()[\]\\]+\.[^\s@<>,;:"()[\]\\]+$/;
12882
+ var isValidEmailAddress = (addr) => addr.length <= 254 && EMAIL_RE.test(addr);
12883
+ var oneLine = (s) => s.replace(/[\r\n]+/g, " ").trim();
12884
+ function formatAlertEmail(payload) {
12885
+ const label = payload.state === "firing" ? "FIRING" : "RESOLVED";
12886
+ const name = oneLine(payload.rule.name);
12887
+ const lines = [
12888
+ payload.state === "firing" ? `Alert rule "${name}" is firing.` : `Alert rule "${name}" has resolved.`,
12889
+ "",
12890
+ `Rule: ${name} (${payload.rule.id})`,
12891
+ `Signal: ${payload.rule.signal}`,
12892
+ `State: ${payload.state}`,
12893
+ `Value: ${payload.value}`,
12894
+ `Condition: value ${payload.comparison} ${payload.threshold}`,
12895
+ `Threshold: ${payload.threshold}`,
12896
+ `Comparison: ${payload.comparison}`,
12897
+ `Evaluated: ${payload.evaluatedAt}`,
12898
+ `Project: ${payload.projectId}`
12899
+ ];
12900
+ if (payload.analysis) {
12901
+ lines.push(
12902
+ `Analysis: ${payload.analysis.id}${payload.analysis.status ? ` (${payload.analysis.status})` : ""}`
12903
+ );
12904
+ if (payload.analysis.narrative) {
12905
+ lines.push("", payload.analysis.narrative);
12906
+ }
12907
+ }
12908
+ lines.push("", "Sent by obs-unified.");
12909
+ return {
12910
+ subject: `[obs-unified] ${label}: ${name}`,
12911
+ text: `${lines.join("\n")}
12912
+ `
12913
+ };
12914
+ }
12915
+
12875
12916
  // src/lib/alerts-store.ts
12876
12917
  import { EVIDENCE_REFERENCE_CONTRACT as EVIDENCE_REFERENCE_CONTRACT4 } from "@obsunified/types";
12918
+
12919
+ // src/lib/metric-alerts.ts
12920
+ var METRIC_AGGREGATIONS = [
12921
+ "last",
12922
+ "min",
12923
+ "max",
12924
+ "avg",
12925
+ "sum",
12926
+ "increase",
12927
+ "rate",
12928
+ "age_seconds"
12929
+ ];
12930
+ var METRIC_FIELDS = [
12931
+ "value",
12932
+ "count",
12933
+ "sum"
12934
+ ];
12935
+ var TEMPORALITY_DELTA = 1;
12936
+ var MAX_METRIC_NAMES = 20;
12937
+ var MAX_ATTRIBUTE_KEYS = 32;
12938
+ var MAX_MATCHED_SERIES = 1e3;
12939
+ var MAX_LOADED_POINTS = 5e4;
12940
+ var MAX_METRIC_WINDOW_MINS = 1440;
12941
+ var DEFAULT_STALE_SECS = 300;
12942
+ var MAX_STALE_SECS = 86400;
12943
+ var METRIC_NO_DATA_POLICIES = ["zero", "keep"];
12944
+ var ID_CHUNK = 50;
12945
+ var SELECTOR_KEYS = [
12946
+ "metric",
12947
+ "attributes",
12948
+ "resourceAttributes",
12949
+ "serviceName",
12950
+ "field"
12951
+ ];
12952
+ var QUERY_KEYS = [
12953
+ ...SELECTOR_KEYS,
12954
+ "aggregation",
12955
+ "denominator",
12956
+ "minDenominator",
12957
+ "staleSecs",
12958
+ "noData"
12959
+ ];
12960
+ var DENOMINATOR_KEYS = [...SELECTOR_KEYS, "aggregation"];
12961
+ var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
12962
+ var validateAttributes = (value, path) => {
12963
+ if (value === void 0) return;
12964
+ if (!isPlainObject(value))
12965
+ throw new Error(`${path} must be an object of attribute: value pairs`);
12966
+ const entries = Object.entries(value);
12967
+ if (entries.length > MAX_ATTRIBUTE_KEYS)
12968
+ throw new Error(`${path} may list at most ${MAX_ATTRIBUTE_KEYS} keys`);
12969
+ for (const [key, v] of entries) {
12970
+ if (!key.trim()) throw new Error(`${path} has an empty attribute name`);
12971
+ const ok = typeof v === "string" || typeof v === "boolean" || typeof v === "number" && Number.isFinite(v);
12972
+ if (!ok)
12973
+ throw new Error(
12974
+ `${path}.${key} must be a string, finite number or boolean`
12975
+ );
12976
+ }
12977
+ };
12978
+ var validateSelector = (q, path, allowedKeys) => {
12979
+ for (const key of Object.keys(q)) {
12980
+ if (!allowedKeys.includes(key))
12981
+ throw new Error(
12982
+ `${path}.${key} is not a metrics query field (allowed: ${allowedKeys.join(", ")})`
12983
+ );
12984
+ }
12985
+ const metric = q.metric;
12986
+ if (typeof metric === "string") {
12987
+ if (!metric.trim()) throw new Error(`${path}.metric must not be empty`);
12988
+ } else if (Array.isArray(metric)) {
12989
+ if (metric.length === 0)
12990
+ throw new Error(`${path}.metric must name at least one metric`);
12991
+ if (metric.length > MAX_METRIC_NAMES)
12992
+ throw new Error(
12993
+ `${path}.metric may name at most ${MAX_METRIC_NAMES} metrics`
12994
+ );
12995
+ for (const m of metric) {
12996
+ if (typeof m !== "string" || !m.trim())
12997
+ throw new Error(`${path}.metric entries must be non-empty strings`);
12998
+ }
12999
+ } else {
13000
+ throw new Error(
13001
+ `${path}.metric is required: a metric name or a list of names`
13002
+ );
13003
+ }
13004
+ validateAttributes(q.attributes, `${path}.attributes`);
13005
+ validateAttributes(q.resourceAttributes, `${path}.resourceAttributes`);
13006
+ if (q.serviceName !== void 0 && (typeof q.serviceName !== "string" || !q.serviceName.trim()))
13007
+ throw new Error(`${path}.serviceName must be a non-empty string`);
13008
+ if (q.field !== void 0 && !METRIC_FIELDS.includes(q.field))
13009
+ throw new Error(
13010
+ `${path}.field must be one of: ${METRIC_FIELDS.join(", ")}`
13011
+ );
13012
+ };
13013
+ function validateMetricQuery(query) {
13014
+ if (!isPlainObject(query))
13015
+ throw new Error("query must be an object for the metrics signal");
13016
+ validateSelector(query, "query", QUERY_KEYS);
13017
+ const aggregation = query.aggregation;
13018
+ if (!METRIC_AGGREGATIONS.includes(aggregation))
13019
+ throw new Error(
13020
+ `query.aggregation must be one of: ${METRIC_AGGREGATIONS.join(", ")}`
13021
+ );
13022
+ if (query.staleSecs !== void 0 && (!Number.isInteger(query.staleSecs) || query.staleSecs < 1 || query.staleSecs > MAX_STALE_SECS))
13023
+ throw new Error(
13024
+ `query.staleSecs must be an integer from 1 to ${MAX_STALE_SECS}`
13025
+ );
13026
+ if (query.noData !== void 0 && !METRIC_NO_DATA_POLICIES.includes(query.noData))
13027
+ throw new Error(
13028
+ `query.noData must be one of: ${METRIC_NO_DATA_POLICIES.join(", ")}`
13029
+ );
13030
+ if (query.denominator === void 0) {
13031
+ if (query.minDenominator !== void 0)
13032
+ throw new Error("query.minDenominator requires query.denominator");
13033
+ return;
13034
+ }
13035
+ if (aggregation === "age_seconds")
13036
+ throw new Error("query.denominator cannot be used with age_seconds");
13037
+ if (!isPlainObject(query.denominator))
13038
+ throw new Error("query.denominator must be an object");
13039
+ validateSelector(query.denominator, "query.denominator", DENOMINATOR_KEYS);
13040
+ const denAgg = query.denominator.aggregation;
13041
+ if (denAgg !== void 0) {
13042
+ if (denAgg === "age_seconds" || !METRIC_AGGREGATIONS.includes(denAgg))
13043
+ throw new Error(
13044
+ `query.denominator.aggregation must be one of: ${METRIC_AGGREGATIONS.filter((a) => a !== "age_seconds").join(", ")}`
13045
+ );
13046
+ }
13047
+ if (query.minDenominator !== void 0 && (typeof query.minDenominator !== "number" || !Number.isFinite(query.minDenominator) || query.minDenominator < 0))
13048
+ throw new Error("query.minDenominator must be a finite number >= 0");
13049
+ }
13050
+ var toBigInt = (ns) => {
13051
+ if (!ns) return null;
13052
+ try {
13053
+ return BigInt(ns);
13054
+ } catch {
13055
+ return null;
13056
+ }
13057
+ };
13058
+ var readField = (p, field) => {
13059
+ const raw = field === "count" ? p.count : field === "sum" ? p.sum : p.value;
13060
+ const n = typeof raw === "string" ? Number(raw) : raw;
13061
+ return typeof n === "number" && Number.isFinite(n) ? n : null;
13062
+ };
13063
+ var sortPoints = (points) => [...points].sort((a, b) => {
13064
+ const ta = toBigInt(a.tsNs) ?? 0n;
13065
+ const tb = toBigInt(b.tsNs) ?? 0n;
13066
+ if (ta !== tb) return ta < tb ? -1 : 1;
13067
+ return a.receivedAt < b.receivedAt ? -1 : a.receivedAt > b.receivedAt ? 1 : 0;
13068
+ });
13069
+ var cumulativeIncrease = (points, windowStartNs, bornInWindow) => {
13070
+ if (points.length === 0) return 0;
13071
+ const first = points[0];
13072
+ const countsFirst = first.start !== null ? first.start >= windowStartNs : bornInWindow;
13073
+ let total = countsFirst ? first.v : 0;
13074
+ for (let i = 1; i < points.length; i++) {
13075
+ const prev = points[i - 1];
13076
+ const cur = points[i];
13077
+ const restarted = cur.start !== null && prev.start !== null && cur.start !== prev.start;
13078
+ if (restarted || cur.v < prev.v) total += cur.v;
13079
+ else total += cur.v - prev.v;
13080
+ }
13081
+ return total;
13082
+ };
13083
+ function reduceSeries(points, opts) {
13084
+ const usable = [];
13085
+ for (const p of sortPoints(points)) {
13086
+ const v = readField(p, opts.field);
13087
+ if (v !== null) usable.push({ v, start: toBigInt(p.startTsNs) });
13088
+ }
13089
+ if (usable.length === 0) return null;
13090
+ const values = usable.map((u) => u.v);
13091
+ switch (opts.aggregation) {
13092
+ case "last":
13093
+ return values[values.length - 1];
13094
+ case "min":
13095
+ return values.reduce((a, b) => b < a ? b : a);
13096
+ case "max":
13097
+ return values.reduce((a, b) => b > a ? b : a);
13098
+ case "sum":
13099
+ return values.reduce((a, b) => a + b, 0);
13100
+ case "avg":
13101
+ return values.reduce((a, b) => a + b, 0) / values.length;
13102
+ case "increase":
13103
+ case "rate": {
13104
+ const increase = opts.temporality === TEMPORALITY_DELTA ? values.reduce((a, b) => a + b, 0) : cumulativeIncrease(
13105
+ usable,
13106
+ opts.windowStartNs,
13107
+ opts.bornInWindow ?? false
13108
+ );
13109
+ return opts.aggregation === "rate" ? increase / opts.windowSecs : increase;
13110
+ }
13111
+ }
13112
+ }
13113
+ var metricRatio = (numerator, denominator, minDenominator) => denominator > 0 && denominator >= minDenominator ? numerator / denominator : 0;
13114
+ var parseAttrs2 = (raw) => {
13115
+ if (raw === null || raw === void 0) return {};
13116
+ if (typeof raw === "string") {
13117
+ try {
13118
+ const parsed = JSON.parse(raw);
13119
+ return isPlainObject(parsed) ? parsed : {};
13120
+ } catch {
13121
+ return {};
13122
+ }
13123
+ }
13124
+ return isPlainObject(raw) ? raw : {};
13125
+ };
13126
+ var attrsMatch = (stored, wanted) => {
13127
+ if (!wanted) return true;
13128
+ for (const [key, expected] of Object.entries(wanted)) {
13129
+ const actual = stored[key];
13130
+ if (actual === void 0 || actual === null) return false;
13131
+ if (typeof actual === "object") return false;
13132
+ if (String(actual) !== String(expected)) return false;
13133
+ }
13134
+ return true;
13135
+ };
13136
+ var metricNames = (selector) => [
13137
+ ...new Set(
13138
+ (Array.isArray(selector.metric) ? selector.metric : [selector.metric]).map(
13139
+ (m) => m.trim()
13140
+ )
13141
+ )
13142
+ ];
13143
+ var chunks = (items, size) => {
13144
+ const out = [];
13145
+ for (let i = 0; i < items.length; i += size)
13146
+ out.push(items.slice(i, i + size));
13147
+ return out;
13148
+ };
13149
+ async function matchingSeries(db, projectId, selector) {
13150
+ const names = metricNames(selector);
13151
+ let sql = `SELECT id, temporality, created_at, attributes_json, resource_attrs_json
13152
+ FROM metric_series
13153
+ WHERE project_id = ? AND name IN (${names.map(() => "?").join(", ")})`;
13154
+ const binds = [projectId, ...names];
13155
+ if (selector.serviceName) {
13156
+ sql += ` AND service_name = ?`;
13157
+ binds.push(selector.serviceName);
13158
+ }
13159
+ const rs = await db.prepare(sql).bind(...binds).all();
13160
+ const matched = (rs.results ?? []).filter(
13161
+ (row) => attrsMatch(parseAttrs2(row.attributes_json), selector.attributes) && attrsMatch(
13162
+ parseAttrs2(row.resource_attrs_json),
13163
+ selector.resourceAttributes
13164
+ )
13165
+ );
13166
+ if (matched.length > MAX_MATCHED_SERIES)
13167
+ throw new Error(
13168
+ `metrics query matches ${matched.length} series (limit ${MAX_MATCHED_SERIES}); narrow it with attributes or serviceName`
13169
+ );
13170
+ return matched.map((row) => ({
13171
+ id: row.id,
13172
+ createdAt: row.created_at,
13173
+ temporality: row.temporality === null || row.temporality === void 0 ? null : Number(row.temporality)
13174
+ }));
13175
+ }
13176
+ var STALENESS_APPLIES = /* @__PURE__ */ new Set([
13177
+ "last",
13178
+ "min",
13179
+ "max",
13180
+ "avg",
13181
+ "sum"
13182
+ ]);
13183
+ async function evaluateSelector(db, selector, aggregation, ctx) {
13184
+ const series = await matchingSeries(db, ctx.projectId, selector);
13185
+ if (series.length === 0) return { value: 0, contributing: 0 };
13186
+ const windowStartMs = ctx.now.getTime() - ctx.windowMins * 6e4;
13187
+ const cutoff = new Date(windowStartMs).toISOString();
13188
+ const bySeries = /* @__PURE__ */ new Map();
13189
+ let loaded = 0;
13190
+ for (const chunk of chunks(series, ID_CHUNK)) {
13191
+ const cap = ctx.maxLoadedPoints ?? MAX_LOADED_POINTS;
13192
+ const budget = cap - loaded;
13193
+ const rs = await db.prepare(
13194
+ `SELECT series_id, ts_ns, start_ts_ns, value, count, sum, received_at
13195
+ FROM metric_point
13196
+ WHERE project_id = ? AND series_id IN (${chunk.map(() => "?").join(", ")})
13197
+ AND received_at >= ?
13198
+ LIMIT ?`
13199
+ ).bind(ctx.projectId, ...chunk.map((s) => s.id), cutoff, budget + 1).all();
13200
+ const rows = rs.results ?? [];
13201
+ loaded += rows.length;
13202
+ if (loaded > cap)
13203
+ throw new Error(
13204
+ `metrics query loads more than ${cap} points in its window; shorten windowMins or narrow the query`
13205
+ );
13206
+ for (const row of rows) {
13207
+ const list = bySeries.get(row.series_id) ?? [];
13208
+ list.push({
13209
+ tsNs: String(row.ts_ns),
13210
+ startTsNs: row.start_ts_ns === null ? null : String(row.start_ts_ns),
13211
+ value: row.value,
13212
+ count: row.count,
13213
+ sum: row.sum,
13214
+ receivedAt: row.received_at
13215
+ });
13216
+ bySeries.set(row.series_id, list);
13217
+ }
13218
+ }
13219
+ const field = selector.field ?? "value";
13220
+ const windowStartNs = BigInt(windowStartMs) * 1000000n;
13221
+ const freshCutoff = new Date(
13222
+ ctx.now.getTime() - (ctx.staleSecs ?? DEFAULT_STALE_SECS) * 1e3
13223
+ ).toISOString();
13224
+ let total = 0;
13225
+ let contributing = 0;
13226
+ for (const s of series) {
13227
+ const points = bySeries.get(s.id) ?? [];
13228
+ if (STALENESS_APPLIES.has(aggregation)) {
13229
+ const newest = points.reduce(
13230
+ (max, p) => max === null || p.receivedAt > max ? p.receivedAt : max,
13231
+ null
13232
+ );
13233
+ if (newest === null || newest < freshCutoff) continue;
13234
+ }
13235
+ const reduced = reduceSeries(points, {
13236
+ aggregation,
13237
+ field,
13238
+ temporality: s.temporality,
13239
+ windowStartNs,
13240
+ windowSecs: ctx.windowMins * 60,
13241
+ bornInWindow: s.createdAt >= cutoff
13242
+ });
13243
+ if (reduced !== null) {
13244
+ total += reduced;
13245
+ contributing += 1;
13246
+ }
13247
+ }
13248
+ return { value: total, contributing };
13249
+ }
13250
+ async function ageSeconds(db, selector, ctx) {
13251
+ const series = await matchingSeries(db, ctx.projectId, selector);
13252
+ let newest = null;
13253
+ for (const chunk of chunks(series, ID_CHUNK)) {
13254
+ const row = await db.prepare(
13255
+ `SELECT MAX(received_at) AS newest FROM metric_point
13256
+ WHERE project_id = ? AND series_id IN (${chunk.map(() => "?").join(", ")})`
13257
+ ).bind(ctx.projectId, ...chunk.map((s) => s.id)).first();
13258
+ if (row?.newest && (newest === null || row.newest > newest))
13259
+ newest = row.newest;
13260
+ }
13261
+ const reference = Date.parse(newest ?? ctx.ruleCreatedAt);
13262
+ if (!Number.isFinite(reference)) return 0;
13263
+ return Math.max(0, (ctx.now.getTime() - reference) / 1e3);
13264
+ }
13265
+ async function evaluateMetricQuery(db, query, ctx) {
13266
+ if (query.aggregation === "age_seconds")
13267
+ return { value: await ageSeconds(db, query, ctx), noData: false };
13268
+ const withStale = { ...ctx, staleSecs: query.staleSecs ?? ctx.staleSecs };
13269
+ const numerator = await evaluateSelector(
13270
+ db,
13271
+ query,
13272
+ query.aggregation,
13273
+ withStale
13274
+ );
13275
+ if (!query.denominator)
13276
+ return {
13277
+ value: numerator.value,
13278
+ noData: numerator.contributing === 0
13279
+ };
13280
+ const denominator = await evaluateSelector(
13281
+ db,
13282
+ query.denominator,
13283
+ query.denominator.aggregation ?? query.aggregation,
13284
+ withStale
13285
+ );
13286
+ return {
13287
+ value: metricRatio(
13288
+ numerator.value,
13289
+ denominator.value,
13290
+ query.minDenominator ?? 0
13291
+ ),
13292
+ noData: numerator.contributing === 0 && denominator.contributing === 0
13293
+ };
13294
+ }
13295
+
13296
+ // src/lib/alerts-store.ts
13297
+ var MAX_PENDING_SECS = 86400;
13298
+ var EMAIL_UNAVAILABLE_MESSAGE = "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";
12877
13299
  var parseJsonField = (value) => {
12878
13300
  if (typeof value === "string") return JSON.parse(value);
12879
13301
  return value;
@@ -12892,6 +13314,8 @@ var rowToRule = (row, state) => ({
12892
13314
  createdAt: row.created_at,
12893
13315
  updatedAt: row.updated_at,
12894
13316
  analysisId: row.analysis_id ?? null,
13317
+ pendingSecs: Number(row.pending_secs ?? 0),
13318
+ pendingSince: state?.pending_since ?? null,
12895
13319
  currentState: state?.current_state,
12896
13320
  lastStateChange: state?.last_state_change ?? null
12897
13321
  });
@@ -12902,11 +13326,18 @@ var rowToEvaluation = (row) => ({
12902
13326
  evaluatedAt: row.evaluated_at,
12903
13327
  value: row.value,
12904
13328
  state: row.state,
12905
- notified: row.notified === 1
13329
+ notified: row.notified === 1,
13330
+ outcome: row.outcome ?? "value",
13331
+ error: row.error_message ?? null
12906
13332
  });
13333
+ var MAX_ERROR_MESSAGE = 500;
13334
+ var sanitizeEvaluationError = (message) => {
13335
+ const cleaned = message.replace(/[a-z][a-z0-9+.-]*:\/\/[^\s"'<>]+/gi, "<url>").replace(/\s+/g, " ").trim();
13336
+ return cleaned.length > MAX_ERROR_MESSAGE ? `${cleaned.slice(0, MAX_ERROR_MESSAGE - 1)}\u2026` : cleaned;
13337
+ };
12907
13338
  var windowCutoffIso = (windowMins) => new Date(Date.now() - windowMins * 60 * 1e3).toISOString();
12908
- var SIGNALS2 = ["spans", "logs", "usage", "ai"];
12909
- var COMPARISONS = [">", ">=", "<", "<="];
13339
+ var SIGNALS2 = ["spans", "logs", "usage", "ai", "metrics"];
13340
+ var COMPARISONS = [">", ">=", "<", "<=", "==", "!="];
12910
13341
  function compareValue(value, threshold, comparison) {
12911
13342
  switch (comparison) {
12912
13343
  case ">":
@@ -12917,6 +13348,10 @@ function compareValue(value, threshold, comparison) {
12917
13348
  return value < threshold;
12918
13349
  case "<=":
12919
13350
  return value <= threshold;
13351
+ case "==":
13352
+ return value === threshold;
13353
+ case "!=":
13354
+ return value !== threshold;
12920
13355
  }
12921
13356
  }
12922
13357
  var AlertsStore = class {
@@ -12934,11 +13369,46 @@ var AlertsStore = class {
12934
13369
  throw new Error("windowMins must be >= 1");
12935
13370
  if (!COMPARISONS.includes(input.comparison))
12936
13371
  throw new Error(`comparison must be one of: ${COMPARISONS.join(", ")}`);
13372
+ if (input.pendingSecs !== void 0 && (!Number.isInteger(input.pendingSecs) || input.pendingSecs < 0 || input.pendingSecs > MAX_PENDING_SECS))
13373
+ throw new Error(
13374
+ `pendingSecs must be an integer from 0 to ${MAX_PENDING_SECS}`
13375
+ );
13376
+ if (input.signal === "metrics") {
13377
+ validateMetricQuery(input.query);
13378
+ if (input.windowMins > MAX_METRIC_WINDOW_MINS)
13379
+ throw new Error(
13380
+ `windowMins must be <= ${MAX_METRIC_WINDOW_MINS} for the metrics signal`
13381
+ );
13382
+ }
12937
13383
  if (!Array.isArray(input.channels) || input.channels.length === 0)
12938
13384
  throw new Error("at least one channel is required");
12939
13385
  for (const ch of input.channels) {
13386
+ if (ch.type === "email") {
13387
+ if (!Array.isArray(ch.to) || ch.to.length === 0)
13388
+ throw new Error("email channel.to must list at least one address");
13389
+ if (ch.to.length > MAX_EMAIL_RECIPIENTS)
13390
+ throw new Error(
13391
+ `email channel.to may list at most ${MAX_EMAIL_RECIPIENTS} addresses`
13392
+ );
13393
+ for (const addr of ch.to) {
13394
+ if (typeof addr !== "string" || !isValidEmailAddress(addr))
13395
+ throw new Error(
13396
+ `email channel.to has an invalid address: ${JSON.stringify(addr)}`
13397
+ );
13398
+ }
13399
+ const extra = Object.keys(ch).filter((k) => k !== "type" && k !== "to");
13400
+ if (extra.length > 0)
13401
+ throw new Error(
13402
+ `email channel accepts only "type" and "to" (SMTP settings come from the collector environment); got: ${extra.join(", ")}`
13403
+ );
13404
+ if (!this.options.emailDeliveryAvailable)
13405
+ throw new Error(EMAIL_UNAVAILABLE_MESSAGE);
13406
+ continue;
13407
+ }
12940
13408
  if (ch.type !== "webhook")
12941
- throw new Error(`unknown channel type: ${ch.type}`);
13409
+ throw new Error(
13410
+ `unknown channel type: ${ch.type}`
13411
+ );
12942
13412
  if (!ch.url || typeof ch.url !== "string")
12943
13413
  throw new Error("channel.url must be http(s)://");
12944
13414
  try {
@@ -12961,7 +13431,55 @@ var AlertsStore = class {
12961
13431
  const stateByRule = new Map(
12962
13432
  (stateRs.results ?? []).map((s) => [s.rule_id, s])
12963
13433
  );
12964
- return rows.map((r) => rowToRule(r, stateByRule.get(r.id)));
13434
+ const lastByRule = await this.lastEvaluations(projectId);
13435
+ return rows.map((r) => {
13436
+ const state = stateByRule.get(r.id);
13437
+ const last = lastByRule.get(r.id);
13438
+ return {
13439
+ ...rowToRule(r, state),
13440
+ notificationDelivered: state ? state.notified === void 0 || state.notified === null ? true : Number(state.notified) === 1 : null,
13441
+ lastEvaluation: last ? {
13442
+ evaluatedAt: last.evaluated_at,
13443
+ outcome: last.outcome ?? "value",
13444
+ value: Number(last.value),
13445
+ error: (last.outcome ?? "value") === "error" ? last.error_message ?? null : null
13446
+ } : null
13447
+ };
13448
+ });
13449
+ }
13450
+ /**
13451
+ * The newest evaluation of every rule in the project, one query. Falls
13452
+ * back to the pre-057/059 column set on older databases.
13453
+ */
13454
+ async lastEvaluations(projectId) {
13455
+ const run = (columns) => this.db.prepare(
13456
+ `SELECT e.rule_id, e.evaluated_at, e.value${columns}
13457
+ FROM alert_evaluations e
13458
+ WHERE e.project_id = ?
13459
+ AND e.evaluated_at = (
13460
+ SELECT MAX(e2.evaluated_at) FROM alert_evaluations e2
13461
+ WHERE e2.rule_id = e.rule_id
13462
+ )`
13463
+ ).bind(projectId).all();
13464
+ let rs;
13465
+ try {
13466
+ rs = await run(", e.outcome, e.error_message");
13467
+ } catch (err) {
13468
+ if (!isMissingColumn(err, "error_message") && !isMissingColumn(err, "outcome"))
13469
+ throw err;
13470
+ try {
13471
+ rs = await run(", e.outcome");
13472
+ } catch (inner) {
13473
+ if (!isMissingColumn(inner, "outcome")) throw inner;
13474
+ rs = await run("");
13475
+ }
13476
+ }
13477
+ const out = /* @__PURE__ */ new Map();
13478
+ for (const row of rs.results ?? []) {
13479
+ const seen = out.get(row.rule_id);
13480
+ if (!seen || row.outcome === "error") out.set(row.rule_id, row);
13481
+ }
13482
+ return out;
12965
13483
  }
12966
13484
  async getRule(id, projectId) {
12967
13485
  const row = await this.db.prepare(`SELECT * FROM alert_rules WHERE id = ? AND project_id = ?`).bind(id, projectId).first();
@@ -12973,10 +13491,8 @@ var AlertsStore = class {
12973
13491
  this.validateInput(input);
12974
13492
  const id = randomHex(16);
12975
13493
  const now = (/* @__PURE__ */ new Date()).toISOString();
12976
- await this.db.prepare(
12977
- `INSERT INTO alert_rules (id, project_id, name, signal, query_json, threshold, window_mins, comparison, channels_json, enabled, created_at, updated_at, analysis_id)
12978
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
12979
- ).bind(
13494
+ const pendingSecs = input.pendingSecs ?? 0;
13495
+ const values = [
12980
13496
  id,
12981
13497
  projectId,
12982
13498
  input.name.trim(),
@@ -12990,7 +13506,18 @@ var AlertsStore = class {
12990
13506
  now,
12991
13507
  now,
12992
13508
  input.analysisId ?? null
12993
- ).run();
13509
+ ];
13510
+ await this.withPendingColumnFallback(
13511
+ pendingSecs,
13512
+ () => this.db.prepare(
13513
+ `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)
13514
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
13515
+ ).bind(...values, pendingSecs).run(),
13516
+ () => this.db.prepare(
13517
+ `INSERT INTO alert_rules (id, project_id, name, signal, query_json, threshold, window_mins, comparison, channels_json, enabled, created_at, updated_at, analysis_id)
13518
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
13519
+ ).bind(...values).run()
13520
+ );
12994
13521
  const created = await this.getRule(id, projectId);
12995
13522
  if (!created) throw new Error("Failed to create rule");
12996
13523
  return created;
@@ -13007,16 +13534,13 @@ var AlertsStore = class {
13007
13534
  comparison: patch.comparison ?? existing.comparison,
13008
13535
  channels: patch.channels ?? existing.channels,
13009
13536
  enabled: patch.enabled ?? existing.enabled,
13010
- analysisId: "analysisId" in patch ? patch.analysisId : existing.analysisId
13537
+ analysisId: "analysisId" in patch ? patch.analysisId : existing.analysisId,
13538
+ pendingSecs: patch.pendingSecs ?? existing.pendingSecs
13011
13539
  };
13012
13540
  this.validateInput(merged);
13013
13541
  const now = (/* @__PURE__ */ new Date()).toISOString();
13014
- await this.db.prepare(
13015
- `UPDATE alert_rules SET
13016
- name = ?, signal = ?, query_json = ?, threshold = ?, window_mins = ?,
13017
- comparison = ?, channels_json = ?, enabled = ?, updated_at = ?, analysis_id = ?
13018
- WHERE id = ? AND project_id = ?`
13019
- ).bind(
13542
+ const pendingSecs = merged.pendingSecs ?? 0;
13543
+ const values = [
13020
13544
  merged.name.trim(),
13021
13545
  merged.signal,
13022
13546
  JSON.stringify(merged.query),
@@ -13026,12 +13550,72 @@ var AlertsStore = class {
13026
13550
  JSON.stringify(merged.channels),
13027
13551
  merged.enabled === false ? 0 : 1,
13028
13552
  now,
13029
- merged.analysisId ?? null,
13030
- id,
13031
- projectId
13032
- ).run();
13553
+ merged.analysisId ?? null
13554
+ ];
13555
+ await this.withPendingColumnFallback(
13556
+ pendingSecs,
13557
+ () => this.db.prepare(
13558
+ `UPDATE alert_rules SET
13559
+ name = ?, signal = ?, query_json = ?, threshold = ?, window_mins = ?,
13560
+ comparison = ?, channels_json = ?, enabled = ?, updated_at = ?, analysis_id = ?,
13561
+ pending_secs = ?
13562
+ WHERE id = ? AND project_id = ?`
13563
+ ).bind(...values, pendingSecs, id, projectId).run(),
13564
+ () => this.db.prepare(
13565
+ `UPDATE alert_rules SET
13566
+ name = ?, signal = ?, query_json = ?, threshold = ?, window_mins = ?,
13567
+ comparison = ?, channels_json = ?, enabled = ?, updated_at = ?, analysis_id = ?
13568
+ WHERE id = ? AND project_id = ?`
13569
+ ).bind(...values, id, projectId).run()
13570
+ );
13571
+ const evaluationChanged = merged.signal !== existing.signal || JSON.stringify(merged.query) !== JSON.stringify(existing.query) || merged.threshold !== existing.threshold || merged.windowMins !== existing.windowMins || merged.comparison !== existing.comparison || merged.enabled !== false !== existing.enabled || (merged.analysisId ?? null) !== (existing.analysisId ?? null) || pendingSecs !== (existing.pendingSecs ?? 0);
13572
+ if (evaluationChanged) await this.resetPending(id, projectId);
13033
13573
  return this.getRule(id, projectId);
13034
13574
  }
13575
+ /**
13576
+ * Clears a rule's pending period. Tolerates databases that have not
13577
+ * applied 055/058 (there is nothing to clear on them).
13578
+ */
13579
+ async resetPending(ruleId, projectId) {
13580
+ try {
13581
+ await this.db.prepare(
13582
+ `UPDATE alert_state SET pending_since = NULL, pending_seen_at = NULL
13583
+ WHERE rule_id = ? AND project_id = ?`
13584
+ ).bind(ruleId, projectId).run();
13585
+ } catch (err) {
13586
+ if (!isMissingColumn(err, "pending_seen_at")) {
13587
+ if (isMissingColumn(err, "pending_since")) return;
13588
+ throw err;
13589
+ }
13590
+ try {
13591
+ await this.db.prepare(
13592
+ `UPDATE alert_state SET pending_since = NULL
13593
+ WHERE rule_id = ? AND project_id = ?`
13594
+ ).bind(ruleId, projectId).run();
13595
+ } catch (inner) {
13596
+ if (!isMissingColumn(inner, "pending_since")) throw inner;
13597
+ }
13598
+ }
13599
+ }
13600
+ /**
13601
+ * Runs `current`; on a database that has not applied migration 054 (no
13602
+ * `alert_rules.pending_secs`) a rule without a pending duration is
13603
+ * written with the legacy statement so rolling deploys keep working. A
13604
+ * rule that needs the column is refused with a clear message instead of
13605
+ * silently losing its pending duration.
13606
+ */
13607
+ async withPendingColumnFallback(pendingSecs, current, legacy) {
13608
+ try {
13609
+ return await current();
13610
+ } catch (err) {
13611
+ if (!isMissingColumn(err, "pending_secs")) throw err;
13612
+ if (pendingSecs > 0)
13613
+ throw new Error(
13614
+ "pendingSecs needs database migration 054 (alert_rules.pending_secs); apply migrations and retry"
13615
+ );
13616
+ return legacy();
13617
+ }
13618
+ }
13035
13619
  async deleteRule(id, projectId) {
13036
13620
  const owned = await this.db.prepare(
13037
13621
  `SELECT 1 AS ok FROM alert_rules WHERE id = ? AND project_id = ?`
@@ -13102,11 +13686,32 @@ var AlertsStore = class {
13102
13686
  throw err;
13103
13687
  }
13104
13688
  }
13105
- async recordEvaluation(ruleId, projectId, value, state, notified) {
13689
+ /**
13690
+ * Record (`{ since, seenAt }`) or clear (`null`) the pending period of a
13691
+ * rule that has a pending duration (migrations 055, 058): when the
13692
+ * condition started holding, and the last evaluation that saw it hold.
13693
+ * A rule without a state row gets one in state `ok` since its creation,
13694
+ * already delivered (nothing is owed for a rule that never changed
13695
+ * state).
13696
+ */
13697
+ async setPending(ruleId, projectId, pending, ruleCreatedAt) {
13106
13698
  await this.db.prepare(
13107
- `INSERT INTO alert_evaluations (id, rule_id, project_id, evaluated_at, value, state, notified)
13108
- VALUES (?, ?, ?, ?, ?, ?, ?)`
13699
+ `INSERT INTO alert_state (rule_id, project_id, current_state, last_state_change, notified, pending_since, pending_seen_at)
13700
+ VALUES (?, ?, 'ok', ?, 1, ?, ?)
13701
+ ON CONFLICT(rule_id) DO UPDATE SET
13702
+ pending_since = excluded.pending_since,
13703
+ pending_seen_at = excluded.pending_seen_at`
13109
13704
  ).bind(
13705
+ ruleId,
13706
+ projectId,
13707
+ ruleCreatedAt,
13708
+ pending?.since ?? null,
13709
+ pending?.seenAt ?? null
13710
+ ).run();
13711
+ }
13712
+ async recordEvaluation(ruleId, projectId, value, state, notified, outcome = "value", errorMessage) {
13713
+ const error = outcome === "error" && errorMessage ? sanitizeEvaluationError(errorMessage) : null;
13714
+ const values = [
13110
13715
  randomHex(16),
13111
13716
  ruleId,
13112
13717
  projectId,
@@ -13114,7 +13719,32 @@ var AlertsStore = class {
13114
13719
  value,
13115
13720
  state,
13116
13721
  notified ? 1 : 0
13117
- ).run();
13722
+ ];
13723
+ try {
13724
+ await this.db.prepare(
13725
+ `INSERT INTO alert_evaluations (id, rule_id, project_id, evaluated_at, value, state, notified, outcome, error_message)
13726
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
13727
+ ).bind(...values, outcome, error).run();
13728
+ return;
13729
+ } catch (err) {
13730
+ if (!isMissingColumn(err, "error_message")) {
13731
+ if (!isMissingColumn(err, "outcome")) throw err;
13732
+ } else {
13733
+ try {
13734
+ await this.db.prepare(
13735
+ `INSERT INTO alert_evaluations (id, rule_id, project_id, evaluated_at, value, state, notified, outcome)
13736
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
13737
+ ).bind(...values, outcome).run();
13738
+ return;
13739
+ } catch (inner) {
13740
+ if (!isMissingColumn(inner, "outcome")) throw inner;
13741
+ }
13742
+ }
13743
+ await this.db.prepare(
13744
+ `INSERT INTO alert_evaluations (id, rule_id, project_id, evaluated_at, value, state, notified)
13745
+ VALUES (?, ?, ?, ?, ?, ?, ?)`
13746
+ ).bind(...values).run();
13747
+ }
13118
13748
  }
13119
13749
  async listEvaluations(params) {
13120
13750
  const cutoff = new Date(
@@ -13136,7 +13766,21 @@ var AlertsStore = class {
13136
13766
  return response;
13137
13767
  }
13138
13768
  // ── Evaluators (count-over-window per signal) ──
13139
- async evaluateRule(rule) {
13769
+ async evaluateRule(rule, now = /* @__PURE__ */ new Date()) {
13770
+ return (await this.evaluateRuleWithStatus(rule, now)).value;
13771
+ }
13772
+ /** Like `evaluateRule`, plus whether a metrics rule found no fresh data. */
13773
+ async evaluateRuleWithStatus(rule, now = /* @__PURE__ */ new Date()) {
13774
+ if (rule.signal === "metrics" && !rule.analysisId)
13775
+ return evaluateMetricQuery(this.db, rule.query, {
13776
+ projectId: rule.projectId,
13777
+ windowMins: rule.windowMins,
13778
+ ruleCreatedAt: rule.createdAt,
13779
+ now
13780
+ });
13781
+ return { value: await this.evaluateCountRule(rule), noData: false };
13782
+ }
13783
+ async evaluateCountRule(rule) {
13140
13784
  if (rule.analysisId) {
13141
13785
  const row = await this.db.prepare(
13142
13786
  `SELECT primary_value FROM analysis_results
@@ -13154,6 +13798,12 @@ var AlertsStore = class {
13154
13798
  return this.evaluateUsageRule(rule);
13155
13799
  case "ai":
13156
13800
  return this.evaluateAIRule(rule);
13801
+ case "metrics":
13802
+ throw new Error(
13803
+ "metrics rules evaluate through evaluateRuleWithStatus"
13804
+ );
13805
+ default:
13806
+ throw new Error(`unknown alert signal: ${String(rule.signal)}`);
13157
13807
  }
13158
13808
  }
13159
13809
  /**
@@ -13239,14 +13889,46 @@ var AlertsStore = class {
13239
13889
  return row?.c ?? 0;
13240
13890
  }
13241
13891
  };
13242
- var isMissingNotifiedColumn = (err) => {
13892
+ var isMissingNotifiedColumn = (err) => isMissingColumn(err, "notified");
13893
+ var isMissingColumn = (err, column) => {
13243
13894
  const message = err instanceof Error ? err.message : String(err);
13244
- return /notified/i.test(message) && /no such column|has no column|does not exist/i.test(message);
13895
+ return message.toLowerCase().includes(column) && /no such column|has no column|does not exist/i.test(message);
13245
13896
  };
13246
13897
 
13247
13898
  // src/plugins/alerts-evaluator.ts
13248
13899
  var DEFAULT_RULE_CONCURRENCY = 5;
13249
13900
  var DEFAULT_RULE_TIMEOUT_MS = 1e4;
13901
+ var DEFAULT_MAX_EVALUATION_GAP_MS = 2 * 5 * 6e4;
13902
+ async function fireEmail(channel, payload, logger, tracer, delivery = {}) {
13903
+ const transport = delivery.email;
13904
+ if (!transport) {
13905
+ logger.error(
13906
+ "[alerts-evaluator] email channel has no SMTP transport on this collector",
13907
+ { rule_id: payload.rule.id, recipients: channel.to.length }
13908
+ );
13909
+ return false;
13910
+ }
13911
+ const exec = async (span) => {
13912
+ try {
13913
+ await transport.send({ to: channel.to, ...formatAlertEmail(payload) });
13914
+ if (span) span.setAttribute("alert.email.sent", true);
13915
+ return true;
13916
+ } catch (err) {
13917
+ if (span) span.setAttribute("alert.email.sent", false);
13918
+ logger.error("[alerts-evaluator] email delivery failed", {
13919
+ rule_id: payload.rule.id,
13920
+ recipients: channel.to.length,
13921
+ error: err instanceof Error ? err.message : String(err)
13922
+ });
13923
+ return false;
13924
+ }
13925
+ };
13926
+ if (!tracer) return exec(null);
13927
+ return tracer("email.alert", async (span) => exec(span), {
13928
+ "alert.state": payload.state,
13929
+ "alert.email.recipients": channel.to.length
13930
+ });
13931
+ }
13250
13932
  async function fireWebhook(channel, payload, logger, tracer, delivery = {}) {
13251
13933
  const target = redactWebhookUrl(channel.url);
13252
13934
  try {
@@ -13318,7 +14000,23 @@ async function fireWebhook(channel, payload, logger, tracer, delivery = {}) {
13318
14000
  async function fireChannels(channels, payload, logger, tracer, delivery = {}) {
13319
14001
  if (channels.length === 0) return true;
13320
14002
  const results = await Promise.all(
13321
- channels.filter((ch) => ch.type === "webhook").map((ch) => fireWebhook(ch, payload, logger, tracer, delivery))
14003
+ channels.map((ch) => {
14004
+ if (ch.type === "webhook")
14005
+ return fireWebhook(
14006
+ ch,
14007
+ payload,
14008
+ logger,
14009
+ tracer,
14010
+ delivery
14011
+ );
14012
+ if (ch.type === "email")
14013
+ return fireEmail(ch, payload, logger, tracer, delivery);
14014
+ logger.error("[alerts-evaluator] unknown channel type", {
14015
+ rule_id: payload.rule.id,
14016
+ type: String(ch.type)
14017
+ });
14018
+ return Promise.resolve(false);
14019
+ })
13322
14020
  );
13323
14021
  return results.every(Boolean);
13324
14022
  }
@@ -13349,19 +14047,47 @@ async function mapWithConcurrency(items, concurrency, mapper) {
13349
14047
  );
13350
14048
  return results;
13351
14049
  }
13352
- async function evaluateOneRule(store, rule, logger, tracer, ruleTimeoutMs, delivery, onAlertFiring) {
14050
+ async function evaluateOneRule(store, rule, logger, tracer, ruleTimeoutMs, delivery, hold, onAlertFiring) {
13353
14051
  const none = { fired: 0, resolved: 0, redelivered: 0 };
14052
+ let previousForError = null;
13354
14053
  try {
13355
- const value = await withTimeout(
13356
- store.evaluateRule(rule),
14054
+ const priorState = await store.getState(rule.id, rule.projectId);
14055
+ const previous = priorState?.current_state ?? "ok";
14056
+ previousForError = previous;
14057
+ const raw = await withTimeout(
14058
+ store.evaluateRuleWithStatus ? store.evaluateRuleWithStatus(rule) : store.evaluateRule(rule),
13357
14059
  ruleTimeoutMs,
13358
14060
  `rule evaluation timed out after ${ruleTimeoutMs}ms`
13359
14061
  );
13360
- const shouldFire = compareValue(value, rule.threshold, rule.comparison);
13361
- const priorState = await store.getState(rule.id, rule.projectId);
13362
- const previous = priorState?.current_state ?? "ok";
13363
- const next = shouldFire ? "firing" : "ok";
14062
+ const { value, noData } = typeof raw === "number" ? { value: raw, noData: false } : raw;
13364
14063
  const now = (/* @__PURE__ */ new Date()).toISOString();
14064
+ const nowMs = Date.parse(now);
14065
+ const keepOnNoData = noData && rule.signal === "metrics" && rule.query.noData === "keep";
14066
+ const matches = !keepOnNoData && compareValue(value, rule.threshold, rule.comparison);
14067
+ const pendingSecs = rule.pendingSecs ?? 0;
14068
+ const pendingSince = priorState?.pending_since ?? null;
14069
+ const seenAt = priorState?.pending_seen_at ?? pendingSince;
14070
+ const holdBroken = pendingSince !== null && (seenAt === null || nowMs - Date.parse(seenAt) > hold.maxEvaluationGapMs || hold.holdStartedAt !== void 0 && seenAt < hold.holdStartedAt);
14071
+ let pendingActive = pendingSince !== null;
14072
+ let shouldFire = matches;
14073
+ if (matches && previous === "ok" && pendingSecs > 0) {
14074
+ const since = holdBroken || pendingSince === null ? now : pendingSince;
14075
+ await store.setPending(
14076
+ rule.id,
14077
+ rule.projectId,
14078
+ { since, seenAt: now },
14079
+ rule.createdAt
14080
+ );
14081
+ pendingActive = true;
14082
+ shouldFire = nowMs - Date.parse(since) >= pendingSecs * 1e3;
14083
+ }
14084
+ const next = keepOnNoData ? previous : shouldFire ? "firing" : "ok";
14085
+ const clearPending = async () => {
14086
+ if (pendingActive && (!matches || next === "firing")) {
14087
+ await store.setPending(rule.id, rule.projectId, null, rule.createdAt);
14088
+ }
14089
+ };
14090
+ const outcome = keepOnNoData ? "no_data" : "value";
13365
14091
  const analysisAttachment = rule.analysisId ? await store.getAnalysisNarrative(rule.projectId, rule.analysisId).then(
13366
14092
  (n) => n ? {
13367
14093
  id: rule.analysisId,
@@ -13387,6 +14113,7 @@ async function evaluateOneRule(store, rule, logger, tracer, ruleTimeoutMs, deliv
13387
14113
  );
13388
14114
  if (previous !== next) {
13389
14115
  await store.transitionState(rule.id, rule.projectId, next, now);
14116
+ await clearPending();
13390
14117
  if (next === "firing" && onAlertFiring) {
13391
14118
  await onAlertFiring(rule).catch(() => void 0);
13392
14119
  }
@@ -13394,7 +14121,14 @@ async function evaluateOneRule(store, rule, logger, tracer, ruleTimeoutMs, deliv
13394
14121
  if (ok) {
13395
14122
  await store.markNotified(rule.id, rule.projectId, next, now);
13396
14123
  }
13397
- await store.recordEvaluation(rule.id, rule.projectId, value, next, ok);
14124
+ await store.recordEvaluation(
14125
+ rule.id,
14126
+ rule.projectId,
14127
+ value,
14128
+ next,
14129
+ ok,
14130
+ outcome
14131
+ );
13398
14132
  if (!ok) {
13399
14133
  logger.warn(
13400
14134
  "[alerts-evaluator] state recorded but delivery failed; will retry",
@@ -13403,6 +14137,7 @@ async function evaluateOneRule(store, rule, logger, tracer, ruleTimeoutMs, deliv
13403
14137
  }
13404
14138
  return next === "firing" ? { fired: 1, resolved: 0, redelivered: 0 } : { fired: 0, resolved: 1, redelivered: 0 };
13405
14139
  }
14140
+ await clearPending();
13406
14141
  const owed = priorState !== null && priorState.notified === 0;
13407
14142
  if (owed) {
13408
14143
  const ok = await deliver(previous);
@@ -13414,7 +14149,14 @@ async function evaluateOneRule(store, rule, logger, tracer, ruleTimeoutMs, deliv
13414
14149
  priorState.last_state_change
13415
14150
  );
13416
14151
  }
13417
- await store.recordEvaluation(rule.id, rule.projectId, value, next, ok);
14152
+ await store.recordEvaluation(
14153
+ rule.id,
14154
+ rule.projectId,
14155
+ value,
14156
+ next,
14157
+ ok,
14158
+ outcome
14159
+ );
13418
14160
  if (!ok) {
13419
14161
  logger.warn("[alerts-evaluator] delivery retry failed; will retry", {
13420
14162
  rule_id: rule.id,
@@ -13425,13 +14167,32 @@ async function evaluateOneRule(store, rule, logger, tracer, ruleTimeoutMs, deliv
13425
14167
  }
13426
14168
  return { fired: 0, resolved: 0, redelivered: 1 };
13427
14169
  }
13428
- await store.recordEvaluation(rule.id, rule.projectId, value, next, false);
14170
+ await store.recordEvaluation(
14171
+ rule.id,
14172
+ rule.projectId,
14173
+ value,
14174
+ next,
14175
+ false,
14176
+ outcome
14177
+ );
13429
14178
  } catch (err) {
14179
+ const message = err instanceof Error ? err.message : String(err);
13430
14180
  logger.error("[alerts-evaluator] rule evaluation failed", {
13431
14181
  rule_id: rule.id,
13432
14182
  project_id: rule.projectId,
13433
- error: err instanceof Error ? err.message : String(err)
14183
+ error: message
13434
14184
  });
14185
+ if (previousForError !== null) {
14186
+ await store.recordEvaluation(
14187
+ rule.id,
14188
+ rule.projectId,
14189
+ 0,
14190
+ previousForError,
14191
+ false,
14192
+ "error",
14193
+ message
14194
+ ).catch(() => void 0);
14195
+ }
13435
14196
  }
13436
14197
  return none;
13437
14198
  }
@@ -13439,7 +14200,8 @@ async function evaluateRuleBatch(store, rules, options) {
13439
14200
  const logger = options?.logger ?? consoleLogger;
13440
14201
  const delivery = {
13441
14202
  allowPrivateWebhookTargets: options?.allowPrivateWebhookTargets,
13442
- hostLookup: options?.hostLookup
14203
+ hostLookup: options?.hostLookup,
14204
+ email: options?.email
13443
14205
  };
13444
14206
  const concurrency = Math.max(
13445
14207
  1,
@@ -13459,6 +14221,10 @@ async function evaluateRuleBatch(store, rules, options) {
13459
14221
  options?.tracer,
13460
14222
  ruleTimeoutMs,
13461
14223
  delivery,
14224
+ {
14225
+ maxEvaluationGapMs: options?.maxEvaluationGapMs ?? DEFAULT_MAX_EVALUATION_GAP_MS,
14226
+ holdStartedAt: options?.holdStartedAt
14227
+ },
13462
14228
  options?.onAlertFiring
13463
14229
  )
13464
14230
  );
@@ -13474,7 +14240,10 @@ async function evaluateAllRules(env, options) {
13474
14240
  const tracer = options?.tracer;
13475
14241
  const allowPrivateWebhookTargets = env.ALERT_WEBHOOK_ALLOW_PRIVATE === "true";
13476
14242
  const db = sqlDbFor(env);
13477
- const store = new AlertsStore(db, { allowPrivateWebhookTargets });
14243
+ const store = new AlertsStore(db, {
14244
+ allowPrivateWebhookTargets,
14245
+ emailDeliveryAvailable: Boolean(env.ALERT_EMAIL)
14246
+ });
13478
14247
  const rules = await store.listEnabledRules();
13479
14248
  return evaluateRuleBatch(store, rules, {
13480
14249
  logger,
@@ -13483,6 +14252,9 @@ async function evaluateAllRules(env, options) {
13483
14252
  ruleTimeoutMs: options?.ruleTimeoutMs,
13484
14253
  allowPrivateWebhookTargets,
13485
14254
  hostLookup: options?.hostLookup,
14255
+ email: env.ALERT_EMAIL,
14256
+ maxEvaluationGapMs: options?.maxEvaluationGapMs,
14257
+ holdStartedAt: options?.holdStartedAt,
13486
14258
  onAlertFiring: async (rule) => {
13487
14259
  const target = await resolveServiceIncidentTarget(
13488
14260
  db,
@@ -13511,7 +14283,8 @@ function createAlertEvaluatorHandler() {
13511
14283
  // src/plugins/alerts-routes.ts
13512
14284
  import { EVIDENCE_REFERENCE_CONTRACT as EVIDENCE_REFERENCE_CONTRACT5 } from "@obsunified/types";
13513
14285
  var storeOptions = (env) => ({
13514
- allowPrivateWebhookTargets: env.ALERT_WEBHOOK_ALLOW_PRIVATE === "true"
14286
+ allowPrivateWebhookTargets: env.ALERT_WEBHOOK_ALLOW_PRIVATE === "true",
14287
+ emailDeliveryAvailable: Boolean(env.ALERT_EMAIL)
13515
14288
  });
13516
14289
  var alertsRoutesPlugin = {
13517
14290
  name: "alerts-routes",
@@ -13588,9 +14361,19 @@ var alertsRoutesPlugin = {
13588
14361
  const store = new AlertsStore(sqlDbFor(c.env), storeOptions(c.env));
13589
14362
  const rule = await store.getRule(id, projectId);
13590
14363
  if (!rule) return c.json({ error: "Rule not found" }, 404);
13591
- const value = await store.evaluateRule(rule);
14364
+ let value;
14365
+ let noData;
14366
+ try {
14367
+ ({ value, noData } = await store.evaluateRuleWithStatus(rule));
14368
+ } catch (err) {
14369
+ const message = err instanceof Error ? err.message : String(err);
14370
+ return c.json({ error: message }, 422);
14371
+ }
13592
14372
  const response = {
13593
14373
  value,
14374
+ noData,
14375
+ // The bare comparison: a rule with `pendingSecs` additionally
14376
+ // needs the condition to hold that long before it fires.
13594
14377
  wouldFire: compareValue(value, rule.threshold, rule.comparison),
13595
14378
  comparison: rule.comparison,
13596
14379
  threshold: rule.threshold,
@@ -16938,12 +17721,12 @@ async function mapWithConcurrency2(items, concurrency, mapper) {
16938
17721
  return results;
16939
17722
  }
16940
17723
  async function fetchReplayChunks(bucket, objects, concurrency = REPLAY_CHUNK_FETCH_CONCURRENCY) {
16941
- const chunks = await mapWithConcurrency2(objects, concurrency, async (obj) => {
17724
+ const chunks2 = await mapWithConcurrency2(objects, concurrency, async (obj) => {
16942
17725
  const objectData = await bucket.get(obj.key);
16943
17726
  if (!objectData) return [];
16944
17727
  return objectData.json();
16945
17728
  });
16946
- return chunks.flat();
17729
+ return chunks2.flat();
16947
17730
  }
16948
17731
  var replayQueryRoutesPlugin = {
16949
17732
  name: "replay-query-routes",
@@ -19870,7 +20653,7 @@ var gunzip = async (input, maxBytes) => {
19870
20653
  );
19871
20654
  if (!stream) throw new OtlpDecodeError("Empty gzip body", 400);
19872
20655
  const reader = stream.getReader();
19873
- const chunks = [];
20656
+ const chunks2 = [];
19874
20657
  let total = 0;
19875
20658
  try {
19876
20659
  for (; ; ) {
@@ -19885,7 +20668,7 @@ var gunzip = async (input, maxBytes) => {
19885
20668
  413
19886
20669
  );
19887
20670
  }
19888
- chunks.push(value);
20671
+ chunks2.push(value);
19889
20672
  }
19890
20673
  } catch (err) {
19891
20674
  if (err instanceof OtlpDecodeError) throw err;
@@ -19893,7 +20676,7 @@ var gunzip = async (input, maxBytes) => {
19893
20676
  }
19894
20677
  const out = new Uint8Array(total);
19895
20678
  let offset = 0;
19896
- for (const chunk of chunks) {
20679
+ for (const chunk of chunks2) {
19897
20680
  out.set(chunk, offset);
19898
20681
  offset += chunk.byteLength;
19899
20682
  }
@@ -23413,16 +24196,16 @@ var BlobStoreToR2Adapter = class {
23413
24196
  };
23414
24197
  var streamToBytes = async (stream) => {
23415
24198
  const reader = stream.getReader();
23416
- const chunks = [];
24199
+ const chunks2 = [];
23417
24200
  while (true) {
23418
24201
  const { value, done } = await reader.read();
23419
24202
  if (done) break;
23420
- if (value) chunks.push(value);
24203
+ if (value) chunks2.push(value);
23421
24204
  }
23422
- const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
24205
+ const total = chunks2.reduce((sum, chunk) => sum + chunk.byteLength, 0);
23423
24206
  const out = new Uint8Array(total);
23424
24207
  let offset = 0;
23425
- for (const chunk of chunks) {
24208
+ for (const chunk of chunks2) {
23426
24209
  out.set(chunk, offset);
23427
24210
  offset += chunk.byteLength;
23428
24211
  }
@@ -23532,16 +24315,16 @@ var toUint8Array = (value) => {
23532
24315
  };
23533
24316
  var streamToBytes2 = async (stream) => {
23534
24317
  const reader = stream.getReader();
23535
- const chunks = [];
24318
+ const chunks2 = [];
23536
24319
  while (true) {
23537
24320
  const { value, done } = await reader.read();
23538
24321
  if (done) break;
23539
- if (value) chunks.push(value);
24322
+ if (value) chunks2.push(value);
23540
24323
  }
23541
- const total = chunks.reduce((n, c) => n + c.byteLength, 0);
24324
+ const total = chunks2.reduce((n, c) => n + c.byteLength, 0);
23542
24325
  const out = new Uint8Array(total);
23543
24326
  let offset = 0;
23544
- for (const c of chunks) {
24327
+ for (const c of chunks2) {
23545
24328
  out.set(c, offset);
23546
24329
  offset += c.byteLength;
23547
24330
  }
@@ -24034,11 +24817,14 @@ export {
24034
24817
  dispatchIncidentWebhook,
24035
24818
  evalCasesRoutesPlugin,
24036
24819
  evaluateAllRules,
24820
+ evaluateMetricQuery,
24037
24821
  evaluateRemediationWatchdogs,
24822
+ evaluateRuleBatch,
24038
24823
  evidenceRetrievalRoutesPlugin,
24039
24824
  extractSpanLineageAndTopology,
24040
24825
  failRemediationProposal,
24041
24826
  findCrossTraceContention,
24827
+ formatAlertEmail,
24042
24828
  genAiNormalizerPlugin,
24043
24829
  getChangeEvent,
24044
24830
  getIncidentInvestigation,
@@ -24048,6 +24834,7 @@ export {
24048
24834
  identityReceiverPlugin,
24049
24835
  isEvalCaseSourceType,
24050
24836
  isEvalRunStatus,
24837
+ isValidEmailAddress,
24051
24838
  issueEnrichmentPlugin,
24052
24839
  issueInsightsPlugin,
24053
24840
  listChangeEvents,
@@ -24069,6 +24856,7 @@ export {
24069
24856
  recordChangeEvent,
24070
24857
  recordInvestigationFinding,
24071
24858
  redactionProcessorPlugin,
24859
+ reduceSeries,
24072
24860
  rejectRemediationProposal,
24073
24861
  replayQueryRoutesPlugin,
24074
24862
  replayReceiverPlugin,
@@ -24083,6 +24871,7 @@ export {
24083
24871
  usageReceiverPlugin,
24084
24872
  usersQueryRoutesPlugin,
24085
24873
  utmEnrichmentPlugin,
24874
+ validateMetricQuery,
24086
24875
  verifyPairedCounterfactualProof
24087
24876
  };
24088
24877
  //# sourceMappingURL=index.js.map