@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.
- package/dist/auth.d.ts +2 -1
- package/dist/index.d.ts +264 -62
- package/dist/index.js +850 -61
- package/dist/index.js.map +1 -1
- package/dist/{ingest-auth-BmP5-f96.d.ts → ingest-auth-BTowieER.d.ts} +66 -2
- package/package.json +2 -2
- package/src/framework/env.ts +9 -1
- package/src/index.ts +14 -0
- package/src/lib/alert-email.ts +104 -0
- package/src/lib/alerts-store.test.ts +116 -2
- package/src/lib/alerts-store.ts +421 -57
- package/src/lib/metric-alerts.test.ts +769 -0
- package/src/lib/metric-alerts.ts +640 -0
- package/src/lib/metrics-store.ts +9 -2
- package/src/lib/sql-db-postgres.integration.test.ts +247 -0
- package/src/lib/store/trace-queries.ts +1 -1
- package/src/migrations/054_alert_rules_pending_secs.sql +11 -0
- package/src/migrations/055_alert_state_pending_since.sql +12 -0
- package/src/migrations/056_metric_point_series_received.sql +9 -0
- package/src/migrations/057_alert_evaluations_outcome.sql +11 -0
- package/src/migrations/058_alert_state_pending_seen_at.sql +12 -0
- package/src/migrations/059_alert_evaluations_error.sql +11 -0
- package/src/migrations-postgres/054_alert_rules_pending_secs.sql +7 -0
- package/src/migrations-postgres/055_alert_state_pending_since.sql +6 -0
- package/src/migrations-postgres/056_metric_point_series_received.sql +7 -0
- package/src/migrations-postgres/057_alert_evaluations_outcome.sql +6 -0
- package/src/migrations-postgres/058_alert_state_pending_seen_at.sql +6 -0
- package/src/migrations-postgres/059_alert_evaluations_error.sql +6 -0
- package/src/migrations-postgres/README.md +13 -0
- package/src/plugins/alerts-evaluator-pending.test.ts +322 -0
- package/src/plugins/alerts-evaluator-retention.test.ts +2 -0
- package/src/plugins/alerts-evaluator.test.ts +10 -2
- package/src/plugins/alerts-evaluator.ts +239 -40
- 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(
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
12977
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13015
|
-
|
|
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
|
-
|
|
13031
|
-
|
|
13032
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
13356
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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:
|
|
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, {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
24199
|
+
const chunks2 = [];
|
|
23417
24200
|
while (true) {
|
|
23418
24201
|
const { value, done } = await reader.read();
|
|
23419
24202
|
if (done) break;
|
|
23420
|
-
if (value)
|
|
24203
|
+
if (value) chunks2.push(value);
|
|
23421
24204
|
}
|
|
23422
|
-
const total =
|
|
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
|
|
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
|
|
24318
|
+
const chunks2 = [];
|
|
23536
24319
|
while (true) {
|
|
23537
24320
|
const { value, done } = await reader.read();
|
|
23538
24321
|
if (done) break;
|
|
23539
|
-
if (value)
|
|
24322
|
+
if (value) chunks2.push(value);
|
|
23540
24323
|
}
|
|
23541
|
-
const total =
|
|
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
|
|
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
|