@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
|
@@ -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,
|
|
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
|
+
"version": "2.4.4",
|
|
4
4
|
"description": "Plugin-based observability collector runtime with pluggable SQL/blob storage.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"sideEffects": false,
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@bufbuild/protobuf": "^2.11.0",
|
|
40
|
-
"@obsunified/pprof-decoder": "2.0.
|
|
41
|
-
"@obsunified/types": "2.
|
|
40
|
+
"@obsunified/pprof-decoder": "2.0.3",
|
|
41
|
+
"@obsunified/types": "2.4.1"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
44
|
"hono": "^4.7.0",
|
|
@@ -129,6 +129,13 @@ export interface CollectorConfig {
|
|
|
129
129
|
* client can never make the host buffer an ingest-sized body.
|
|
130
130
|
*/
|
|
131
131
|
maxAuthBodyBytes?: number;
|
|
132
|
+
/**
|
|
133
|
+
* Acknowledge that this app runs without `auth` on `/v1/*` (e.g. a local
|
|
134
|
+
* sandbox behind a trusted proxy). It changes nothing about request
|
|
135
|
+
* handling; it only silences the one-time warning logged when the first
|
|
136
|
+
* ingest request reaches an app that has no ingest auth configured.
|
|
137
|
+
*/
|
|
138
|
+
allowUnauthenticatedIngest?: boolean;
|
|
132
139
|
}
|
|
133
140
|
|
|
134
141
|
export class CollectorRuntime implements CollectorPluginContext {
|
|
@@ -350,6 +357,21 @@ export const createTelemetryCollectorApp = (
|
|
|
350
357
|
} else if (config.auth?.ingest) {
|
|
351
358
|
// Legacy: explicit ingest-only middleware
|
|
352
359
|
app.use("/v1/*", config.auth.ingest);
|
|
360
|
+
} else if (!config.allowUnauthenticatedIngest) {
|
|
361
|
+
// No ingest auth: /v1/* accepts anonymous writes. Behaviour is kept
|
|
362
|
+
// for compatibility, but say so loudly — once, on the first ingest
|
|
363
|
+
// request, so merely importing the package (whose default export
|
|
364
|
+
// builds an auth-less app) never logs.
|
|
365
|
+
let warned = false;
|
|
366
|
+
app.use("/v1/*", async (_c, next) => {
|
|
367
|
+
if (!warned) {
|
|
368
|
+
warned = true;
|
|
369
|
+
runtime.logger.warn(
|
|
370
|
+
"[collector] WARNING: /v1/* ingest is UNAUTHENTICATED — this app was built without `auth`, so anyone who can reach it can write telemetry. Pass `auth: { middleware: createIngestAuth() }` (and set INGEST_KEY or create a key in the dashboard), or set `allowUnauthenticatedIngest: true` to acknowledge and silence this warning.",
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
await next();
|
|
374
|
+
});
|
|
353
375
|
}
|
|
354
376
|
|
|
355
377
|
// Dashboard auth (session cookie or programmatic bearer token) — owns /internal/* and /dashboard/* when configured.
|
package/src/framework/env.ts
CHANGED
|
@@ -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,
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An app built without ingest auth keeps accepting anonymous `/v1/*` writes
|
|
3
|
+
* (back-compat), but logs one loud warning on the first ingest request.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it, vi } from "vitest";
|
|
7
|
+
import type { CollectorPlugin } from "./collector";
|
|
8
|
+
import { createTelemetryCollectorApp } from "./collector";
|
|
9
|
+
import type { CollectorEnv } from "./env";
|
|
10
|
+
import type { Logger } from "./logger";
|
|
11
|
+
|
|
12
|
+
const okPlugin: CollectorPlugin = {
|
|
13
|
+
name: "ok",
|
|
14
|
+
register(app) {
|
|
15
|
+
app.post("/v1/traces", (c) => c.json({ ok: true }));
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const spyLogger = () => {
|
|
20
|
+
const warn = vi.fn<Logger["warn"]>();
|
|
21
|
+
const logger: Logger = {
|
|
22
|
+
debug: vi.fn(),
|
|
23
|
+
info: vi.fn(),
|
|
24
|
+
warn,
|
|
25
|
+
error: vi.fn(),
|
|
26
|
+
};
|
|
27
|
+
return { logger, warn };
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const ingest = (app: ReturnType<typeof createTelemetryCollectorApp>) =>
|
|
31
|
+
app.request("/v1/traces", { method: "POST", body: "{}" }, {} as CollectorEnv);
|
|
32
|
+
|
|
33
|
+
const unauthWarnings = ({ warn }: ReturnType<typeof spyLogger>) =>
|
|
34
|
+
warn.mock.calls.filter(([m]) => String(m).includes("UNAUTHENTICATED"));
|
|
35
|
+
|
|
36
|
+
describe("unauthenticated ingest warning", () => {
|
|
37
|
+
it("warns exactly once and still accepts the request", async () => {
|
|
38
|
+
const spy = spyLogger();
|
|
39
|
+
const { logger } = spy;
|
|
40
|
+
const app = createTelemetryCollectorApp({ plugins: [okPlugin], logger });
|
|
41
|
+
expect(unauthWarnings(spy)).toHaveLength(0); // not at build time
|
|
42
|
+
for (let i = 0; i < 3; i++) {
|
|
43
|
+
expect((await ingest(app)).status).toBe(200);
|
|
44
|
+
}
|
|
45
|
+
expect(unauthWarnings(spy)).toHaveLength(1);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("stays silent when ingest auth is configured", async () => {
|
|
49
|
+
const spy = spyLogger();
|
|
50
|
+
const { logger } = spy;
|
|
51
|
+
const app = createTelemetryCollectorApp({
|
|
52
|
+
plugins: [okPlugin],
|
|
53
|
+
logger,
|
|
54
|
+
auth: { middleware: async (_c, next) => next() },
|
|
55
|
+
});
|
|
56
|
+
expect((await ingest(app)).status).toBe(200);
|
|
57
|
+
expect(unauthWarnings(spy)).toHaveLength(0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("stays silent with the explicit allowUnauthenticatedIngest opt-in", async () => {
|
|
61
|
+
const spy = spyLogger();
|
|
62
|
+
const { logger } = spy;
|
|
63
|
+
const app = createTelemetryCollectorApp({
|
|
64
|
+
plugins: [okPlugin],
|
|
65
|
+
logger,
|
|
66
|
+
allowUnauthenticatedIngest: true,
|
|
67
|
+
});
|
|
68
|
+
expect((await ingest(app)).status).toBe(200);
|
|
69
|
+
expect(unauthWarnings(spy)).toHaveLength(0);
|
|
70
|
+
});
|
|
71
|
+
});
|
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,
|
|
@@ -272,6 +286,7 @@ export const createDefaultCollectorApp = (config?: Partial<CollectorConfig>) =>
|
|
|
272
286
|
logger: config?.logger,
|
|
273
287
|
withChildSpan: config?.withChildSpan,
|
|
274
288
|
sqlDb: config?.sqlDb,
|
|
289
|
+
allowUnauthenticatedIngest: config?.allowUnauthenticatedIngest,
|
|
275
290
|
});
|
|
276
291
|
|
|
277
292
|
export default createTelemetryCollectorApp(allPlugins);
|
|
@@ -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
|
|
|
@@ -66,6 +66,37 @@ describe("AlertsStore.deleteRule", () => {
|
|
|
66
66
|
});
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
+
describe("AlertsStore.listEvaluations project scoping", () => {
|
|
70
|
+
it("does not return another project's evaluation history for a known rule id", async () => {
|
|
71
|
+
const db = createMigratedSqliteDb();
|
|
72
|
+
const projects = new ProjectsStore(db);
|
|
73
|
+
const alphaProject = await projects.createProject({ name: "A", slug: "a" });
|
|
74
|
+
const betaProject = await projects.createProject({ name: "B", slug: "b" });
|
|
75
|
+
const store = new AlertsStore(db);
|
|
76
|
+
const beta = await store.createRule(
|
|
77
|
+
betaProject.id,
|
|
78
|
+
input({ name: "beta" }),
|
|
79
|
+
);
|
|
80
|
+
await store.recordEvaluation(beta.id, beta.projectId, 5, "firing", true);
|
|
81
|
+
|
|
82
|
+
const own = await store.listEvaluations({
|
|
83
|
+
ruleId: beta.id,
|
|
84
|
+
projectId: betaProject.id,
|
|
85
|
+
hours: 24,
|
|
86
|
+
});
|
|
87
|
+
expect(own.evaluations).toHaveLength(1);
|
|
88
|
+
|
|
89
|
+
const foreign = await store.listEvaluations({
|
|
90
|
+
ruleId: beta.id,
|
|
91
|
+
projectId: alphaProject.id,
|
|
92
|
+
hours: 24,
|
|
93
|
+
});
|
|
94
|
+
expect(foreign.evaluations).toEqual([]);
|
|
95
|
+
expect(foreign.evidenceReferences).toBeUndefined();
|
|
96
|
+
db.close();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
69
100
|
describe("AlertsStore.validateInput webhook targets", () => {
|
|
70
101
|
const store = new AlertsStore(createMigratedSqliteDb());
|
|
71
102
|
|
|
@@ -151,3 +182,117 @@ describe("AlertsStore alert_state.notified (migration 049)", () => {
|
|
|
151
182
|
});
|
|
152
183
|
});
|
|
153
184
|
});
|
|
185
|
+
|
|
186
|
+
describe("AlertsStore.listRules evaluation status", () => {
|
|
187
|
+
it("reports the last evaluation, its error and whether the state was delivered", async () => {
|
|
188
|
+
vi.useFakeTimers({ toFake: ["Date"] });
|
|
189
|
+
try {
|
|
190
|
+
const db = createMigratedSqliteDb();
|
|
191
|
+
const project = await new ProjectsStore(db).createProject({
|
|
192
|
+
name: "S",
|
|
193
|
+
slug: "s",
|
|
194
|
+
});
|
|
195
|
+
const store = new AlertsStore(db);
|
|
196
|
+
const erroring = await store.createRule(
|
|
197
|
+
project.id,
|
|
198
|
+
input({ name: "erroring" }),
|
|
199
|
+
);
|
|
200
|
+
const undelivered = await store.createRule(
|
|
201
|
+
project.id,
|
|
202
|
+
input({ name: "undelivered" }),
|
|
203
|
+
);
|
|
204
|
+
const fresh = await store.createRule(
|
|
205
|
+
project.id,
|
|
206
|
+
input({ name: "fresh" }),
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
vi.setSystemTime(Date.parse("2026-10-31T10:00:00.000Z"));
|
|
210
|
+
await store.recordEvaluation(erroring.id, project.id, 3, "ok", false);
|
|
211
|
+
vi.setSystemTime(Date.parse("2026-10-31T10:05:00.000Z"));
|
|
212
|
+
await store.recordEvaluation(
|
|
213
|
+
erroring.id,
|
|
214
|
+
project.id,
|
|
215
|
+
0,
|
|
216
|
+
"ok",
|
|
217
|
+
false,
|
|
218
|
+
"error",
|
|
219
|
+
"connect ECONNREFUSED postgres://obs:hunter2@db:5432/obs\nat pool",
|
|
220
|
+
);
|
|
221
|
+
await store.transitionState(
|
|
222
|
+
undelivered.id,
|
|
223
|
+
project.id,
|
|
224
|
+
"firing",
|
|
225
|
+
"2026-10-31T10:05:00.000Z",
|
|
226
|
+
);
|
|
227
|
+
await store.recordEvaluation(
|
|
228
|
+
undelivered.id,
|
|
229
|
+
project.id,
|
|
230
|
+
7,
|
|
231
|
+
"firing",
|
|
232
|
+
false,
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
const byName = new Map(
|
|
236
|
+
(await store.listRules(project.id)).map((r) => [r.name, r]),
|
|
237
|
+
);
|
|
238
|
+
expect(byName.get("erroring")).toMatchObject({
|
|
239
|
+
currentState: undefined,
|
|
240
|
+
notificationDelivered: null,
|
|
241
|
+
lastEvaluation: {
|
|
242
|
+
evaluatedAt: "2026-10-31T10:05:00.000Z",
|
|
243
|
+
outcome: "error",
|
|
244
|
+
value: 0,
|
|
245
|
+
error: "connect ECONNREFUSED <url> at pool",
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
expect(JSON.stringify(byName.get("erroring"))).not.toContain("hunter2");
|
|
249
|
+
expect(byName.get("undelivered")).toMatchObject({
|
|
250
|
+
currentState: "firing",
|
|
251
|
+
notificationDelivered: false,
|
|
252
|
+
lastEvaluation: { outcome: "value", value: 7, error: null },
|
|
253
|
+
});
|
|
254
|
+
expect(byName.get("fresh")).toMatchObject({
|
|
255
|
+
notificationDelivered: null,
|
|
256
|
+
lastEvaluation: null,
|
|
257
|
+
});
|
|
258
|
+
// Existing fields are unchanged.
|
|
259
|
+
expect(byName.get("fresh")).toMatchObject({
|
|
260
|
+
id: fresh.id,
|
|
261
|
+
signal: "spans",
|
|
262
|
+
enabled: true,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
await store.markNotified(
|
|
266
|
+
undelivered.id,
|
|
267
|
+
project.id,
|
|
268
|
+
"firing",
|
|
269
|
+
"2026-10-31T10:05:00.000Z",
|
|
270
|
+
);
|
|
271
|
+
expect(
|
|
272
|
+
(await store.listRules(project.id)).find(
|
|
273
|
+
(r) => r.name === "undelivered",
|
|
274
|
+
)?.notificationDelivered,
|
|
275
|
+
).toBe(true);
|
|
276
|
+
|
|
277
|
+
const evaluations = await store.listEvaluations({
|
|
278
|
+
ruleId: erroring.id,
|
|
279
|
+
projectId: project.id,
|
|
280
|
+
hours: 24 * 365,
|
|
281
|
+
});
|
|
282
|
+
expect(evaluations.evaluations[0]).toMatchObject({
|
|
283
|
+
outcome: "error",
|
|
284
|
+
error: "connect ECONNREFUSED <url> at pool",
|
|
285
|
+
});
|
|
286
|
+
db.close();
|
|
287
|
+
} finally {
|
|
288
|
+
vi.useRealTimers();
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("truncates long error messages", () => {
|
|
293
|
+
expect(sanitizeEvaluationError("x".repeat(800))).toHaveLength(500);
|
|
294
|
+
expect(sanitizeEvaluationError("see https://hooks.slack.com/T/B/s")).toBe(
|
|
295
|
+
"see <url>",
|
|
296
|
+
);
|
|
297
|
+
});
|
|
298
|
+
});
|