@obsunified/collector 2.3.0 → 2.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/auth.d.ts +2 -1
  2. package/dist/index.d.ts +264 -62
  3. package/dist/index.js +850 -61
  4. package/dist/index.js.map +1 -1
  5. package/dist/{ingest-auth-BmP5-f96.d.ts → ingest-auth-BTowieER.d.ts} +66 -2
  6. package/package.json +2 -2
  7. package/src/framework/env.ts +9 -1
  8. package/src/index.ts +14 -0
  9. package/src/lib/alert-email.ts +104 -0
  10. package/src/lib/alerts-store.test.ts +116 -2
  11. package/src/lib/alerts-store.ts +421 -57
  12. package/src/lib/metric-alerts.test.ts +769 -0
  13. package/src/lib/metric-alerts.ts +640 -0
  14. package/src/lib/metrics-store.ts +9 -2
  15. package/src/lib/sql-db-postgres.integration.test.ts +247 -0
  16. package/src/lib/store/trace-queries.ts +1 -1
  17. package/src/migrations/054_alert_rules_pending_secs.sql +11 -0
  18. package/src/migrations/055_alert_state_pending_since.sql +12 -0
  19. package/src/migrations/056_metric_point_series_received.sql +9 -0
  20. package/src/migrations/057_alert_evaluations_outcome.sql +11 -0
  21. package/src/migrations/058_alert_state_pending_seen_at.sql +12 -0
  22. package/src/migrations/059_alert_evaluations_error.sql +11 -0
  23. package/src/migrations-postgres/054_alert_rules_pending_secs.sql +7 -0
  24. package/src/migrations-postgres/055_alert_state_pending_since.sql +6 -0
  25. package/src/migrations-postgres/056_metric_point_series_received.sql +7 -0
  26. package/src/migrations-postgres/057_alert_evaluations_outcome.sql +6 -0
  27. package/src/migrations-postgres/058_alert_state_pending_seen_at.sql +6 -0
  28. package/src/migrations-postgres/059_alert_evaluations_error.sql +6 -0
  29. package/src/migrations-postgres/README.md +13 -0
  30. package/src/plugins/alerts-evaluator-pending.test.ts +322 -0
  31. package/src/plugins/alerts-evaluator-retention.test.ts +2 -0
  32. package/src/plugins/alerts-evaluator.test.ts +10 -2
  33. package/src/plugins/alerts-evaluator.ts +239 -40
  34. package/src/plugins/alerts-routes.ts +17 -2
@@ -1,4 +1,62 @@
1
1
  import { Hono, Context, MiddlewareHandler } from 'hono';
2
+ import { AlertState } from '@obsunified/types';
3
+
4
+ /**
5
+ * Email alert channel: the runtime-neutral half.
6
+ *
7
+ * The collector package runs on Cloudflare Workers as well as Node, and a
8
+ * Worker cannot open an SMTP connection. So the package only defines what an
9
+ * email transport must do (`AlertEmailTransport`) and how an alert reads as
10
+ * mail (`formatAlertEmail`); a host that can speak SMTP binds a transport as
11
+ * `env.ALERT_EMAIL` (the Node collector does, from `ALERT_SMTP_*`, see
12
+ * apps/collector-node/src/alert-email.ts). Without that binding, rules with
13
+ * an email channel are refused at create/update time and an email channel on
14
+ * an existing rule counts as a failed delivery at fire time.
15
+ */
16
+
17
+ interface AlertEmailMessage {
18
+ to: string[];
19
+ subject: string;
20
+ text: string;
21
+ }
22
+ 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
+ /** What every channel is told about a transition (the webhook JSON body). */
30
+ interface AlertNotificationPayload {
31
+ rule: {
32
+ id: string;
33
+ name: string;
34
+ signal: string;
35
+ };
36
+ value: number;
37
+ threshold: number;
38
+ comparison: string;
39
+ state: AlertState;
40
+ evaluatedAt: string;
41
+ projectId: string;
42
+ /**
43
+ * RFC 0002 Stage 6 — populated when the rule is bound to an Analysis
44
+ * (`rule.analysisId` set). The narrative is the gated, LLM-generated
45
+ * sentence the dashboard panel was last showing — what's actually
46
+ * happening, in plain English. Webhooks consuming this can put the
47
+ * narrative in the Slack/PagerDuty body directly.
48
+ */
49
+ analysis?: {
50
+ id: string;
51
+ narrative: string | null;
52
+ status: string | null;
53
+ };
54
+ }
55
+ declare const isValidEmailAddress: (addr: string) => boolean;
56
+ declare function formatAlertEmail(payload: AlertNotificationPayload): {
57
+ subject: string;
58
+ text: string;
59
+ };
2
60
 
3
61
  /**
4
62
  * Pluggable logger interface for the collector framework.
@@ -69,6 +127,12 @@ interface CollectorEnv {
69
127
  /** Durable Object namespace for live-tail SSE pub/sub. Optional — when
70
128
  * unbound the /internal/telemetry/tail endpoint returns 503. */
71
129
  TAIL_HUB?: DurableObjectNamespace;
130
+ /**
131
+ * SMTP transport for email alert channels. Bound by hosts that can send
132
+ * mail (the Node collector, from `ALERT_SMTP_*`); never set on Cloudflare
133
+ * Workers. Without it, rules with an email channel are refused.
134
+ */
135
+ ALERT_EMAIL?: AlertEmailTransport;
72
136
  /** Write-only API key for SDK ingest endpoints (/v1/*) */
73
137
  INGEST_KEY?: string;
74
138
  /** Password for dashboard login */
@@ -155,7 +219,7 @@ declare const COLLECTOR_STRING_ENV_MANIFEST: {
155
219
  type CollectorOptionalStringEnvKey = (typeof COLLECTOR_OPTIONAL_STRING_ENV_KEYS)[number];
156
220
  type CollectorHostManagedEnvKey = (typeof COLLECTOR_HOST_MANAGED_ENV_KEYS)[number];
157
221
  type CollectorManifestStringEnvKey = CollectorOptionalStringEnvKey | CollectorHostManagedEnvKey;
158
- type CollectorObjectBindingKey = "DB" | "REPLAYS_BUCKET" | "PROFILES_BUCKET" | "TAIL_HUB";
222
+ type CollectorObjectBindingKey = "DB" | "REPLAYS_BUCKET" | "PROFILES_BUCKET" | "TAIL_HUB" | "ALERT_EMAIL";
159
223
  type CollectorStringEnvKey = Exclude<keyof CollectorEnv, CollectorObjectBindingKey>;
160
224
  /**
161
225
  * Verifies that every string key in `declaredKeys` is categorized in either
@@ -202,4 +266,4 @@ declare function createIngestAuth(config?: {
202
266
  };
203
267
  }>;
204
268
 
205
- export { COLLECTOR_HOST_MANAGED_ENV_KEYS as C, type Logger as L, COLLECTOR_OPTIONAL_STRING_ENV_KEYS as a, COLLECTOR_STRING_ENV_MANIFEST as b, type ChildSpanRunner as c, type CollectorApp as d, type CollectorEnv as e, type CollectorHostManagedEnvKey as f, type CollectorManifestStringEnvKey as g, type CollectorObjectBindingKey as h, type CollectorOptionalStringEnvKey as i, type CollectorRouteContext as j, type CollectorStringEnvKey as k, assertCollectorEnvManifestCompleteness as l, consoleLogger as m, createIngestAuth as n, resetIngestAuthCache as r };
269
+ export { type AlertEmailMessage as A, COLLECTOR_HOST_MANAGED_ENV_KEYS as C, type Logger as L, type AlertEmailTransport as a, type AlertNotificationPayload as b, COLLECTOR_OPTIONAL_STRING_ENV_KEYS as c, COLLECTOR_STRING_ENV_MANIFEST as d, type ChildSpanRunner as e, type CollectorApp as f, type CollectorEnv as g, type CollectorHostManagedEnvKey as h, type CollectorManifestStringEnvKey as i, type CollectorObjectBindingKey as j, type CollectorOptionalStringEnvKey as k, type CollectorRouteContext as l, type CollectorStringEnvKey as m, assertCollectorEnvManifestCompleteness as n, consoleLogger as o, createIngestAuth as p, formatAlertEmail as q, isValidEmailAddress as r, resetIngestAuthCache as s };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@obsunified/collector",
3
- "version": "2.3.0",
3
+ "version": "2.4.3",
4
4
  "description": "Plugin-based observability collector runtime with pluggable SQL/blob storage.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -38,7 +38,7 @@
38
38
  "dependencies": {
39
39
  "@bufbuild/protobuf": "^2.11.0",
40
40
  "@obsunified/pprof-decoder": "2.0.2",
41
- "@obsunified/types": "2.3.0"
41
+ "@obsunified/types": "2.4.1"
42
42
  },
43
43
  "peerDependencies": {
44
44
  "hono": "^4.7.0",
@@ -1,4 +1,5 @@
1
1
  import type { Context, Hono } from "hono";
2
+ import type { AlertEmailTransport } from "../lib/alert-email";
2
3
  import type { Logger } from "./logger";
3
4
 
4
5
  /**
@@ -22,6 +23,12 @@ export interface CollectorEnv {
22
23
  /** Durable Object namespace for live-tail SSE pub/sub. Optional — when
23
24
  * unbound the /internal/telemetry/tail endpoint returns 503. */
24
25
  TAIL_HUB?: DurableObjectNamespace;
26
+ /**
27
+ * SMTP transport for email alert channels. Bound by hosts that can send
28
+ * mail (the Node collector, from `ALERT_SMTP_*`); never set on Cloudflare
29
+ * Workers. Without it, rules with an email channel are refused.
30
+ */
31
+ ALERT_EMAIL?: AlertEmailTransport;
25
32
  /** Write-only API key for SDK ingest endpoints (/v1/*) */
26
33
  INGEST_KEY?: string;
27
34
  /** Password for dashboard login */
@@ -146,7 +153,8 @@ export type CollectorObjectBindingKey =
146
153
  | "DB"
147
154
  | "REPLAYS_BUCKET"
148
155
  | "PROFILES_BUCKET"
149
- | "TAIL_HUB";
156
+ | "TAIL_HUB"
157
+ | "ALERT_EMAIL";
150
158
 
151
159
  export type CollectorStringEnvKey = Exclude<
152
160
  keyof CollectorEnv,
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ import { aiSpanPayloadsProcessorPlugin } from "./plugins/ai-span-payloads-proces
9
9
  import {
10
10
  createAlertEvaluatorHandler,
11
11
  evaluateAllRules,
12
+ evaluateRuleBatch,
12
13
  } from "./plugins/alerts-evaluator";
13
14
  import { alertsRoutesPlugin } from "./plugins/alerts-routes";
14
15
  import { analysesRoutesPlugin } from "./plugins/analyses-routes";
@@ -100,6 +101,13 @@ export {
100
101
  type RetrievalEventRecord,
101
102
  type ToolCallRecord,
102
103
  } from "./lib/actions-store";
104
+ export {
105
+ type AlertEmailMessage,
106
+ type AlertEmailTransport,
107
+ type AlertNotificationPayload,
108
+ formatAlertEmail,
109
+ isValidEmailAddress,
110
+ } from "./lib/alert-email";
103
111
  export { AlertsStore, compareValue } from "./lib/alerts-store";
104
112
  export {
105
113
  computeChangeBlastRadius,
@@ -168,6 +176,11 @@ export {
168
176
  isEvalCaseSourceType,
169
177
  isEvalRunStatus,
170
178
  } from "./lib/eval-cases-store";
179
+ export {
180
+ evaluateMetricQuery,
181
+ reduceSeries,
182
+ validateMetricQuery,
183
+ } from "./lib/metric-alerts";
171
184
  export { MetricsStore } from "./lib/metrics-store";
172
185
  export { ProjectsStore } from "./lib/projects-store";
173
186
  export { D1Adapter, type SqlDb, type SqlStatement } from "./lib/sql-db";
@@ -192,6 +205,7 @@ export {
192
205
  defaultSpanEnrichmentPlugin,
193
206
  evalCasesRoutesPlugin,
194
207
  evaluateAllRules,
208
+ evaluateRuleBatch,
195
209
  evidenceRetrievalRoutesPlugin,
196
210
  genAiNormalizerPlugin,
197
211
  identityReceiverPlugin,
@@ -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
+ });