@obsunified/collector 2.2.3 → 2.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth.d.ts +2 -1
- package/dist/{chunk-ZSF5XP3B.js → chunk-RASSTGNB.js} +30 -22
- package/dist/chunk-RASSTGNB.js.map +1 -0
- package/dist/index.d.ts +264 -62
- package/dist/index.js +3390 -2350
- package/dist/index.js.map +1 -1
- package/dist/{ingest-auth-C5-8Mly5.d.ts → ingest-auth-BTowieER.d.ts} +96 -1
- package/dist/{openai-GSJZGFGL.js → openai-VDKVVZAV.js} +2 -2
- package/package.json +4 -2
- package/src/__tests__/autosre-closed-loop.test.ts +110 -0
- package/src/__tests__/db-parity.integration.test.ts +961 -0
- package/src/__tests__/internal-contracts-routes.test.ts +451 -0
- package/src/framework/env.ts +113 -0
- package/src/index.ts +27 -4
- package/src/lib/ai-store/evaluations.test.ts +3 -3
- 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/analyses-store.test.ts +6 -6
- package/src/lib/db-parity.ts +883 -0
- package/src/lib/eval-cases-store.test.ts +1 -1
- package/src/lib/eval-cases-store.ts +16 -4
- package/src/lib/evidence-references.ts +30 -22
- 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/stage6.test.ts +1 -1
- 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/parity.test.ts +105 -3
- 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/ai-receiver.ts +6 -1
- 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 +251 -140
- package/src/plugins/connected-routes/manifest.test.ts +114 -32
- package/src/plugins/connected-routes/manifest.ts +59 -76
- package/src/plugins/connected-routes.test.ts +10 -8
- package/src/plugins/connected-routes.ts +42 -12
- package/src/plugins/eval-cases-routes.ts +33 -10
- package/src/plugins/evidence-retrieval-routes.ts +60 -25
- package/dist/chunk-ZSF5XP3B.js.map +0 -1
- /package/dist/{openai-GSJZGFGL.js.map → openai-VDKVVZAV.js.map} +0 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email alert channel: the runtime-neutral half.
|
|
3
|
+
*
|
|
4
|
+
* The collector package runs on Cloudflare Workers as well as Node, and a
|
|
5
|
+
* Worker cannot open an SMTP connection. So the package only defines what an
|
|
6
|
+
* email transport must do (`AlertEmailTransport`) and how an alert reads as
|
|
7
|
+
* mail (`formatAlertEmail`); a host that can speak SMTP binds a transport as
|
|
8
|
+
* `env.ALERT_EMAIL` (the Node collector does, from `ALERT_SMTP_*`, see
|
|
9
|
+
* apps/collector-node/src/alert-email.ts). Without that binding, rules with
|
|
10
|
+
* an email channel are refused at create/update time and an email channel on
|
|
11
|
+
* an existing rule counts as a failed delivery at fire time.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { AlertState } from "@obsunified/types";
|
|
15
|
+
|
|
16
|
+
export interface AlertEmailMessage {
|
|
17
|
+
to: string[];
|
|
18
|
+
subject: string;
|
|
19
|
+
text: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface AlertEmailTransport {
|
|
23
|
+
/**
|
|
24
|
+
* Sends one message. Resolves only when the SMTP server accepted it for
|
|
25
|
+
* every recipient; rejects otherwise.
|
|
26
|
+
*/
|
|
27
|
+
send(message: AlertEmailMessage): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** What every channel is told about a transition (the webhook JSON body). */
|
|
31
|
+
export interface AlertNotificationPayload {
|
|
32
|
+
rule: {
|
|
33
|
+
id: string;
|
|
34
|
+
name: string;
|
|
35
|
+
signal: string;
|
|
36
|
+
};
|
|
37
|
+
value: number;
|
|
38
|
+
threshold: number;
|
|
39
|
+
comparison: string;
|
|
40
|
+
state: AlertState;
|
|
41
|
+
evaluatedAt: string;
|
|
42
|
+
projectId: string;
|
|
43
|
+
/**
|
|
44
|
+
* RFC 0002 Stage 6 — populated when the rule is bound to an Analysis
|
|
45
|
+
* (`rule.analysisId` set). The narrative is the gated, LLM-generated
|
|
46
|
+
* sentence the dashboard panel was last showing — what's actually
|
|
47
|
+
* happening, in plain English. Webhooks consuming this can put the
|
|
48
|
+
* narrative in the Slack/PagerDuty body directly.
|
|
49
|
+
*/
|
|
50
|
+
analysis?: {
|
|
51
|
+
id: string;
|
|
52
|
+
narrative: string | null;
|
|
53
|
+
status: string | null;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const MAX_EMAIL_RECIPIENTS = 20;
|
|
58
|
+
|
|
59
|
+
// Deliberately strict: one plain address, no display name, no whitespace,
|
|
60
|
+
// commas, angle brackets or quotes (nothing that could add a header or a
|
|
61
|
+
// second recipient).
|
|
62
|
+
const EMAIL_RE =
|
|
63
|
+
/^[^\s@<>,;:"()[\]\\]+@[^\s@<>,;:"()[\]\\]+\.[^\s@<>,;:"()[\]\\]+$/;
|
|
64
|
+
|
|
65
|
+
export const isValidEmailAddress = (addr: string): boolean =>
|
|
66
|
+
addr.length <= 254 && EMAIL_RE.test(addr);
|
|
67
|
+
|
|
68
|
+
const oneLine = (s: string): string => s.replace(/[\r\n]+/g, " ").trim();
|
|
69
|
+
|
|
70
|
+
export function formatAlertEmail(payload: AlertNotificationPayload): {
|
|
71
|
+
subject: string;
|
|
72
|
+
text: string;
|
|
73
|
+
} {
|
|
74
|
+
const label = payload.state === "firing" ? "FIRING" : "RESOLVED";
|
|
75
|
+
const name = oneLine(payload.rule.name);
|
|
76
|
+
const lines = [
|
|
77
|
+
payload.state === "firing"
|
|
78
|
+
? `Alert rule "${name}" is firing.`
|
|
79
|
+
: `Alert rule "${name}" has resolved.`,
|
|
80
|
+
"",
|
|
81
|
+
`Rule: ${name} (${payload.rule.id})`,
|
|
82
|
+
`Signal: ${payload.rule.signal}`,
|
|
83
|
+
`State: ${payload.state}`,
|
|
84
|
+
`Value: ${payload.value}`,
|
|
85
|
+
`Condition: value ${payload.comparison} ${payload.threshold}`,
|
|
86
|
+
`Threshold: ${payload.threshold}`,
|
|
87
|
+
`Comparison: ${payload.comparison}`,
|
|
88
|
+
`Evaluated: ${payload.evaluatedAt}`,
|
|
89
|
+
`Project: ${payload.projectId}`,
|
|
90
|
+
];
|
|
91
|
+
if (payload.analysis) {
|
|
92
|
+
lines.push(
|
|
93
|
+
`Analysis: ${payload.analysis.id}${payload.analysis.status ? ` (${payload.analysis.status})` : ""}`,
|
|
94
|
+
);
|
|
95
|
+
if (payload.analysis.narrative) {
|
|
96
|
+
lines.push("", payload.analysis.narrative);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
lines.push("", "Sent by obs-unified.");
|
|
100
|
+
return {
|
|
101
|
+
subject: `[obs-unified] ${label}: ${name}`,
|
|
102
|
+
text: `${lines.join("\n")}\n`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { AlertRuleInput } from "@obsunified/types";
|
|
7
|
-
import { describe, expect, it } from "vitest";
|
|
8
|
-
import { AlertsStore } from "./alerts-store";
|
|
7
|
+
import { describe, expect, it, vi } from "vitest";
|
|
8
|
+
import { AlertsStore, sanitizeEvaluationError } from "./alerts-store";
|
|
9
9
|
import { ProjectsStore } from "./projects-store";
|
|
10
10
|
import { createMigratedSqliteDb } from "./test-utils/node-sqlite-db";
|
|
11
11
|
|
|
@@ -151,3 +151,117 @@ describe("AlertsStore alert_state.notified (migration 049)", () => {
|
|
|
151
151
|
});
|
|
152
152
|
});
|
|
153
153
|
});
|
|
154
|
+
|
|
155
|
+
describe("AlertsStore.listRules evaluation status", () => {
|
|
156
|
+
it("reports the last evaluation, its error and whether the state was delivered", async () => {
|
|
157
|
+
vi.useFakeTimers({ toFake: ["Date"] });
|
|
158
|
+
try {
|
|
159
|
+
const db = createMigratedSqliteDb();
|
|
160
|
+
const project = await new ProjectsStore(db).createProject({
|
|
161
|
+
name: "S",
|
|
162
|
+
slug: "s",
|
|
163
|
+
});
|
|
164
|
+
const store = new AlertsStore(db);
|
|
165
|
+
const erroring = await store.createRule(
|
|
166
|
+
project.id,
|
|
167
|
+
input({ name: "erroring" }),
|
|
168
|
+
);
|
|
169
|
+
const undelivered = await store.createRule(
|
|
170
|
+
project.id,
|
|
171
|
+
input({ name: "undelivered" }),
|
|
172
|
+
);
|
|
173
|
+
const fresh = await store.createRule(
|
|
174
|
+
project.id,
|
|
175
|
+
input({ name: "fresh" }),
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
vi.setSystemTime(Date.parse("2026-10-31T10:00:00.000Z"));
|
|
179
|
+
await store.recordEvaluation(erroring.id, project.id, 3, "ok", false);
|
|
180
|
+
vi.setSystemTime(Date.parse("2026-10-31T10:05:00.000Z"));
|
|
181
|
+
await store.recordEvaluation(
|
|
182
|
+
erroring.id,
|
|
183
|
+
project.id,
|
|
184
|
+
0,
|
|
185
|
+
"ok",
|
|
186
|
+
false,
|
|
187
|
+
"error",
|
|
188
|
+
"connect ECONNREFUSED postgres://obs:hunter2@db:5432/obs\nat pool",
|
|
189
|
+
);
|
|
190
|
+
await store.transitionState(
|
|
191
|
+
undelivered.id,
|
|
192
|
+
project.id,
|
|
193
|
+
"firing",
|
|
194
|
+
"2026-10-31T10:05:00.000Z",
|
|
195
|
+
);
|
|
196
|
+
await store.recordEvaluation(
|
|
197
|
+
undelivered.id,
|
|
198
|
+
project.id,
|
|
199
|
+
7,
|
|
200
|
+
"firing",
|
|
201
|
+
false,
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const byName = new Map(
|
|
205
|
+
(await store.listRules(project.id)).map((r) => [r.name, r]),
|
|
206
|
+
);
|
|
207
|
+
expect(byName.get("erroring")).toMatchObject({
|
|
208
|
+
currentState: undefined,
|
|
209
|
+
notificationDelivered: null,
|
|
210
|
+
lastEvaluation: {
|
|
211
|
+
evaluatedAt: "2026-10-31T10:05:00.000Z",
|
|
212
|
+
outcome: "error",
|
|
213
|
+
value: 0,
|
|
214
|
+
error: "connect ECONNREFUSED <url> at pool",
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
expect(JSON.stringify(byName.get("erroring"))).not.toContain("hunter2");
|
|
218
|
+
expect(byName.get("undelivered")).toMatchObject({
|
|
219
|
+
currentState: "firing",
|
|
220
|
+
notificationDelivered: false,
|
|
221
|
+
lastEvaluation: { outcome: "value", value: 7, error: null },
|
|
222
|
+
});
|
|
223
|
+
expect(byName.get("fresh")).toMatchObject({
|
|
224
|
+
notificationDelivered: null,
|
|
225
|
+
lastEvaluation: null,
|
|
226
|
+
});
|
|
227
|
+
// Existing fields are unchanged.
|
|
228
|
+
expect(byName.get("fresh")).toMatchObject({
|
|
229
|
+
id: fresh.id,
|
|
230
|
+
signal: "spans",
|
|
231
|
+
enabled: true,
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
await store.markNotified(
|
|
235
|
+
undelivered.id,
|
|
236
|
+
project.id,
|
|
237
|
+
"firing",
|
|
238
|
+
"2026-10-31T10:05:00.000Z",
|
|
239
|
+
);
|
|
240
|
+
expect(
|
|
241
|
+
(await store.listRules(project.id)).find(
|
|
242
|
+
(r) => r.name === "undelivered",
|
|
243
|
+
)?.notificationDelivered,
|
|
244
|
+
).toBe(true);
|
|
245
|
+
|
|
246
|
+
const evaluations = await store.listEvaluations({
|
|
247
|
+
ruleId: erroring.id,
|
|
248
|
+
projectId: project.id,
|
|
249
|
+
hours: 24 * 365,
|
|
250
|
+
});
|
|
251
|
+
expect(evaluations.evaluations[0]).toMatchObject({
|
|
252
|
+
outcome: "error",
|
|
253
|
+
error: "connect ECONNREFUSED <url> at pool",
|
|
254
|
+
});
|
|
255
|
+
db.close();
|
|
256
|
+
} finally {
|
|
257
|
+
vi.useRealTimers();
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("truncates long error messages", () => {
|
|
262
|
+
expect(sanitizeEvaluationError("x".repeat(800))).toHaveLength(500);
|
|
263
|
+
expect(sanitizeEvaluationError("see https://hooks.slack.com/T/B/s")).toBe(
|
|
264
|
+
"see <url>",
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
});
|