@obsunified/collector 2.0.1 → 2.1.1

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 (107) hide show
  1. package/dist/auth.d.ts +1 -1
  2. package/dist/auth.js +1 -1
  3. package/dist/{chunk-UGWDOGGH.js → chunk-KMEZKGSO.js} +22 -10
  4. package/dist/chunk-KMEZKGSO.js.map +1 -0
  5. package/dist/index.d.ts +322 -26
  6. package/dist/index.js +1478 -415
  7. package/dist/index.js.map +1 -1
  8. package/dist/{ingest-auth-HFVO1zIp.d.ts → ingest-auth-C5-8Mly5.d.ts} +9 -0
  9. package/package.json +5 -5
  10. package/src/analyses/derive.ts +26 -25
  11. package/src/analyses/investigations.ts +8 -8
  12. package/src/analyses/tier0.ts +22 -22
  13. package/src/auth/dashboard-auth.test.ts +791 -0
  14. package/src/auth/dashboard-auth.ts +187 -38
  15. package/src/auth/login-rate-limit.test.ts +129 -0
  16. package/src/auth/login-rate-limit.ts +143 -0
  17. package/src/framework/body-limit.test.ts +165 -0
  18. package/src/framework/collector.ts +355 -115
  19. package/src/framework/env.ts +9 -0
  20. package/src/framework/limits.ts +31 -0
  21. package/src/framework/scheduled-handlers.test.ts +342 -0
  22. package/src/index.ts +16 -1
  23. package/src/lib/action-retention.test.ts +472 -0
  24. package/src/lib/action-retention.ts +420 -0
  25. package/src/lib/actions-store.ts +6 -12
  26. package/src/lib/ai-store/calls.ts +2 -2
  27. package/src/lib/ai-store/sessions.ts +3 -3
  28. package/src/lib/ai-store/spans.ts +1 -1
  29. package/src/lib/ai-store/types.ts +2 -1
  30. package/src/lib/alerts-store.test.ts +153 -0
  31. package/src/lib/alerts-store.ts +122 -21
  32. package/src/lib/analyses-runner.test.ts +75 -0
  33. package/src/lib/analyses-runner.ts +60 -3
  34. package/src/lib/analyses-store-latest.test.ts +61 -0
  35. package/src/lib/analyses-store.ts +17 -10
  36. package/src/lib/blob-store.ts +1 -1
  37. package/src/lib/housekeeping.test.ts +73 -0
  38. package/src/lib/housekeeping.ts +93 -0
  39. package/src/lib/identity-index/action-lookups.ts +6 -6
  40. package/src/lib/identity-index/key-lookups.ts +2 -2
  41. package/src/lib/identity-index/user-lookup.ts +8 -5
  42. package/src/lib/identity-index.test.ts +2 -3
  43. package/src/lib/metrics-store-series.test.ts +99 -0
  44. package/src/lib/metrics-store.test.ts +23 -0
  45. package/src/lib/metrics-store.ts +27 -3
  46. package/src/lib/projects-store.test.ts +25 -0
  47. package/src/lib/projects-store.ts +4 -3
  48. package/src/lib/sql-db-dialect.test.ts +12 -3
  49. package/src/lib/sql-db-postgres.integration.test.ts +1225 -0
  50. package/src/lib/sql-db-postgres.test.ts +314 -0
  51. package/src/lib/sql-db-postgres.ts +392 -54
  52. package/src/lib/sql-db-sqlite-time-windows.test.ts +194 -0
  53. package/src/lib/sql-db.ts +40 -6
  54. package/src/lib/test-utils/action-graph-fixtures.ts +307 -0
  55. package/src/lib/test-utils/node-sqlite-db.ts +188 -0
  56. package/src/lib/webhook-url.test.ts +122 -0
  57. package/src/lib/webhook-url.ts +155 -0
  58. package/src/migrations/041_ingest_key_hash_text.sql +5 -0
  59. package/src/migrations/042_session_replay_project_composite_pk.sql +52 -0
  60. package/src/migrations/043_metric_exemplars_text_timestamps.sql +5 -0
  61. package/src/migrations/044_actions_started_at_index.sql +10 -0
  62. package/src/migrations/045_user_profiles_project_composite_pk.sql +40 -0
  63. package/src/migrations/046_action_graph_project_composite_pk.sql +255 -0
  64. package/src/migrations/047_ai_span_payloads_project_composite_pk.sql +61 -0
  65. package/src/migrations/048_telemetry_spans_project_composite_pk.sql +92 -0
  66. package/src/migrations/049_alert_state_notified.sql +17 -0
  67. package/src/migrations/alert-state-notified.test.ts +111 -0
  68. package/src/migrations/parity.test.ts +64 -0
  69. package/src/migrations/project-scoped-keys.test.ts +432 -0
  70. package/src/migrations-postgres/009_projects_and_keys.sql +4 -4
  71. package/src/migrations-postgres/026_obs_dashboard_project_seed.sql +1 -1
  72. package/src/migrations-postgres/035_metric_exemplars.sql +5 -2
  73. package/src/migrations-postgres/041_ingest_key_hash_text.sql +32 -0
  74. package/src/migrations-postgres/042_session_replay_project_composite_pk.sql +16 -0
  75. package/src/migrations-postgres/043_metric_exemplars_text_timestamps.sql +47 -0
  76. package/src/migrations-postgres/044_actions_started_at_index.sql +8 -0
  77. package/src/migrations-postgres/045_user_profiles_project_composite_pk.sql +33 -0
  78. package/src/migrations-postgres/046_action_graph_project_composite_pk.sql +39 -0
  79. package/src/migrations-postgres/047_ai_span_payloads_project_composite_pk.sql +30 -0
  80. package/src/migrations-postgres/048_telemetry_spans_project_composite_pk.sql +37 -0
  81. package/src/migrations-postgres/049_alert_state_notified.sql +10 -0
  82. package/src/migrations-postgres/README.md +58 -5
  83. package/src/otlp/decode/body.test.ts +76 -0
  84. package/src/otlp/decode/body.ts +67 -6
  85. package/src/plugins/_json.ts +21 -0
  86. package/src/plugins/action-graph-processor.ts +6 -12
  87. package/src/plugins/ai-receiver.ts +5 -12
  88. package/src/plugins/ai-span-payloads-processor.ts +1 -2
  89. package/src/plugins/alerts-evaluator-retention.test.ts +170 -0
  90. package/src/plugins/alerts-evaluator.test.ts +464 -3
  91. package/src/plugins/alerts-evaluator.ts +165 -41
  92. package/src/plugins/alerts-routes.ts +10 -6
  93. package/src/plugins/ask-routes.ts +4 -5
  94. package/src/plugins/connected-routes.test.ts +106 -0
  95. package/src/plugins/connected-routes.ts +14 -5
  96. package/src/plugins/dashboard-routes.test.ts +120 -0
  97. package/src/plugins/dashboard-routes.ts +11 -2
  98. package/src/plugins/eval-cases-routes.ts +8 -17
  99. package/src/plugins/identity-receiver.ts +9 -11
  100. package/src/plugins/json-body.test.ts +91 -0
  101. package/src/plugins/onboarding-routes.test.ts +105 -0
  102. package/src/plugins/onboarding-routes.ts +26 -11
  103. package/src/plugins/projects-routes.ts +2 -1
  104. package/src/plugins/replay-isolation.test.ts +922 -0
  105. package/src/plugins/replay-receiver.ts +8 -8
  106. package/src/plugins/usage-receiver.ts +3 -6
  107. package/dist/chunk-UGWDOGGH.js.map +0 -1
@@ -0,0 +1,432 @@
1
+ /**
2
+ * Migrations 045–048 move four row families from globally unique keys to
3
+ * project-scoped composite keys. These tests run the real D1 migration
4
+ * files through node:sqlite and then exercise the real write paths
5
+ * (/v1/identify, ActionStore, the AI span payload processor, span ingest)
6
+ * with two projects that emit identical ids.
7
+ */
8
+
9
+ import type { StoredSpan } from "@obsunified/types";
10
+ import type { MiddlewareHandler } from "hono";
11
+ import { describe, expect, it } from "vitest";
12
+ import {
13
+ type CollectorRuntime,
14
+ createTelemetryCollectorApp,
15
+ type SpanProcessorPlugin,
16
+ } from "../framework/collector";
17
+ import type { CollectorEnv, CollectorRouteContext } from "../framework/env";
18
+ import { ActionStore } from "../lib/actions-store";
19
+ import { getAISpansOverview } from "../lib/ai-store/spans";
20
+ import { manifestByUser } from "../lib/identity-index/user-lookup";
21
+ import { ingestTelemetrySpans } from "../lib/store/ingest";
22
+ import { actionRecord } from "../lib/test-utils/action-graph-fixtures";
23
+ import {
24
+ applySqliteMigrations,
25
+ createMigratedSqliteDb,
26
+ indexNames,
27
+ type NodeSqliteDb,
28
+ primaryKeyColumns,
29
+ } from "../lib/test-utils/node-sqlite-db";
30
+ import { aiSpanPayloadsProcessorPlugin } from "../plugins/ai-span-payloads-processor";
31
+ import { identityReceiverPlugin } from "../plugins/identity-receiver";
32
+
33
+ const NOW = "2026-10-13T05:00:00.000Z";
34
+ const LATER = "2026-10-14T05:00:00.000Z";
35
+
36
+ const EXPECTED_KEYS: Record<string, string[]> = {
37
+ user_profiles: ["project_id", "user_id"],
38
+ actions: ["project_id", "id"],
39
+ agent_runs: ["project_id", "id"],
40
+ tool_calls: ["project_id", "id"],
41
+ retrieval_events: ["project_id", "id"],
42
+ eval_results: ["project_id", "id"],
43
+ artifacts: ["project_id", "id"],
44
+ ai_span_payloads: ["project_id", "trace_id", "span_id"],
45
+ telemetry_spans: ["project_id", "trace_id", "span_id"],
46
+ };
47
+
48
+ const EXPECTED_INDEXES: Record<string, string[]> = {
49
+ user_profiles: ["idx_user_visitor", "idx_users_project_last"],
50
+ actions: [
51
+ "idx_actions_project_actor",
52
+ "idx_actions_project_agent_run",
53
+ "idx_actions_project_interaction",
54
+ "idx_actions_project_root",
55
+ "idx_actions_project_trace",
56
+ "idx_actions_started_at",
57
+ ],
58
+ agent_runs: ["idx_agent_runs_project_agent"],
59
+ tool_calls: ["idx_tool_calls_action", "idx_tool_calls_name"],
60
+ retrieval_events: ["idx_retrieval_events_action"],
61
+ eval_results: ["idx_eval_results_action"],
62
+ artifacts: ["idx_artifacts_action"],
63
+ ai_span_payloads: [
64
+ "idx_ai_payloads_action",
65
+ "idx_ai_payloads_kind",
66
+ "idx_ai_payloads_project",
67
+ "idx_ai_payloads_received",
68
+ "idx_ai_payloads_session",
69
+ "idx_ai_payloads_user",
70
+ ],
71
+ telemetry_spans: [
72
+ "idx_spans_interaction",
73
+ "idx_spans_project_received",
74
+ "idx_spans_project_service_received",
75
+ "idx_spans_received_at",
76
+ "idx_spans_root",
77
+ "idx_spans_sdk_name",
78
+ "idx_spans_service",
79
+ "idx_spans_session",
80
+ "idx_spans_status",
81
+ "idx_spans_trace_id",
82
+ "idx_spans_with_links",
83
+ ],
84
+ };
85
+
86
+ const span = (over: Partial<StoredSpan>): StoredSpan => ({
87
+ projectId: "alpha",
88
+ traceId: "trace-shared",
89
+ spanId: "span-shared",
90
+ parentSpanId: null,
91
+ traceState: null,
92
+ serviceName: "agent",
93
+ scopeName: null,
94
+ scopeVersion: null,
95
+ spanName: "llm",
96
+ spanKind: 1,
97
+ statusCode: 1,
98
+ statusMessage: null,
99
+ startTime: NOW,
100
+ endTime: NOW,
101
+ durationMs: 5,
102
+ attributesJson: "{}",
103
+ droppedAttributesCount: 0,
104
+ resourceAttributesJson: "{}",
105
+ eventsJson: "[]",
106
+ droppedEventsCount: 0,
107
+ linksJson: "[]",
108
+ droppedLinksCount: 0,
109
+ receivedAt: new Date().toISOString(),
110
+ expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
111
+ sessionId: null,
112
+ interactionId: null,
113
+ ...over,
114
+ });
115
+
116
+ const aiSpan = (projectId: string, input: string): StoredSpan =>
117
+ span({
118
+ projectId,
119
+ attributesJson: JSON.stringify({
120
+ "openinference.span.kind": "LLM",
121
+ "ai.payload.input": input,
122
+ "ai.payload.output": `${input}-output`,
123
+ }),
124
+ });
125
+
126
+ const runPayloadProcessor = async (db: NodeSqliteDb, spans: StoredSpan[]) => {
127
+ const processors: SpanProcessorPlugin[] = [];
128
+ aiSpanPayloadsProcessorPlugin.register(
129
+ {} as Parameters<typeof aiSpanPayloadsProcessorPlugin.register>[0],
130
+ {
131
+ addSpanProcessor(p: SpanProcessorPlugin) {
132
+ processors.push(p);
133
+ },
134
+ } as unknown as CollectorRuntime,
135
+ );
136
+ const process = processors[0]?.process;
137
+ if (!process) throw new Error("span processor was not registered");
138
+ return process(spans, {
139
+ env: { DB: db, OBS_PAYLOAD_CAPTURE_DEFAULT: "true" },
140
+ now: new Date(),
141
+ logger: { ...console, error: () => {} },
142
+ } as unknown as CollectorRouteContext);
143
+ };
144
+
145
+ const identifyApp = () => {
146
+ const setProject: MiddlewareHandler<{
147
+ Bindings: CollectorEnv;
148
+ Variables: { projectId: string };
149
+ }> = async (c, next) => {
150
+ const header = c.req.header("x-project-id");
151
+ if (header) c.set("projectId", header);
152
+ await next();
153
+ };
154
+ return createTelemetryCollectorApp({
155
+ plugins: [identityReceiverPlugin],
156
+ auth: { middleware: setProject },
157
+ dashboardAuth: { middleware: setProject, registerRoutes: () => {} },
158
+ });
159
+ };
160
+
161
+ const identify = (
162
+ app: ReturnType<typeof identifyApp>,
163
+ db: NodeSqliteDb,
164
+ projectId: string,
165
+ body: Record<string, unknown>,
166
+ ) =>
167
+ app.request(
168
+ "/v1/identify",
169
+ {
170
+ method: "POST",
171
+ headers: {
172
+ "content-type": "application/json",
173
+ "x-project-id": projectId,
174
+ },
175
+ body: JSON.stringify(body),
176
+ },
177
+ { DB: db as unknown as D1Database } as CollectorEnv,
178
+ );
179
+
180
+ describe("migrations 045–048 (project-scoped composite keys)", () => {
181
+ it("rebuild each table in place, keeping rows, defaults and indexes", async () => {
182
+ const db = createMigratedSqliteDb({ upTo: "044" });
183
+ const raw = db.raw;
184
+
185
+ // Legacy rows written under the old global keys (project_id defaults
186
+ // to 'default' on user_profiles / telemetry_spans).
187
+ raw.exec(`
188
+ INSERT INTO user_profiles (user_id, visitor_id, email, first_seen_at, last_seen_at)
189
+ VALUES ('ada@example.com', 'vis-1', 'ada@example.com', '${NOW}', '${NOW}');
190
+ INSERT INTO actions (id, project_id, root_action_id, actor_type, action_kind, started_at)
191
+ VALUES ('01ACTION', 'alpha', '01ACTION', 'agent', 'agent.run', '${NOW}');
192
+ INSERT INTO agent_runs (id, project_id, agent_id, agent_name, agent_version, autonomy_level)
193
+ VALUES ('01ACTION', 'alpha', 'bot', 'Bot', '1', 'read_only');
194
+ INSERT INTO tool_calls (id, action_id, project_id, tool_name, args_hash, result_hash, mutation_diff_json)
195
+ VALUES ('span-1', '01ACTION', 'alpha', 'sql_query', 'a', 'r', '{"diff":true}');
196
+ INSERT INTO retrieval_events (id, action_id, project_id, retriever_name, query_hash)
197
+ VALUES ('span-2', '01ACTION', 'alpha', 'vector', 'q');
198
+ INSERT INTO eval_results (id, action_id, project_id, evaluator_name, evaluator_version, score)
199
+ VALUES ('span-3', '01ACTION', 'alpha', 'groundedness', '1', 0.9);
200
+ INSERT INTO artifacts (id, action_id, project_id, artifact_name, artifact_type)
201
+ VALUES ('span-4', '01ACTION', 'alpha', 'invoice', 'file');
202
+ INSERT INTO ai_span_payloads (project_id, trace_id, span_id, span_kind, input_json, received_at, expires_at, input_hash, action_id)
203
+ VALUES ('alpha', 't1', 's1', 'LLM', '"prompt"', '${NOW}', '${LATER}', 'hash', '01ACTION');
204
+ INSERT INTO telemetry_spans (trace_id, span_id, span_name, start_time, end_time, duration_ms, received_at, expires_at, telemetry_sdk_name)
205
+ VALUES ('t1', 's1', 'llm', '${NOW}', '${NOW}', 5, '${NOW}', '${LATER}', 'beyla');
206
+ `);
207
+
208
+ const applied = applySqliteMigrations(raw, { from: "045", upTo: "048" });
209
+ expect(applied.map((f) => f.slice(0, 3))).toEqual([
210
+ "045",
211
+ "046",
212
+ "047",
213
+ "048",
214
+ ]);
215
+
216
+ for (const [table, key] of Object.entries(EXPECTED_KEYS)) {
217
+ expect(primaryKeyColumns(raw, table), table).toEqual(key);
218
+ expect(indexNames(raw, table), table).toEqual(EXPECTED_INDEXES[table]);
219
+ expect(
220
+ raw.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get(),
221
+ table,
222
+ ).toEqual({ n: 1 });
223
+ expect(
224
+ raw
225
+ .prepare(`SELECT name FROM sqlite_master WHERE name = ?`)
226
+ .get(`${table}_new`),
227
+ `${table}_new left behind`,
228
+ ).toBeUndefined();
229
+ }
230
+
231
+ expect(
232
+ raw.prepare(`SELECT project_id, email FROM user_profiles`).get(),
233
+ ).toEqual({ project_id: "default", email: "ada@example.com" });
234
+ expect(
235
+ raw
236
+ .prepare(
237
+ `SELECT project_id, status, mutation_diff_json, side_effect FROM tool_calls JOIN actions USING (project_id)`,
238
+ )
239
+ .get(),
240
+ ).toEqual({
241
+ project_id: "alpha",
242
+ status: "ok",
243
+ mutation_diff_json: '{"diff":true}',
244
+ side_effect: 0,
245
+ });
246
+ expect(
247
+ raw
248
+ .prepare(
249
+ `SELECT project_id, telemetry_sdk_name, dropped_links_count FROM telemetry_spans`,
250
+ )
251
+ .get(),
252
+ ).toEqual({
253
+ project_id: "default",
254
+ telemetry_sdk_name: "beyla",
255
+ dropped_links_count: 0,
256
+ });
257
+ expect(
258
+ raw.prepare(`SELECT input_hash, action_id FROM ai_span_payloads`).get(),
259
+ ).toEqual({ input_hash: "hash", action_id: "01ACTION" });
260
+
261
+ // Re-applying the rebuild files is a no-op in effect.
262
+ applySqliteMigrations(raw, { from: "045" });
263
+ for (const [table, key] of Object.entries(EXPECTED_KEYS)) {
264
+ expect(primaryKeyColumns(raw, table), table).toEqual(key);
265
+ expect(
266
+ raw.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get(),
267
+ table,
268
+ ).toEqual({ n: 1 });
269
+ }
270
+ db.close();
271
+ });
272
+ });
273
+
274
+ describe("tenant isolation on project-scoped keys", () => {
275
+ it("/v1/identify keeps one profile per project for the same userId", async () => {
276
+ const db = createMigratedSqliteDb();
277
+ const app = identifyApp();
278
+ const userId = "ada@example.com";
279
+
280
+ const alpha = await identify(app, db, "alpha", {
281
+ userId,
282
+ visitorId: "vis-alpha",
283
+ email: userId,
284
+ name: "Ada (alpha)",
285
+ properties: { plan: "alpha-plan" },
286
+ });
287
+ expect(alpha.status).toBe(200);
288
+ const beta = await identify(app, db, "beta", {
289
+ userId,
290
+ visitorId: "vis-beta",
291
+ name: "Ada (beta)",
292
+ });
293
+ expect(beta.status).toBe(200);
294
+
295
+ const rows = await db
296
+ .prepare(
297
+ `SELECT project_id, visitor_id, email, name, properties_json
298
+ FROM user_profiles WHERE user_id = ? ORDER BY project_id`,
299
+ )
300
+ .bind(userId)
301
+ .all();
302
+ expect(rows.results).toEqual([
303
+ {
304
+ project_id: "alpha",
305
+ visitor_id: "vis-alpha",
306
+ email: userId,
307
+ name: "Ada (alpha)",
308
+ properties_json: JSON.stringify({ plan: "alpha-plan" }),
309
+ },
310
+ {
311
+ project_id: "beta",
312
+ visitor_id: "vis-beta",
313
+ email: null,
314
+ name: "Ada (beta)",
315
+ properties_json: null,
316
+ },
317
+ ]);
318
+
319
+ // A repeat identify from beta updates beta only.
320
+ await identify(app, db, "beta", { userId, visitorId: "vis-beta-2" });
321
+ const visitors = await db
322
+ .prepare(
323
+ `SELECT project_id, visitor_id FROM user_profiles WHERE user_id = ? ORDER BY project_id`,
324
+ )
325
+ .bind(userId)
326
+ .all();
327
+ expect(visitors.results).toEqual([
328
+ { project_id: "alpha", visitor_id: "vis-alpha" },
329
+ { project_id: "beta", visitor_id: "vis-beta-2" },
330
+ ]);
331
+
332
+ // The identity index resolves the visitor inside the caller's project.
333
+ const alphaCalls = db.raw.prepare(
334
+ `INSERT INTO usage_events (project_id, event_id, session_id, visitor_id, event_type, event_name, context_json, properties_json, occurred_at, received_at, expires_at)
335
+ VALUES (?, ?, ?, ?, 'page_view', 'page_view', '{}', '{}', ?, ?, ?)`,
336
+ );
337
+ alphaCalls.run("alpha", "evt-a", "sess-a", "vis-alpha", NOW, NOW, LATER);
338
+ alphaCalls.run("beta", "evt-b", "sess-b", "vis-beta-2", NOW, NOW, LATER);
339
+ const alphaManifest = await manifestByUser(db, "alpha", userId);
340
+ const betaManifest = await manifestByUser(db, "beta", userId);
341
+ expect(alphaManifest.usageEvents.map((e) => e.sessionId)).toEqual([
342
+ "sess-a",
343
+ ]);
344
+ expect(betaManifest.usageEvents.map((e) => e.sessionId)).toEqual([
345
+ "sess-b",
346
+ ]);
347
+ db.close();
348
+ });
349
+
350
+ it("ActionStore keeps the same action id separate per project", async () => {
351
+ const db = createMigratedSqliteDb();
352
+ const store = new ActionStore(db);
353
+ await store.upsertAction(
354
+ actionRecord({ id: "01SHARED", projectId: "alpha", name: "alpha run" }),
355
+ );
356
+ await store.upsertAction(
357
+ actionRecord({ id: "01SHARED", projectId: "beta", name: "beta run" }),
358
+ );
359
+ // A second write from beta must not reach alpha.
360
+ await store.upsertAction(
361
+ actionRecord({ id: "01SHARED", projectId: "beta", name: "beta run v2" }),
362
+ );
363
+
364
+ expect((await store.getAction("alpha", "01SHARED"))?.name).toBe(
365
+ "alpha run",
366
+ );
367
+ expect((await store.getAction("beta", "01SHARED"))?.name).toBe(
368
+ "beta run v2",
369
+ );
370
+ const count = await db
371
+ .prepare(`SELECT COUNT(*) AS n FROM actions WHERE id = ?`)
372
+ .bind("01SHARED")
373
+ .first<{ n: number }>();
374
+ expect(count?.n).toBe(2);
375
+ db.close();
376
+ });
377
+
378
+ it("AI span payloads with identical trace/span ids stay with their project", async () => {
379
+ const db = createMigratedSqliteDb();
380
+ await runPayloadProcessor(db, [aiSpan("alpha", "alpha prompt")]);
381
+ await runPayloadProcessor(db, [aiSpan("beta", "beta prompt")]);
382
+ await runPayloadProcessor(db, [aiSpan("beta", "beta prompt v2")]);
383
+
384
+ const rows = await db
385
+ .prepare(
386
+ `SELECT project_id, input_json FROM ai_span_payloads
387
+ WHERE trace_id = ? AND span_id = ? ORDER BY project_id`,
388
+ )
389
+ .bind("trace-shared", "span-shared")
390
+ .all<{ project_id: string; input_json: string }>();
391
+ expect(rows.results).toEqual([
392
+ { project_id: "alpha", input_json: "alpha prompt" },
393
+ { project_id: "beta", input_json: "beta prompt v2" },
394
+ ]);
395
+ db.close();
396
+ });
397
+
398
+ it("span ingest keeps colliding ids per project and the AI span join stays in-project", async () => {
399
+ const db = createMigratedSqliteDb();
400
+ const alphaSpan = span({ projectId: "alpha", spanName: "alpha-llm" });
401
+ const betaSpan = span({ projectId: "beta", spanName: "beta-llm" });
402
+ await ingestTelemetrySpans(db, [alphaSpan]);
403
+ // Previously INSERT OR IGNORE dropped this row: same (trace_id, span_id).
404
+ await ingestTelemetrySpans(db, [betaSpan]);
405
+
406
+ const names = await db
407
+ .prepare(
408
+ `SELECT project_id, span_name FROM telemetry_spans
409
+ WHERE trace_id = ? AND span_id = ? ORDER BY project_id`,
410
+ )
411
+ .bind("trace-shared", "span-shared")
412
+ .all();
413
+ expect(names.results).toEqual([
414
+ { project_id: "alpha", span_name: "alpha-llm" },
415
+ { project_id: "beta", span_name: "beta-llm" },
416
+ ]);
417
+
418
+ // Only alpha has a payload row; beta's span must not be joined to it.
419
+ await runPayloadProcessor(db, [aiSpan("alpha", "alpha prompt")]);
420
+ const alpha = await getAISpansOverview(db, {
421
+ projectId: "alpha",
422
+ hours: 24,
423
+ });
424
+ const beta = await getAISpansOverview(db, {
425
+ projectId: "beta",
426
+ hours: 24,
427
+ });
428
+ expect(alpha.spans.map((s) => s.spanName)).toEqual(["alpha-llm"]);
429
+ expect(beta.spans).toEqual([]);
430
+ db.close();
431
+ });
432
+ });
@@ -10,10 +10,10 @@ CREATE INDEX IF NOT EXISTS idx_projects_slug ON projects (slug);
10
10
  CREATE TABLE IF NOT EXISTS ingest_keys (
11
11
  id TEXT PRIMARY KEY,
12
12
  project_id TEXT NOT NULL,
13
- -- D1 used TEXT for the bcrypt hash. Postgres stores it as BYTEA so
14
- -- the column-comparison code in ingest-auth.ts (constant-time
15
- -- compare via bytes) doesn't pay a UTF-8 decode cost on every check.
16
- key_hash BYTEA NOT NULL UNIQUE,
13
+ -- SHA-256 hex digest of the plaintext key, exactly as D1 stores it.
14
+ -- ProjectsStore binds the hex string, so the column must be TEXT; an
15
+ -- earlier BYTEA version is converted by 041_ingest_key_hash_text.sql.
16
+ key_hash TEXT NOT NULL UNIQUE,
17
17
  key_prefix TEXT NOT NULL,
18
18
  name TEXT NOT NULL,
19
19
  created_at TEXT NOT NULL,
@@ -6,7 +6,7 @@ INSERT INTO ingest_keys (id, project_id, key_hash, key_prefix, name, created_at)
6
6
  VALUES (
7
7
  'obs-dashboard-bootstrap',
8
8
  'obs-dashboard',
9
- decode('6aa18dc1261b312e98b236d5a7dae698d1a90ecbe33d8024744063a066a35b9b', 'hex'),
9
+ '6aa18dc1261b312e98b236d5a7dae698d1a90ecbe33d8024744063a066a35b9b',
10
10
  'obs_dashboard_65',
11
11
  'bootstrap',
12
12
  now()::text
@@ -15,8 +15,11 @@ CREATE TABLE IF NOT EXISTS metric_exemplars (
15
15
  span_id TEXT,
16
16
  ts_ns TEXT NOT NULL,
17
17
  value DOUBLE PRECISION NOT NULL,
18
- received_at TIMESTAMPTZ NOT NULL,
19
- expires_at TIMESTAMPTZ NOT NULL
18
+ -- TEXT ISO timestamps like every other retention table; see
19
+ -- 043_metric_exemplars_text_timestamps.sql for databases that were
20
+ -- created with the earlier TIMESTAMPTZ version of this file.
21
+ received_at TEXT NOT NULL,
22
+ expires_at TEXT NOT NULL
20
23
  );
21
24
 
22
25
  CREATE INDEX IF NOT EXISTS idx_metric_exemplars_project_trace
@@ -0,0 +1,32 @@
1
+ -- Convert ingest_keys.key_hash from BYTEA to TEXT on databases migrated
2
+ -- before 009 was corrected.
3
+ --
4
+ -- ProjectsStore binds the SHA-256 digest as a hex string. Against a BYTEA
5
+ -- column Postgres parsed that string as escape-format bytes, so:
6
+ -- * keys created by the collector (bootstrap INGEST_KEY, minted keys)
7
+ -- were stored as the 64 ASCII bytes of the hex text;
8
+ -- * the obs-dashboard seed in 026 used decode(..., 'hex') and stored the
9
+ -- 32 raw digest bytes, which never matched a lookup.
10
+ -- Both shapes are mapped back to the 64-char lowercase hex digest here.
11
+ -- Fresh installs already create the column as TEXT and this is a no-op.
12
+ DO $$
13
+ BEGIN
14
+ IF EXISTS (
15
+ SELECT 1
16
+ FROM information_schema.columns
17
+ WHERE table_schema = current_schema()
18
+ AND table_name = 'ingest_keys'
19
+ AND column_name = 'key_hash'
20
+ AND data_type = 'bytea'
21
+ ) THEN
22
+ ALTER TABLE ingest_keys
23
+ ALTER COLUMN key_hash TYPE TEXT
24
+ USING (
25
+ CASE
26
+ WHEN octet_length(key_hash) = 32 THEN encode(key_hash, 'hex')
27
+ ELSE convert_from(key_hash, 'UTF8')
28
+ END
29
+ );
30
+ END IF;
31
+ END
32
+ $$;
@@ -0,0 +1,16 @@
1
+ -- Migration 042: session replay composite primary key (project_id, session_id)
2
+ --
3
+ -- Fixes cross-project session replay metadata collisions. Previously,
4
+ -- session_replay_metadata used session_id alone as primary key while
5
+ -- project_id was added in migration 010. Replay receiver upserts used
6
+ -- ON CONFLICT(session_id), causing identical session IDs from different
7
+ -- projects to overwrite or increment each other.
8
+ --
9
+ -- This forward migration drops the existing single-column primary key constraint
10
+ -- and establishes PRIMARY KEY (project_id, session_id) while preserving all
11
+ -- existing rows and indexes.
12
+
13
+ UPDATE session_replay_metadata SET project_id = 'default' WHERE project_id IS NULL;
14
+ ALTER TABLE session_replay_metadata ALTER COLUMN project_id SET NOT NULL;
15
+ ALTER TABLE session_replay_metadata DROP CONSTRAINT IF EXISTS session_replay_metadata_pkey;
16
+ ALTER TABLE session_replay_metadata ADD PRIMARY KEY (project_id, session_id);
@@ -0,0 +1,47 @@
1
+ -- Convert metric_exemplars.received_at / expires_at from TIMESTAMPTZ to TEXT
2
+ -- on databases migrated with the original Postgres 035.
3
+ --
4
+ -- Every other retention table stores timestamps as TEXT holding
5
+ -- Date#toISOString() output, and postgresDialect renders now() as ISO text
6
+ -- (to_char(..., 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')). Against TIMESTAMPTZ
7
+ -- columns MetricsStore.purgeExpired failed with
8
+ -- operator does not exist: timestamp with time zone < text
9
+ -- so metric exemplars were never purged on Postgres.
10
+ --
11
+ -- Existing rows are preserved: each value is rendered in UTC in the exact
12
+ -- shape the collector writes (millisecond precision, trailing "Z"), so the
13
+ -- converted text compares correctly against freshly ingested rows and the
14
+ -- dialect's now(). idx_metric_exemplars_expires is rebuilt by Postgres as
15
+ -- part of the type change.
16
+ --
17
+ -- Fresh installs already create both columns as TEXT in 035 and this is a
18
+ -- no-op. Re-running is also a no-op.
19
+ DO $$
20
+ BEGIN
21
+ IF EXISTS (
22
+ SELECT 1
23
+ FROM information_schema.columns
24
+ WHERE table_schema = current_schema()
25
+ AND table_name = 'metric_exemplars'
26
+ AND column_name = 'received_at'
27
+ AND data_type = 'timestamp with time zone'
28
+ ) THEN
29
+ ALTER TABLE metric_exemplars
30
+ ALTER COLUMN received_at TYPE TEXT
31
+ USING to_char(received_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"');
32
+ END IF;
33
+
34
+ IF EXISTS (
35
+ SELECT 1
36
+ FROM information_schema.columns
37
+ WHERE table_schema = current_schema()
38
+ AND table_name = 'metric_exemplars'
39
+ AND column_name = 'expires_at'
40
+ AND data_type = 'timestamp with time zone'
41
+ ) THEN
42
+ ALTER TABLE metric_exemplars
43
+ ALTER COLUMN expires_at TYPE TEXT
44
+ USING to_char(expires_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"');
45
+ END IF;
46
+ END
47
+ $$;
@@ -0,0 +1,8 @@
1
+ -- RFC 0010 — action graph retention.
2
+ --
3
+ -- Mirrors ../migrations/044_actions_started_at_index.sql. `started_at` is
4
+ -- ISO-8601 TEXT on both backends, so the retention sweep compares it against
5
+ -- a text cutoff rendered the same way `postgresDialect.sinceHours` does.
6
+
7
+ CREATE INDEX IF NOT EXISTS idx_actions_started_at
8
+ ON actions (started_at);
@@ -0,0 +1,33 @@
1
+ -- Migration 045: user_profiles composite primary key (project_id, user_id)
2
+ --
3
+ -- Mirrors ../migrations/045_user_profiles_project_composite_pk.sql. The 006
4
+ -- table used `user_id TEXT PRIMARY KEY`; 010 added project_id outside the
5
+ -- key, so /v1/identify's ON CONFLICT(user_id) upsert let one project rewrite
6
+ -- another project's profile. Swaps the key for (project_id, user_id) in
7
+ -- place, keeping every row and index. The old key was unique, so the
8
+ -- composite key cannot collide.
9
+ --
10
+ -- Idempotent: the block looks up the current primary key by definition and
11
+ -- does nothing when it is already (project_id, user_id).
12
+
13
+ UPDATE user_profiles SET project_id = 'default' WHERE project_id IS NULL;
14
+ ALTER TABLE user_profiles ALTER COLUMN project_id SET NOT NULL;
15
+
16
+ DO $$
17
+ DECLARE
18
+ pk_name TEXT;
19
+ pk_def TEXT;
20
+ BEGIN
21
+ SELECT c.conname, pg_get_constraintdef(c.oid)
22
+ INTO pk_name, pk_def
23
+ FROM pg_constraint c
24
+ WHERE c.conrelid = 'user_profiles'::regclass AND c.contype = 'p';
25
+
26
+ IF pk_def IS DISTINCT FROM 'PRIMARY KEY (project_id, user_id)' THEN
27
+ IF pk_name IS NOT NULL THEN
28
+ EXECUTE format('ALTER TABLE user_profiles DROP CONSTRAINT %I', pk_name);
29
+ END IF;
30
+ ALTER TABLE user_profiles ADD PRIMARY KEY (project_id, user_id);
31
+ END IF;
32
+ END
33
+ $$;
@@ -0,0 +1,39 @@
1
+ -- Migration 046: action graph tables keyed by (project_id, id)
2
+ --
3
+ -- Mirrors ../migrations/046_action_graph_project_composite_pk.sql. The 031
4
+ -- spine and leaf tables used `id TEXT PRIMARY KEY`; action ids are
5
+ -- client-supplied and leaf ids are raw OTel span ids, so two projects can
6
+ -- emit the same id and the ON CONFLICT(id) upserts let one tenant overwrite
7
+ -- another's row. Swaps each key for (project_id, id) in place, keeping every
8
+ -- row and index (031, 038, 044). The old keys were unique, so the composite
9
+ -- keys cannot collide.
10
+ --
11
+ -- Idempotent: each table is skipped when its primary key is already
12
+ -- (project_id, id).
13
+
14
+ DO $$
15
+ DECLARE
16
+ t TEXT;
17
+ pk_name TEXT;
18
+ pk_def TEXT;
19
+ BEGIN
20
+ FOREACH t IN ARRAY ARRAY[
21
+ 'actions', 'agent_runs', 'tool_calls', 'retrieval_events',
22
+ 'eval_results', 'artifacts'
23
+ ] LOOP
24
+ pk_name := NULL;
25
+ pk_def := NULL;
26
+ SELECT c.conname, pg_get_constraintdef(c.oid)
27
+ INTO pk_name, pk_def
28
+ FROM pg_constraint c
29
+ WHERE c.conrelid = t::regclass AND c.contype = 'p';
30
+
31
+ IF pk_def IS DISTINCT FROM 'PRIMARY KEY (project_id, id)' THEN
32
+ IF pk_name IS NOT NULL THEN
33
+ EXECUTE format('ALTER TABLE %I DROP CONSTRAINT %I', t, pk_name);
34
+ END IF;
35
+ EXECUTE format('ALTER TABLE %I ADD PRIMARY KEY (project_id, id)', t);
36
+ END IF;
37
+ END LOOP;
38
+ END
39
+ $$;
@@ -0,0 +1,30 @@
1
+ -- Migration 047: ai_span_payloads composite primary key
2
+ -- (project_id, trace_id, span_id)
3
+ --
4
+ -- Mirrors ../migrations/047_ai_span_payloads_project_composite_pk.sql. 019
5
+ -- keyed the payload side table by (trace_id, span_id) only; the processor's
6
+ -- ON CONFLICT(trace_id, span_id) upsert let a second project replace the
7
+ -- first project's prompt/completion payloads. Swaps the key for
8
+ -- (project_id, trace_id, span_id) in place, keeping every row and index.
9
+ -- The old key was unique, so the composite key cannot collide.
10
+ --
11
+ -- Idempotent: does nothing when the key is already composite.
12
+
13
+ DO $$
14
+ DECLARE
15
+ pk_name TEXT;
16
+ pk_def TEXT;
17
+ BEGIN
18
+ SELECT c.conname, pg_get_constraintdef(c.oid)
19
+ INTO pk_name, pk_def
20
+ FROM pg_constraint c
21
+ WHERE c.conrelid = 'ai_span_payloads'::regclass AND c.contype = 'p';
22
+
23
+ IF pk_def IS DISTINCT FROM 'PRIMARY KEY (project_id, trace_id, span_id)' THEN
24
+ IF pk_name IS NOT NULL THEN
25
+ EXECUTE format('ALTER TABLE ai_span_payloads DROP CONSTRAINT %I', pk_name);
26
+ END IF;
27
+ ALTER TABLE ai_span_payloads ADD PRIMARY KEY (project_id, trace_id, span_id);
28
+ END IF;
29
+ END
30
+ $$;