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