@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.
Files changed (58) hide show
  1. package/dist/auth.d.ts +2 -1
  2. package/dist/{chunk-ZSF5XP3B.js → chunk-RASSTGNB.js} +30 -22
  3. package/dist/chunk-RASSTGNB.js.map +1 -0
  4. package/dist/index.d.ts +264 -62
  5. package/dist/index.js +3390 -2350
  6. package/dist/index.js.map +1 -1
  7. package/dist/{ingest-auth-C5-8Mly5.d.ts → ingest-auth-BTowieER.d.ts} +96 -1
  8. package/dist/{openai-GSJZGFGL.js → openai-VDKVVZAV.js} +2 -2
  9. package/package.json +4 -2
  10. package/src/__tests__/autosre-closed-loop.test.ts +110 -0
  11. package/src/__tests__/db-parity.integration.test.ts +961 -0
  12. package/src/__tests__/internal-contracts-routes.test.ts +451 -0
  13. package/src/framework/env.ts +113 -0
  14. package/src/index.ts +27 -4
  15. package/src/lib/ai-store/evaluations.test.ts +3 -3
  16. package/src/lib/alert-email.ts +104 -0
  17. package/src/lib/alerts-store.test.ts +116 -2
  18. package/src/lib/alerts-store.ts +421 -57
  19. package/src/lib/analyses-store.test.ts +6 -6
  20. package/src/lib/db-parity.ts +883 -0
  21. package/src/lib/eval-cases-store.test.ts +1 -1
  22. package/src/lib/eval-cases-store.ts +16 -4
  23. package/src/lib/evidence-references.ts +30 -22
  24. package/src/lib/metric-alerts.test.ts +769 -0
  25. package/src/lib/metric-alerts.ts +640 -0
  26. package/src/lib/metrics-store.ts +9 -2
  27. package/src/lib/sql-db-postgres.integration.test.ts +247 -0
  28. package/src/lib/stage6.test.ts +1 -1
  29. package/src/lib/store/trace-queries.ts +1 -1
  30. package/src/migrations/054_alert_rules_pending_secs.sql +11 -0
  31. package/src/migrations/055_alert_state_pending_since.sql +12 -0
  32. package/src/migrations/056_metric_point_series_received.sql +9 -0
  33. package/src/migrations/057_alert_evaluations_outcome.sql +11 -0
  34. package/src/migrations/058_alert_state_pending_seen_at.sql +12 -0
  35. package/src/migrations/059_alert_evaluations_error.sql +11 -0
  36. package/src/migrations/parity.test.ts +105 -3
  37. package/src/migrations-postgres/054_alert_rules_pending_secs.sql +7 -0
  38. package/src/migrations-postgres/055_alert_state_pending_since.sql +6 -0
  39. package/src/migrations-postgres/056_metric_point_series_received.sql +7 -0
  40. package/src/migrations-postgres/057_alert_evaluations_outcome.sql +6 -0
  41. package/src/migrations-postgres/058_alert_state_pending_seen_at.sql +6 -0
  42. package/src/migrations-postgres/059_alert_evaluations_error.sql +6 -0
  43. package/src/migrations-postgres/README.md +13 -0
  44. package/src/plugins/ai-receiver.ts +6 -1
  45. package/src/plugins/alerts-evaluator-pending.test.ts +322 -0
  46. package/src/plugins/alerts-evaluator-retention.test.ts +2 -0
  47. package/src/plugins/alerts-evaluator.test.ts +10 -2
  48. package/src/plugins/alerts-evaluator.ts +239 -40
  49. package/src/plugins/alerts-routes.ts +17 -2
  50. package/src/plugins/autosre-routes.ts +251 -140
  51. package/src/plugins/connected-routes/manifest.test.ts +114 -32
  52. package/src/plugins/connected-routes/manifest.ts +59 -76
  53. package/src/plugins/connected-routes.test.ts +10 -8
  54. package/src/plugins/connected-routes.ts +42 -12
  55. package/src/plugins/eval-cases-routes.ts +33 -10
  56. package/src/plugins/evidence-retrieval-routes.ts +60 -25
  57. package/dist/chunk-ZSF5XP3B.js.map +0 -1
  58. /package/dist/{openai-GSJZGFGL.js.map → openai-VDKVVZAV.js.map} +0 -0
@@ -0,0 +1,451 @@
1
+ import {
2
+ ContractMismatchError,
3
+ createInternalClient,
4
+ InternalClientHttpError,
5
+ type InternalEndpoints,
6
+ } from "@obsunified/types";
7
+ import { Hono } from "hono";
8
+ import { describe, expect, it } from "vitest";
9
+ import { CollectorRuntime } from "../framework/collector";
10
+ import type { CollectorEnv } from "../framework/env";
11
+ import { upsertOntologyFromSpans } from "../lib/autosre-ontology";
12
+ import {
13
+ createMigratedSqliteDb,
14
+ type NodeSqliteDb,
15
+ } from "../lib/test-utils/node-sqlite-db";
16
+ import { aiReceiverPlugin } from "../plugins/ai-receiver";
17
+ import {
18
+ autosreRoutesPlugin,
19
+ respondWithContract,
20
+ } from "../plugins/autosre-routes";
21
+ import { connectedRoutesPlugin } from "../plugins/connected-routes";
22
+ import { evalCasesRoutesPlugin } from "../plugins/eval-cases-routes";
23
+ import { evidenceRetrievalRoutesPlugin } from "../plugins/evidence-retrieval-routes";
24
+
25
+ const createTestApp = (db: NodeSqliteDb) => {
26
+ const app = new Hono<{ Bindings: CollectorEnv }>();
27
+ const runtime = new CollectorRuntime();
28
+ autosreRoutesPlugin.register(app, runtime);
29
+ evalCasesRoutesPlugin.register(app, runtime);
30
+ connectedRoutesPlugin.register(app, runtime);
31
+ evidenceRetrievalRoutesPlugin.register(app, runtime);
32
+ aiReceiverPlugin.register(app, runtime);
33
+ const env: CollectorEnv = {
34
+ DB: db as unknown as D1Database,
35
+ };
36
+ return { app, env };
37
+ };
38
+
39
+ describe("Collector internal HTTP contract integration (RFC 0014 WS5)", () => {
40
+ it("respondWithContract throws ContractMismatchError if a handler attempts to return a mistyped payload", () => {
41
+ const fakeContext = {
42
+ json: (data: unknown, status = 200) =>
43
+ new Response(JSON.stringify(data), { status }),
44
+ };
45
+ expect(() =>
46
+ respondWithContract(
47
+ fakeContext,
48
+ "GET /internal/incidents",
49
+ // Intentionally mistyped payload
50
+ {
51
+ incidents: "not-an-array",
52
+ } as unknown as InternalEndpoints["GET /internal/incidents"]["response"],
53
+ ),
54
+ ).toThrow(ContractMismatchError);
55
+ });
56
+
57
+ it("createInternalClient round-trips changes, incidents, findings, counterfactuals, remediations, evals, connected, AI calls, and evidence against collector routes", async () => {
58
+ const db = createMigratedSqliteDb();
59
+ const { app, env } = createTestApp(db);
60
+
61
+ const makeClient = (defaultHeaders?: Record<string, string>) =>
62
+ createInternalClient({
63
+ baseUrl: "http://localhost",
64
+ defaultHeaders,
65
+ fetchImpl: (input, init) => app.request(input, init, env),
66
+ });
67
+
68
+ const client = makeClient();
69
+
70
+ // Seed a trace + span so eval cases and ontology have a source entity
71
+ db.raw
72
+ .prepare(
73
+ `INSERT INTO telemetry_spans (
74
+ project_id, trace_id, span_id, parent_span_id, service_name,
75
+ span_name, span_kind, start_time, end_time, duration_ms,
76
+ status_code, status_message, attributes_json, resource_attributes_json,
77
+ events_json, links_json, received_at, expires_at,
78
+ environment, service_version, commit_sha, target_node_id, error_signature
79
+ ) VALUES (
80
+ 'default', 'trace-ws5', 'span-ws5', NULL, 'checkout',
81
+ 'POST /charge', 2, '2026-04-16T00:00:00.000Z', '2026-04-16T00:00:00.500Z', 500,
82
+ 2, 'Timeout', '{}', '{}',
83
+ '[]', '[]', '2026-04-16T00:00:00.500Z', '2099-01-01T00:00:00.000Z',
84
+ 'production', '2.1.0', 'abc1234', 'service:default:checkout', 'Timeout'
85
+ )`,
86
+ )
87
+ .run();
88
+ await upsertOntologyFromSpans(db, [
89
+ {
90
+ projectId: "default",
91
+ traceId: "trace-ws5",
92
+ spanId: "span-ws5",
93
+ parentSpanId: null,
94
+ traceState: null,
95
+ serviceName: "checkout",
96
+ spanName: "POST /charge",
97
+ spanKind: 2,
98
+ statusCode: 2,
99
+ statusMessage: "Timeout",
100
+ startTime: "2026-04-16T00:00:00.000Z",
101
+ endTime: "2026-04-16T00:00:00.500Z",
102
+ durationMs: 500,
103
+ environment: "prod",
104
+ scopeName: null,
105
+ scopeVersion: null,
106
+ attributesJson: "{}",
107
+ resourceAttributesJson: "{}",
108
+ eventsJson: "[]",
109
+ linksJson: "[]",
110
+ droppedAttributesCount: 0,
111
+ droppedEventsCount: 0,
112
+ droppedLinksCount: 0,
113
+ receivedAt: "2026-04-16T00:00:00.500Z",
114
+ expiresAt: "2099-01-01T00:00:00.000Z",
115
+ },
116
+ ]);
117
+
118
+ // 1. Changes
119
+ const createdChange = await client.request("POST /v1/changes", {
120
+ body: {
121
+ environment: "prod",
122
+ kind: "deploy",
123
+ serviceName: "checkout",
124
+ version: "2.1.0",
125
+ previousVersion: "2.0.0",
126
+ commitSha: "abc1234",
127
+ summary: "Deploy checkout 2.1.0",
128
+ },
129
+ });
130
+ expect(createdChange.id).toBeTruthy();
131
+ expect(createdChange.change.id).toBe(createdChange.id);
132
+ expect(createdChange.changeEvent?.id).toBe(createdChange.id);
133
+
134
+ const listedChanges = await client.request("GET /internal/changes", {
135
+ query: { environment: "prod", serviceName: "checkout" },
136
+ });
137
+ expect(listedChanges.changes.some((c) => c.id === createdChange.id)).toBe(
138
+ true,
139
+ );
140
+
141
+ const fetchedChange = await client.request("GET /internal/changes/:id", {
142
+ params: { id: createdChange.id },
143
+ });
144
+ expect(fetchedChange.change.id).toBe(createdChange.id);
145
+
146
+ const blastRadius = await client.request(
147
+ "GET /internal/changes/:id/blast-radius",
148
+ {
149
+ params: { id: createdChange.id },
150
+ },
151
+ );
152
+ expect(blastRadius.change.id).toBe(createdChange.id);
153
+ expect(blastRadius.blastRadius.change.id).toBe(createdChange.id);
154
+
155
+ // 2. Ontology
156
+ const ontology = await client.request("GET /internal/ontology", {
157
+ query: { environment: "prod", traceId: "trace-ws5" },
158
+ });
159
+ expect(Array.isArray(ontology.nodes)).toBe(true);
160
+
161
+ // 3. Incidents (both /internal/incidents and /internal/incidents/open)
162
+ const openedIncident = await client.request("POST /internal/incidents", {
163
+ body: {
164
+ environment: "prod",
165
+ targetNodeId: "service:default:checkout",
166
+ symptomAnchorKind: "trace",
167
+ symptomAnchorId: "trace-ws5",
168
+ symptomFingerprint: "fp-checkout-timeout",
169
+ },
170
+ });
171
+ expect(openedIncident.deduplicated).toBe(false);
172
+
173
+ const dedupedIncident = await client.request(
174
+ "POST /internal/incidents/open",
175
+ {
176
+ body: {
177
+ environment: "prod",
178
+ targetNodeId: "service:default:checkout",
179
+ symptomAnchorKind: "trace",
180
+ symptomAnchorId: "trace-ws5",
181
+ symptomFingerprint: "fp-checkout-timeout",
182
+ },
183
+ },
184
+ );
185
+ expect(dedupedIncident.deduplicated).toBe(true);
186
+ expect(dedupedIncident.incident.id).toBe(openedIncident.incident.id);
187
+
188
+ const leaseResult = await client.request(
189
+ "POST /internal/incidents/:id/lease",
190
+ {
191
+ params: { id: openedIncident.incident.id },
192
+ body: {
193
+ runnerId: "runner-ws5",
194
+ leaseDurationSeconds: 120,
195
+ },
196
+ },
197
+ );
198
+ expect(leaseResult.acquired).toBe(true);
199
+
200
+ const incidentsList = await client.request("GET /internal/incidents", {
201
+ query: { environment: "prod" },
202
+ });
203
+ expect(
204
+ incidentsList.incidents.some((i) => i.id === openedIncident.incident.id),
205
+ ).toBe(true);
206
+
207
+ // 4. Findings & Counterfactuals
208
+ const createdFinding = await client.request("POST /internal/findings", {
209
+ body: {
210
+ environment: "prod",
211
+ incidentId: openedIncident.incident.id,
212
+ title: "Checkout timeout after 2.1.0 deploy",
213
+ summary: "Cohort isolated to version 2.1.0",
214
+ symptomEntityKind: "trace",
215
+ symptomEntityId: "trace-ws5",
216
+ faultLocusEntityKind: "span",
217
+ faultLocusEntityId: "trace-ws5:span-ws5",
218
+ rootCauseEntityKind: "change",
219
+ rootCauseEntityId: createdChange.id,
220
+ contributingChangeId: createdChange.id,
221
+ causalChain: [
222
+ {
223
+ subjectKind: "change",
224
+ subjectId: createdChange.id,
225
+ predicate: "degraded_after_change",
226
+ objectKind: "span",
227
+ objectId: "trace-ws5:span-ws5",
228
+ relationshipCertainty: "exact",
229
+ causalProofTier: "corroborated_candidate",
230
+ confidence: 0.9,
231
+ reason: "Version 2.1.0 deployed immediately before errors",
232
+ },
233
+ ],
234
+ citedEvidenceIds: [`change:${createdChange.id}`, "trace:trace-ws5"],
235
+ },
236
+ });
237
+ expect(createdFinding.finding.id).toBe(createdFinding.id);
238
+
239
+ const fetchedFinding = await client.request("GET /internal/findings/:id", {
240
+ params: { id: createdFinding.id },
241
+ });
242
+ expect(fetchedFinding.finding.id).toBe(createdFinding.id);
243
+
244
+ const findingsList = await client.request("GET /internal/findings", {
245
+ query: { incidentId: openedIncident.incident.id },
246
+ });
247
+ expect(findingsList.findings.some((f) => f.id === createdFinding.id)).toBe(
248
+ true,
249
+ );
250
+
251
+ // Eval case + runs for counterfactual verification
252
+ const createdCase = await client.request("POST /internal/eval-cases", {
253
+ body: {
254
+ sourceEntityType: "trace",
255
+ sourceEntityId: "trace-ws5",
256
+ name: "Checkout timeout regression case",
257
+ },
258
+ });
259
+ expect(createdCase.evalCase.id).toBeTruthy();
260
+
261
+ const listedCases = await client.request("GET /internal/eval-cases", {
262
+ query: { sourceEntityType: "trace" },
263
+ });
264
+ expect(
265
+ listedCases.evalCases.some((ec) => ec.id === createdCase.evalCase.id),
266
+ ).toBe(true);
267
+
268
+ const failingRun = await client.request("POST /internal/eval-runs", {
269
+ body: {
270
+ evalCaseId: createdCase.evalCase.id,
271
+ status: "failed",
272
+ environment: "prod",
273
+ serviceVersion: "2.1.0",
274
+ commitSha: "abc1234",
275
+ inputPayloadHash: "hash-ws5",
276
+ failureFingerprint: createdFinding.finding.fingerprint,
277
+ totalCount: 1,
278
+ passCount: 0,
279
+ failCount: 1,
280
+ },
281
+ });
282
+ await client.request("POST /internal/eval-cases/:id/results", {
283
+ params: { id: createdCase.evalCase.id },
284
+ body: {
285
+ runId: failingRun.evalRun.id,
286
+ passed: false,
287
+ inputPayloadHash: "hash-ws5",
288
+ failureFingerprint: createdFinding.finding.fingerprint,
289
+ },
290
+ });
291
+
292
+ const passingRunIds: string[] = [];
293
+ for (let i = 0; i < 3; i += 1) {
294
+ const passRun = await client.request("POST /internal/eval-runs", {
295
+ body: {
296
+ evalCaseId: createdCase.evalCase.id,
297
+ status: "completed",
298
+ environment: "prod",
299
+ serviceVersion: "2.0.0",
300
+ commitSha: "prev000",
301
+ inputPayloadHash: "hash-ws5",
302
+ totalCount: 1,
303
+ passCount: 1,
304
+ failCount: 0,
305
+ },
306
+ });
307
+ await client.request("POST /internal/eval-cases/:id/results", {
308
+ params: { id: createdCase.evalCase.id },
309
+ body: {
310
+ runId: passRun.evalRun.id,
311
+ passed: true,
312
+ inputPayloadHash: "hash-ws5",
313
+ },
314
+ });
315
+ passingRunIds.push(passRun.evalRun.id);
316
+ }
317
+
318
+ const evalRunSummary = await client.request(
319
+ "GET /internal/evals/runs/:id",
320
+ {
321
+ params: { id: failingRun.evalRun.id },
322
+ },
323
+ );
324
+ expect(evalRunSummary.run.id).toBe(failingRun.evalRun.id);
325
+
326
+ const counterfactual = await client.request(
327
+ "POST /internal/findings/:id/counterfactual",
328
+ {
329
+ params: { id: createdFinding.id },
330
+ body: {
331
+ environment: "prod",
332
+ evalCaseId: createdCase.evalCase.id,
333
+ failingRunId: failingRun.evalRun.id,
334
+ passingRunIds,
335
+ },
336
+ },
337
+ );
338
+ expect(counterfactual.verified).toBe(true);
339
+ expect(counterfactual.finding.causalProofTier).toBe(
340
+ "counterfactual_verified",
341
+ );
342
+
343
+ // 5. Remediations
344
+ const proposedHuman = await client.request(
345
+ "POST /internal/remediations/propose",
346
+ {
347
+ body: {
348
+ environment: "prod",
349
+ incidentId: openedIncident.incident.id,
350
+ findingId: createdFinding.id,
351
+ idempotencyKey: "idem-ws5-human",
352
+ actionKind: "rollback_deploy",
353
+ targetNodeId: "service:default:checkout",
354
+ expectedCurrentState: { version: "2.1.0" },
355
+ desiredTargetState: { version: "2.0.0" },
356
+ requestedAutonomy: "human_approved_write",
357
+ },
358
+ },
359
+ );
360
+ expect(proposedHuman.proposal.status).toBe("pending_human_approval");
361
+
362
+ // Bearer token approval must fail with 403 InternalClientHttpError
363
+ const bearerClient = makeClient({
364
+ Authorization: "Bearer obs_secret_agent_token",
365
+ });
366
+ await expect(
367
+ bearerClient.request("POST /internal/remediations/:id/approve", {
368
+ params: { id: proposedHuman.proposal.id },
369
+ body: {},
370
+ }),
371
+ ).rejects.toThrow(InternalClientHttpError);
372
+
373
+ // Cookie session approval succeeds and mints grantToken
374
+ const humanSessionClient = makeClient({
375
+ Cookie: "obs_session=valid_human_session_cookie",
376
+ });
377
+ const approved = await humanSessionClient.request(
378
+ "POST /internal/remediations/:id/approve",
379
+ {
380
+ params: { id: proposedHuman.proposal.id },
381
+ body: {},
382
+ },
383
+ );
384
+ expect(approved.proposal.status).toBe("grant_issued");
385
+ expect(typeof approved.grantToken).toBe("string");
386
+
387
+ const claimed = await client.request(
388
+ "POST /internal/remediations/:id/claim",
389
+ {
390
+ params: { id: proposedHuman.proposal.id },
391
+ body: {
392
+ grantToken: approved.grantToken as string,
393
+ runnerId: "runner-ws5",
394
+ },
395
+ },
396
+ );
397
+ expect(claimed.claimed).toBe(true);
398
+ expect(claimed.proposal.status).toBe("claimed_executing");
399
+
400
+ const completed = await client.request(
401
+ "POST /internal/remediations/:id/complete",
402
+ {
403
+ params: { id: proposedHuman.proposal.id },
404
+ body: {
405
+ runnerId: "runner-ws5",
406
+ resultingChangeId: createdChange.id,
407
+ },
408
+ },
409
+ );
410
+ expect(completed.proposal.status).toBe("completed");
411
+
412
+ const watchdog = await client.request(
413
+ "POST /internal/remediations/watchdog",
414
+ {
415
+ body: {},
416
+ },
417
+ );
418
+ expect(typeof watchdog.summary.evaluated).toBe("number");
419
+
420
+ const incidentDetail = await client.request("GET /internal/incidents/:id", {
421
+ params: { id: openedIncident.incident.id },
422
+ });
423
+ expect(incidentDetail.incident.id).toBe(openedIncident.incident.id);
424
+ expect(incidentDetail.findings.length).toBeGreaterThanOrEqual(1);
425
+ expect(incidentDetail.proposals.length).toBeGreaterThanOrEqual(1);
426
+
427
+ // 6. Connected, Evidence Bundle, Evidence Stats
428
+ const connected = await client.request(
429
+ "GET /internal/connected/:kind/:id",
430
+ {
431
+ params: { kind: "incident", id: openedIncident.incident.id },
432
+ },
433
+ );
434
+ expect(connected.entity.id).toBe(openedIncident.incident.id);
435
+
436
+ const bundle = await client.request("POST /internal/evidence/bundle", {
437
+ body: {
438
+ anchor: {
439
+ entityKind: "incident",
440
+ entityId: openedIncident.incident.id,
441
+ },
442
+ },
443
+ });
444
+ expect(bundle.schemaVersion).toBe("obs-unified.evidence-bundle.v1");
445
+
446
+ const stats = await client.request("GET /internal/evidence/stats", {
447
+ query: { limit: 10 },
448
+ });
449
+ expect(stats.projectId).toBe("default");
450
+ });
451
+ });
@@ -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 */
@@ -89,6 +96,112 @@ export interface CollectorEnv {
89
96
  TELEMETRY_QUERY_TOKEN?: string;
90
97
  }
91
98
 
99
+ /**
100
+ * Optional string settings on `CollectorEnv` that host adapters (such as
101
+ * `apps/collector-node`) forward directly from the process environment after
102
+ * trimming whitespace and coercing blank strings to `undefined`.
103
+ */
104
+ export const COLLECTOR_OPTIONAL_STRING_ENV_KEYS = [
105
+ "RETENTION_HOURS",
106
+ "ANTHROPIC_API_KEY",
107
+ "OPENAI_API_KEY",
108
+ "OPENAI_BASE_URL",
109
+ "NARRATIVE_MODEL",
110
+ "NARRATIVE_BUDGET_PER_HOUR",
111
+ "ALERT_WEBHOOK_ALLOW_PRIVATE",
112
+ "MAX_BODY_BYTES",
113
+ "TELEMETRY_REDACT_FIELDS",
114
+ "OBS_PAYLOAD_CAPTURE_DEFAULT",
115
+ ] as const;
116
+
117
+ /**
118
+ * String settings on `CollectorEnv` that require explicit host handling (e.g.
119
+ * required auth secrets, CORS origin parsing, Worker-only self-instrumentation
120
+ * vars, or deprecated Worker tokens) and must not be blindly forwarded by the
121
+ * generic optional-env loop.
122
+ */
123
+ export const COLLECTOR_HOST_MANAGED_ENV_KEYS = [
124
+ "INGEST_KEY",
125
+ "DASHBOARD_PASSWORD",
126
+ "DASHBOARD_TOKEN",
127
+ "ALLOWED_ORIGINS",
128
+ "ALLOW_UNAUTHENTICATED",
129
+ "OBS_DASHBOARD_INGEST_KEY",
130
+ "OBS_COLLECTOR_SELF_URL",
131
+ "OBS_SELF_AWAIT_EXPORTS",
132
+ "TELEMETRY_INGEST_TOKEN",
133
+ "USAGE_INGEST_TOKEN",
134
+ "TELEMETRY_QUERY_TOKEN",
135
+ ] as const;
136
+
137
+ export const COLLECTOR_STRING_ENV_MANIFEST = {
138
+ forwarded: COLLECTOR_OPTIONAL_STRING_ENV_KEYS,
139
+ hostManaged: COLLECTOR_HOST_MANAGED_ENV_KEYS,
140
+ } as const;
141
+
142
+ export type CollectorOptionalStringEnvKey =
143
+ (typeof COLLECTOR_OPTIONAL_STRING_ENV_KEYS)[number];
144
+
145
+ export type CollectorHostManagedEnvKey =
146
+ (typeof COLLECTOR_HOST_MANAGED_ENV_KEYS)[number];
147
+
148
+ export type CollectorManifestStringEnvKey =
149
+ | CollectorOptionalStringEnvKey
150
+ | CollectorHostManagedEnvKey;
151
+
152
+ export type CollectorObjectBindingKey =
153
+ | "DB"
154
+ | "REPLAYS_BUCKET"
155
+ | "PROFILES_BUCKET"
156
+ | "TAIL_HUB"
157
+ | "ALERT_EMAIL";
158
+
159
+ export type CollectorStringEnvKey = Exclude<
160
+ keyof CollectorEnv,
161
+ CollectorObjectBindingKey
162
+ >;
163
+
164
+ type AssertNever<T extends never> = T;
165
+ type _UnaccountedCollectorStringEnvKeys = AssertNever<
166
+ Exclude<CollectorStringEnvKey, CollectorManifestStringEnvKey>
167
+ >;
168
+ type _UnknownManifestStringEnvKeys = AssertNever<
169
+ Exclude<CollectorManifestStringEnvKey, CollectorStringEnvKey>
170
+ >;
171
+ type _OverlappingManifestStringEnvKeys = AssertNever<
172
+ Extract<CollectorOptionalStringEnvKey, CollectorHostManagedEnvKey>
173
+ >;
174
+
175
+ /**
176
+ * Verifies that every string key in `declaredKeys` is categorized in either
177
+ * `COLLECTOR_OPTIONAL_STRING_ENV_KEYS` or `COLLECTOR_HOST_MANAGED_ENV_KEYS`
178
+ * without overlap. Throws if any key is uncategorized or duplicated.
179
+ */
180
+ export function assertCollectorEnvManifestCompleteness(
181
+ declaredKeys: readonly string[],
182
+ manifest: {
183
+ readonly forwarded: readonly string[];
184
+ readonly hostManaged: readonly string[];
185
+ } = COLLECTOR_STRING_ENV_MANIFEST,
186
+ ): void {
187
+ const forwardedSet = new Set(manifest.forwarded);
188
+ const hostManagedSet = new Set(manifest.hostManaged);
189
+ const overlap = manifest.forwarded.filter((k) => hostManagedSet.has(k));
190
+ if (overlap.length > 0) {
191
+ throw new Error(
192
+ `CollectorEnv manifest keys cannot be both forwarded and host-managed: ${overlap.join(", ")}`,
193
+ );
194
+ }
195
+ const unaccounted = declaredKeys.filter(
196
+ (k) => !forwardedSet.has(k) && !hostManagedSet.has(k),
197
+ );
198
+ if (unaccounted.length > 0) {
199
+ throw new Error(
200
+ `Uncategorized CollectorEnv string setting(s): ${unaccounted.join(", ")}. Add each key to COLLECTOR_OPTIONAL_STRING_ENV_KEYS or COLLECTOR_HOST_MANAGED_ENV_KEYS.`,
201
+ );
202
+ }
203
+ }
204
+
92
205
  export interface CollectorRouteContext {
93
206
  hono: Context<{ Bindings: CollectorEnv }>;
94
207
  env: 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";
@@ -68,10 +69,19 @@ export {
68
69
  type UsageEventProcessorPlugin,
69
70
  } from "./framework/collector";
70
71
 
71
- export type {
72
- CollectorApp,
73
- CollectorEnv,
74
- CollectorRouteContext,
72
+ export {
73
+ assertCollectorEnvManifestCompleteness,
74
+ COLLECTOR_HOST_MANAGED_ENV_KEYS,
75
+ COLLECTOR_OPTIONAL_STRING_ENV_KEYS,
76
+ COLLECTOR_STRING_ENV_MANIFEST,
77
+ type CollectorApp,
78
+ type CollectorEnv,
79
+ type CollectorHostManagedEnvKey,
80
+ type CollectorManifestStringEnvKey,
81
+ type CollectorObjectBindingKey,
82
+ type CollectorOptionalStringEnvKey,
83
+ type CollectorRouteContext,
84
+ type CollectorStringEnvKey,
75
85
  } from "./framework/env";
76
86
 
77
87
  export type { Logger } from "./framework/logger";
@@ -91,6 +101,13 @@ export {
91
101
  type RetrievalEventRecord,
92
102
  type ToolCallRecord,
93
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";
94
111
  export { AlertsStore, compareValue } from "./lib/alerts-store";
95
112
  export {
96
113
  computeChangeBlastRadius,
@@ -159,6 +176,11 @@ export {
159
176
  isEvalCaseSourceType,
160
177
  isEvalRunStatus,
161
178
  } from "./lib/eval-cases-store";
179
+ export {
180
+ evaluateMetricQuery,
181
+ reduceSeries,
182
+ validateMetricQuery,
183
+ } from "./lib/metric-alerts";
162
184
  export { MetricsStore } from "./lib/metrics-store";
163
185
  export { ProjectsStore } from "./lib/projects-store";
164
186
  export { D1Adapter, type SqlDb, type SqlStatement } from "./lib/sql-db";
@@ -183,6 +205,7 @@ export {
183
205
  defaultSpanEnrichmentPlugin,
184
206
  evalCasesRoutesPlugin,
185
207
  evaluateAllRules,
208
+ evaluateRuleBatch,
186
209
  evidenceRetrievalRoutesPlugin,
187
210
  genAiNormalizerPlugin,
188
211
  identityReceiverPlugin,
@@ -25,20 +25,20 @@ describe("mapEvaluationRows", () => {
25
25
  evidenceId: "ai-evaluation:eval-1",
26
26
  entityKind: "eval",
27
27
  entityId: "eval-1",
28
- route: "#/evals/eval-1",
28
+ route: "#/evaluations/eval-1",
29
29
  source: "ai_span_evaluations.llm_judge",
30
30
  citations: [
31
31
  {
32
32
  label: "span span-1",
33
33
  entityKind: "span",
34
34
  entityId: "trace-1:span-1",
35
- route: "#/traces/trace-1#span=span-1",
35
+ route: "#/traces?trace=trace-1&span=span-1",
36
36
  },
37
37
  {
38
38
  label: "trace trace-1",
39
39
  entityKind: "trace",
40
40
  entityId: "trace-1",
41
- route: "#/traces/trace-1",
41
+ route: "#/traces?trace=trace-1",
42
42
  },
43
43
  ],
44
44
  }),