@obsunified/collector 2.0.1 → 2.2.0

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 (126) 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/{chunk-XKQTVIRE.js → chunk-ZSF5XP3B.js} +8 -2
  6. package/dist/chunk-ZSF5XP3B.js.map +1 -0
  7. package/dist/index.d.ts +630 -27
  8. package/dist/index.js +5819 -853
  9. package/dist/index.js.map +1 -1
  10. package/dist/{ingest-auth-HFVO1zIp.d.ts → ingest-auth-C5-8Mly5.d.ts} +9 -0
  11. package/dist/{openai-FJYOQ7WO.js → openai-GSJZGFGL.js} +2 -2
  12. package/package.json +5 -5
  13. package/src/__tests__/autosre-closed-loop.test.ts +1275 -0
  14. package/src/analyses/derive.ts +26 -25
  15. package/src/analyses/investigations.ts +8 -8
  16. package/src/analyses/tier0.ts +22 -22
  17. package/src/auth/dashboard-auth.test.ts +791 -0
  18. package/src/auth/dashboard-auth.ts +187 -38
  19. package/src/auth/login-rate-limit.test.ts +129 -0
  20. package/src/auth/login-rate-limit.ts +143 -0
  21. package/src/framework/body-limit.test.ts +165 -0
  22. package/src/framework/collector.ts +355 -115
  23. package/src/framework/env.ts +9 -0
  24. package/src/framework/limits.ts +31 -0
  25. package/src/framework/scheduled-handlers.test.ts +342 -0
  26. package/src/index.ts +59 -1
  27. package/src/lib/action-retention.test.ts +472 -0
  28. package/src/lib/action-retention.ts +420 -0
  29. package/src/lib/actions-store.ts +6 -12
  30. package/src/lib/ai-store/calls.ts +2 -2
  31. package/src/lib/ai-store/sessions.ts +3 -3
  32. package/src/lib/ai-store/spans.ts +1 -1
  33. package/src/lib/ai-store/types.ts +2 -1
  34. package/src/lib/alerts-store.test.ts +153 -0
  35. package/src/lib/alerts-store.ts +122 -21
  36. package/src/lib/analyses-runner.test.ts +75 -0
  37. package/src/lib/analyses-runner.ts +69 -3
  38. package/src/lib/analyses-store-latest.test.ts +61 -0
  39. package/src/lib/analyses-store.ts +17 -10
  40. package/src/lib/autosre-change-correlation.ts +780 -0
  41. package/src/lib/autosre-findings.ts +725 -0
  42. package/src/lib/autosre-ontology.ts +914 -0
  43. package/src/lib/autosre-remediation-gate.ts +1663 -0
  44. package/src/lib/blob-store.ts +1 -1
  45. package/src/lib/eval-cases-store.ts +63 -1
  46. package/src/lib/evidence-references.ts +6 -0
  47. package/src/lib/housekeeping.test.ts +153 -0
  48. package/src/lib/housekeeping.ts +193 -0
  49. package/src/lib/identity-index/action-lookups.ts +6 -6
  50. package/src/lib/identity-index/key-lookups.ts +2 -2
  51. package/src/lib/identity-index/user-lookup.ts +8 -5
  52. package/src/lib/identity-index.test.ts +2 -3
  53. package/src/lib/metrics-store-series.test.ts +99 -0
  54. package/src/lib/metrics-store.test.ts +23 -0
  55. package/src/lib/metrics-store.ts +27 -3
  56. package/src/lib/projects-store.test.ts +25 -0
  57. package/src/lib/projects-store.ts +4 -3
  58. package/src/lib/sql-db-dialect.test.ts +12 -3
  59. package/src/lib/sql-db-postgres.integration.test.ts +1225 -0
  60. package/src/lib/sql-db-postgres.test.ts +314 -0
  61. package/src/lib/sql-db-postgres.ts +392 -54
  62. package/src/lib/sql-db-sqlite-time-windows.test.ts +194 -0
  63. package/src/lib/sql-db.ts +40 -6
  64. package/src/lib/store/ingest.ts +94 -6
  65. package/src/lib/test-utils/action-graph-fixtures.ts +307 -0
  66. package/src/lib/test-utils/node-sqlite-db.ts +188 -0
  67. package/src/lib/webhook-url.test.ts +122 -0
  68. package/src/lib/webhook-url.ts +155 -0
  69. package/src/migrations/041_ingest_key_hash_text.sql +5 -0
  70. package/src/migrations/042_session_replay_project_composite_pk.sql +52 -0
  71. package/src/migrations/043_metric_exemplars_text_timestamps.sql +5 -0
  72. package/src/migrations/044_actions_started_at_index.sql +10 -0
  73. package/src/migrations/045_user_profiles_project_composite_pk.sql +40 -0
  74. package/src/migrations/046_action_graph_project_composite_pk.sql +255 -0
  75. package/src/migrations/047_ai_span_payloads_project_composite_pk.sql +61 -0
  76. package/src/migrations/048_telemetry_spans_project_composite_pk.sql +92 -0
  77. package/src/migrations/049_alert_state_notified.sql +17 -0
  78. package/src/migrations/050_autosre_causal_ontology.sql +216 -0
  79. package/src/migrations/alert-state-notified.test.ts +111 -0
  80. package/src/migrations/parity.test.ts +68 -0
  81. package/src/migrations/project-scoped-keys.test.ts +432 -0
  82. package/src/migrations-postgres/009_projects_and_keys.sql +4 -4
  83. package/src/migrations-postgres/026_obs_dashboard_project_seed.sql +1 -1
  84. package/src/migrations-postgres/035_metric_exemplars.sql +5 -2
  85. package/src/migrations-postgres/041_ingest_key_hash_text.sql +32 -0
  86. package/src/migrations-postgres/042_session_replay_project_composite_pk.sql +16 -0
  87. package/src/migrations-postgres/043_metric_exemplars_text_timestamps.sql +47 -0
  88. package/src/migrations-postgres/044_actions_started_at_index.sql +8 -0
  89. package/src/migrations-postgres/045_user_profiles_project_composite_pk.sql +33 -0
  90. package/src/migrations-postgres/046_action_graph_project_composite_pk.sql +39 -0
  91. package/src/migrations-postgres/047_ai_span_payloads_project_composite_pk.sql +30 -0
  92. package/src/migrations-postgres/048_telemetry_spans_project_composite_pk.sql +37 -0
  93. package/src/migrations-postgres/049_alert_state_notified.sql +10 -0
  94. package/src/migrations-postgres/050_autosre_causal_ontology.sql +216 -0
  95. package/src/migrations-postgres/README.md +58 -5
  96. package/src/otlp/decode/body.test.ts +76 -0
  97. package/src/otlp/decode/body.ts +67 -6
  98. package/src/plugins/_json.ts +21 -0
  99. package/src/plugins/action-graph-processor.ts +6 -12
  100. package/src/plugins/ai-receiver.ts +5 -12
  101. package/src/plugins/ai-span-payloads-processor.ts +1 -2
  102. package/src/plugins/alerts-evaluator-retention.test.ts +170 -0
  103. package/src/plugins/alerts-evaluator.test.ts +464 -3
  104. package/src/plugins/alerts-evaluator.ts +181 -41
  105. package/src/plugins/alerts-routes.ts +10 -6
  106. package/src/plugins/ask-routes.ts +4 -5
  107. package/src/plugins/autosre-routes.ts +1019 -0
  108. package/src/plugins/connected-routes/manifest.ts +13 -1
  109. package/src/plugins/connected-routes.test.ts +106 -0
  110. package/src/plugins/connected-routes.ts +285 -5
  111. package/src/plugins/dashboard-routes.test.ts +120 -0
  112. package/src/plugins/dashboard-routes.ts +11 -2
  113. package/src/plugins/default-span-enrichment.ts +13 -1
  114. package/src/plugins/eval-cases-routes.ts +65 -19
  115. package/src/plugins/evidence-retrieval-routes.ts +300 -4
  116. package/src/plugins/identity-receiver.ts +9 -11
  117. package/src/plugins/json-body.test.ts +91 -0
  118. package/src/plugins/onboarding-routes.test.ts +105 -0
  119. package/src/plugins/onboarding-routes.ts +26 -11
  120. package/src/plugins/projects-routes.ts +2 -1
  121. package/src/plugins/replay-isolation.test.ts +922 -0
  122. package/src/plugins/replay-receiver.ts +8 -8
  123. package/src/plugins/usage-receiver.ts +3 -6
  124. package/dist/chunk-UGWDOGGH.js.map +0 -1
  125. package/dist/chunk-XKQTVIRE.js.map +0 -1
  126. /package/dist/{openai-FJYOQ7WO.js.map → openai-GSJZGFGL.js.map} +0 -0
@@ -0,0 +1,1225 @@
1
+ /**
2
+ * Live-Postgres integration checks for the standalone Node collector path
3
+ * (RFC 0008 `PostgresAdapter`). Skipped unless `OBS_PG_TEST_URL` is set:
4
+ *
5
+ * OBS_PG_TEST_URL=postgres://obs:obs@127.0.0.1:5432/obs_unified \
6
+ * pnpm --filter @obsunified/collector exec vitest run \
7
+ * src/lib/sql-db-postgres.integration.test.ts
8
+ *
9
+ * Point it at an EMPTY scratch database: the run applies
10
+ * `migrations-postgres/` into the connection's default schema, refuses to
11
+ * start if the schema already contains any table, and drops every table it
12
+ * created afterwards. (Session state such as `search_path` is deliberately not
13
+ * relied upon so the suite also runs against single-session servers like
14
+ * PGlite's socket bridge.)
15
+ *
16
+ * Each test pins a Postgres-only failure that D1 never surfaced: BYTEA vs
17
+ * hex `key_hash`, BOOLEAN flags, TEXT timestamps compared to
18
+ * `timestamptz` (including the `metric_exemplars` TIMESTAMPTZ columns that
19
+ * 043 converts), and SQLite built-ins in the shared analysis SQL.
20
+ */
21
+
22
+ import { readdir, readFile } from "node:fs/promises";
23
+ import path from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+ import type {
26
+ AICallRecord,
27
+ AlertRule,
28
+ StoredSpan,
29
+ UsageEventRecord,
30
+ } from "@obsunified/types";
31
+ import type { MiddlewareHandler } from "hono";
32
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
33
+ import { deriveAnalysesForProject } from "../analyses/derive";
34
+ import { INVESTIGATION_ANALYSES } from "../analyses/investigations";
35
+ import { TIER0_ANALYSES } from "../analyses/tier0";
36
+ import {
37
+ type CollectorRuntime,
38
+ createTelemetryCollectorApp,
39
+ type SpanProcessorPlugin,
40
+ } from "../framework/collector";
41
+ import type { CollectorEnv, CollectorRouteContext } from "../framework/env";
42
+ import type { DecodedMetricPoint } from "../otlp/decode";
43
+ import { aiSpanPayloadsProcessorPlugin } from "../plugins/ai-span-payloads-processor";
44
+ import { identityReceiverPlugin } from "../plugins/identity-receiver";
45
+ import { purgeExpiredActionGraph } from "./action-retention";
46
+ import { ActionStore } from "./actions-store";
47
+ import { getAICallsOverview, ingestAICallBatch } from "./ai-store/calls";
48
+ import { purgeExpiredAIData } from "./ai-store/retention";
49
+ import { getAISpansOverview } from "./ai-store/spans";
50
+ import { AlertsStore } from "./alerts-store";
51
+ import { runSqlAnalysis } from "./analyses-runner";
52
+ import { sha256Hex } from "./hash";
53
+ import { MetricsStore } from "./metrics-store";
54
+ import { ProjectsStore } from "./projects-store";
55
+ import {
56
+ configurePostgresTypeParsers,
57
+ PostgresAdapter,
58
+ } from "./sql-db-postgres";
59
+ import { ingestTelemetrySpans } from "./store/ingest";
60
+ import {
61
+ actionRecord,
62
+ agentRunRecord,
63
+ countActionGraph,
64
+ FIXTURE_NOW_MS,
65
+ FIXTURE_RETENTION_HOURS,
66
+ FRESH_START,
67
+ seedEvalCase,
68
+ seedTree,
69
+ } from "./test-utils/action-graph-fixtures";
70
+ import { UsageStore } from "./usage-store";
71
+
72
+ const databaseUrl = process.env.OBS_PG_TEST_URL;
73
+
74
+ const here = path.dirname(fileURLToPath(import.meta.url));
75
+ const migrationsDir = path.resolve(here, "../migrations-postgres");
76
+
77
+ /** Plaintext lives in apps/web/.env.development.local; this is its SHA-256. */
78
+ const DASHBOARD_SEED_HASH =
79
+ "6aa18dc1261b312e98b236d5a7dae698d1a90ecbe33d8024744063a066a35b9b";
80
+
81
+ const VALID_STATUSES = new Set(["ok", "warn", "critical", "unknown"]);
82
+
83
+ type PgModule = typeof import("pg");
84
+ type PgPool = import("pg").Pool;
85
+ type PgClient = import("pg").Client;
86
+
87
+ const isoNow = () => new Date().toISOString();
88
+ const isoIn = (ms: number) => new Date(Date.now() + ms).toISOString();
89
+
90
+ const aiCall = (over: Partial<AICallRecord>): AICallRecord => ({
91
+ projectId: "default",
92
+ callId: `call-${Math.random().toString(36).slice(2)}`,
93
+ traceId: null,
94
+ spanId: null,
95
+ serviceName: "checkout",
96
+ modelName: "gpt-test",
97
+ provider: "openai",
98
+ callType: "chat",
99
+ requestJson: JSON.stringify({ messages: [] }),
100
+ responseJson: null,
101
+ promptTokens: 10,
102
+ completionTokens: 5,
103
+ totalCostUsd: 0.01,
104
+ latencyMs: 120,
105
+ isError: false,
106
+ errorMessage: null,
107
+ occurredAt: isoNow(),
108
+ receivedAt: isoNow(),
109
+ expiresAt: isoIn(60 * 60 * 1000),
110
+ ...over,
111
+ });
112
+
113
+ const usageEvent = (over: Partial<UsageEventRecord>): UsageEventRecord => ({
114
+ projectId: "default",
115
+ eventId: `evt-${Math.random().toString(36).slice(2)}`,
116
+ sessionId: "sess-1",
117
+ visitorId: "visitor-1",
118
+ eventType: "page_view",
119
+ eventName: "page_view",
120
+ pagePath: "/",
121
+ pageTitle: "Home",
122
+ referrer: null,
123
+ severity: "info",
124
+ source: "web",
125
+ contextJson: JSON.stringify({ viewport: "1280x720" }),
126
+ propertiesJson: JSON.stringify({ loadTimeMs: 250 }),
127
+ userAgent: "Mozilla/5.0",
128
+ occurredAt: isoNow(),
129
+ receivedAt: isoNow(),
130
+ expiresAt: isoIn(60 * 60 * 1000),
131
+ country: "US",
132
+ browser: "Chrome",
133
+ os: "macOS",
134
+ deviceType: "desktop",
135
+ isBot: false,
136
+ utmSource: null,
137
+ utmMedium: null,
138
+ utmCampaign: null,
139
+ ...over,
140
+ });
141
+
142
+ const metricPoint = (
143
+ over: Partial<DecodedMetricPoint>,
144
+ ): DecodedMetricPoint => ({
145
+ name: "http.server.duration",
146
+ description: null,
147
+ unit: "ms",
148
+ type: "histogram",
149
+ isMonotonic: null,
150
+ temporality: 2,
151
+ scopeName: "@opentelemetry/instrumentation-http",
152
+ scopeVersion: "1.0.0",
153
+ serviceName: "checkout",
154
+ resourceAttrsJson: JSON.stringify({ "service.name": "checkout" }),
155
+ attributesJson: JSON.stringify({ route: "/checkout" }),
156
+ identity: `metric-${Math.random().toString(36).slice(2)}`,
157
+ tsNs: "1800000000000000000",
158
+ startTsNs: null,
159
+ value: null,
160
+ count: 1,
161
+ sum: 42,
162
+ min: null,
163
+ max: null,
164
+ boundsJson: JSON.stringify([50]),
165
+ bucketCountsJson: JSON.stringify([1]),
166
+ extraJson: null,
167
+ exemplarsJson: null,
168
+ ...over,
169
+ });
170
+
171
+ const exemplarJson = (traceId: string, tsNs: string) =>
172
+ JSON.stringify([{ value: 1, traceId, spanId: null, tsNs }]);
173
+
174
+ /** Same `to_char` shape `postgresDialect.now()` and 043 render. */
175
+ const ISO_TEXT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
176
+
177
+ describe.skipIf(!databaseUrl)("PostgresAdapter against a live Postgres", () => {
178
+ const legacySchema = `obs_pg_test_legacy_${Date.now().toString(36)}`;
179
+ let pg: PgModule;
180
+ let pool: PgPool;
181
+ let db: PostgresAdapter;
182
+ let migrationFiles: string[] = [];
183
+ let preexistingTables = new Set<string>();
184
+
185
+ const adminClient = async (): Promise<PgClient> => {
186
+ const client = new pg.Client({ connectionString: databaseUrl });
187
+ await client.connect();
188
+ return client;
189
+ };
190
+
191
+ const listTables = async (client: PgClient): Promise<Set<string>> => {
192
+ const res = await client.query<{ table_name: string }>(
193
+ `SELECT table_name FROM information_schema.tables
194
+ WHERE table_schema = current_schema() AND table_type = 'BASE TABLE'`,
195
+ );
196
+ return new Set(res.rows.map((r) => r.table_name));
197
+ };
198
+
199
+ beforeAll(async () => {
200
+ pg = (await import("pg")).default as unknown as PgModule;
201
+ configurePostgresTypeParsers(pg.types);
202
+
203
+ const admin = await adminClient();
204
+ try {
205
+ preexistingTables = await listTables(admin);
206
+ if (preexistingTables.size > 0) {
207
+ // Refuse any non-empty schema, not just one with collector tables,
208
+ // so the teardown below can never touch an unrelated database.
209
+ throw new Error(
210
+ `OBS_PG_TEST_URL must point at an empty scratch database (found ${preexistingTables.size} table(s): ${[...preexistingTables].sort().join(", ")})`,
211
+ );
212
+ }
213
+ await admin.query(`CREATE SCHEMA ${legacySchema}`);
214
+
215
+ // Apply every Postgres migration the same way
216
+ // apps/collector-node/scripts/migrate-pg.mjs does (one transaction
217
+ // per file).
218
+ migrationFiles = (await readdir(migrationsDir))
219
+ .filter((f) => f.endsWith(".sql"))
220
+ .sort();
221
+ for (const file of migrationFiles) {
222
+ const sql = await readFile(path.join(migrationsDir, file), "utf8");
223
+ await admin.query("BEGIN");
224
+ try {
225
+ await admin.query(sql);
226
+ await admin.query("COMMIT");
227
+ } catch (err) {
228
+ await admin.query("ROLLBACK");
229
+ throw new Error(`${file} failed: ${(err as Error).message}`);
230
+ }
231
+ }
232
+ } finally {
233
+ await admin.end();
234
+ }
235
+
236
+ // max: 1 keeps the run deterministic on single-session servers
237
+ // (PGlite's socket bridge) and costs nothing against real Postgres.
238
+ pool = new pg.Pool({ connectionString: databaseUrl, max: 1 });
239
+ db = new PostgresAdapter(pool);
240
+ }, 120_000);
241
+
242
+ afterAll(async () => {
243
+ await pool?.end();
244
+ if (!pg) return;
245
+ const admin = await adminClient();
246
+ try {
247
+ await admin.query(`DROP SCHEMA IF EXISTS ${legacySchema} CASCADE`);
248
+ const created = [...(await listTables(admin))].filter(
249
+ (t) => !preexistingTables.has(t),
250
+ );
251
+ for (const table of created) {
252
+ await admin.query(`DROP TABLE IF EXISTS "${table}" CASCADE`);
253
+ }
254
+ } finally {
255
+ await admin.end();
256
+ }
257
+ });
258
+
259
+ it("applies every migration and leaves ingest_keys.key_hash as TEXT", async () => {
260
+ expect(migrationFiles).toContain("041_ingest_key_hash_text.sql");
261
+ const row = await db
262
+ .prepare(
263
+ `SELECT data_type FROM information_schema.columns
264
+ WHERE table_schema = current_schema() AND table_name = 'ingest_keys' AND column_name = 'key_hash'`,
265
+ )
266
+ .first<{ data_type: string }>();
267
+ expect(row?.data_type).toBe("text");
268
+ });
269
+
270
+ it("resolves the seeded obs-dashboard key by its hex digest", async () => {
271
+ const store = new ProjectsStore(db);
272
+ await expect(store.findByKeyHash(DASHBOARD_SEED_HASH)).resolves.toEqual({
273
+ projectId: "obs-dashboard",
274
+ keyId: "obs-dashboard-bootstrap",
275
+ });
276
+ });
277
+
278
+ it("bootstraps INGEST_KEY, mints and revokes keys through the hex digest", async () => {
279
+ const store = new ProjectsStore(db);
280
+ await store.bootstrapEnvKey("dev-ingest-key");
281
+ await store.bootstrapEnvKey("dev-ingest-key"); // idempotent
282
+ const bootstrapped = await store.findByKeyHash(
283
+ await sha256Hex("dev-ingest-key"),
284
+ );
285
+ expect(bootstrapped?.projectId).toBe("default");
286
+
287
+ const minted = await store.createKey("default", "ci");
288
+ const found = await store.findByKeyHash(await sha256Hex(minted.key));
289
+ expect(found).toEqual({ projectId: "default", keyId: minted.id });
290
+
291
+ expect(await store.revokeKey(minted.id, "other-project")).toBe(false);
292
+ expect(await store.revokeKey(minted.id, "default")).toBe(true);
293
+ expect(await store.findByKeyHash(await sha256Hex(minted.key))).toBeNull();
294
+
295
+ const listed = await store.listKeys("default");
296
+ expect(listed.map((k) => k.name).sort()).toEqual(["bootstrap", "ci"]);
297
+ });
298
+
299
+ it("migration 041 converts a legacy BYTEA key_hash column in place", async () => {
300
+ const legacy = await adminClient();
301
+ try {
302
+ await legacy.query(`SET search_path TO ${legacySchema}`);
303
+ // Shape of migrations-postgres/009 before the fix.
304
+ await legacy.query(`
305
+ CREATE TABLE projects (id TEXT PRIMARY KEY, name TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL);
306
+ CREATE TABLE ingest_keys (
307
+ id TEXT PRIMARY KEY, project_id TEXT NOT NULL, key_hash BYTEA NOT NULL UNIQUE,
308
+ key_prefix TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL, revoked_at TEXT,
309
+ FOREIGN KEY (project_id) REFERENCES projects (id));
310
+ CREATE INDEX idx_ingest_keys_hash ON ingest_keys (key_hash);
311
+ INSERT INTO projects VALUES ('default', 'Default', 'default', 'x'), ('obs-dashboard', 'Obs Dashboard', 'obs-dashboard', 'x');
312
+ `);
313
+ // 026 seeded the raw 32 digest bytes …
314
+ await legacy.query(
315
+ `INSERT INTO ingest_keys VALUES ('seed', 'obs-dashboard', decode($1, 'hex'), 'obs_dashboard_65', 'bootstrap', 'x', NULL)`,
316
+ [DASHBOARD_SEED_HASH],
317
+ );
318
+ // … while the collector bound the hex text, which BYTEA kept as 64 ASCII bytes.
319
+ const bootstrapHash = await sha256Hex("dev-ingest-key");
320
+ await legacy.query(
321
+ `INSERT INTO ingest_keys VALUES ('boot', 'default', $1, 'dev-ingest-key', 'bootstrap', 'x', NULL)`,
322
+ [bootstrapHash],
323
+ );
324
+ const before = await legacy.query(
325
+ `SELECT id, octet_length(key_hash) AS len FROM ingest_keys ORDER BY id`,
326
+ );
327
+ expect(before.rows).toEqual([
328
+ { id: "boot", len: 64 },
329
+ { id: "seed", len: 32 },
330
+ ]);
331
+
332
+ const sql = await readFile(
333
+ path.join(migrationsDir, "041_ingest_key_hash_text.sql"),
334
+ "utf8",
335
+ );
336
+ await legacy.query(sql);
337
+ await legacy.query(sql); // re-running is a no-op
338
+
339
+ const type = await legacy.query(
340
+ `SELECT data_type FROM information_schema.columns
341
+ WHERE table_schema = $1 AND table_name = 'ingest_keys' AND column_name = 'key_hash'`,
342
+ [legacySchema],
343
+ );
344
+ expect(type.rows[0]?.data_type).toBe("text");
345
+ const after = await legacy.query(
346
+ `SELECT id, key_hash FROM ingest_keys ORDER BY id`,
347
+ );
348
+ expect(after.rows).toEqual([
349
+ { id: "boot", key_hash: bootstrapHash },
350
+ { id: "seed", key_hash: DASHBOARD_SEED_HASH },
351
+ ]);
352
+ const lookup = await legacy.query(
353
+ `SELECT id FROM ingest_keys WHERE key_hash = $1`,
354
+ [DASHBOARD_SEED_HASH],
355
+ );
356
+ expect(lookup.rows).toEqual([{ id: "seed" }]);
357
+ } finally {
358
+ // Single-session servers (PGlite) share GUCs across sockets, so put
359
+ // search_path back before the pooled adapter runs again.
360
+ await legacy.query("RESET search_path").catch(() => {});
361
+ await legacy.end();
362
+ }
363
+ });
364
+
365
+ it("creates metric_exemplars timestamps as TEXT and purges expired exemplars", async () => {
366
+ expect(migrationFiles).toContain(
367
+ "043_metric_exemplars_text_timestamps.sql",
368
+ );
369
+ const cols = await db
370
+ .prepare(
371
+ `SELECT column_name, data_type FROM information_schema.columns
372
+ WHERE table_schema = current_schema() AND table_name = 'metric_exemplars'
373
+ AND column_name IN ('received_at', 'expires_at')
374
+ ORDER BY column_name`,
375
+ )
376
+ .all<{ column_name: string; data_type: string }>();
377
+ expect(cols.results).toEqual([
378
+ { column_name: "expires_at", data_type: "text" },
379
+ { column_name: "received_at", data_type: "text" },
380
+ ]);
381
+
382
+ const metrics = new MetricsStore(db);
383
+ await metrics.ingestBatch({
384
+ projectId: "default",
385
+ receivedAt: isoNow(),
386
+ expiresAt: isoIn(-60_000),
387
+ points: [
388
+ metricPoint({ exemplarsJson: exemplarJson("trace-ex-expired", "1") }),
389
+ ],
390
+ });
391
+ await metrics.ingestBatch({
392
+ projectId: "default",
393
+ receivedAt: isoNow(),
394
+ expiresAt: isoIn(3_600_000),
395
+ points: [
396
+ metricPoint({ exemplarsJson: exemplarJson("trace-ex-live", "2") }),
397
+ ],
398
+ });
399
+
400
+ // One expired exemplar + its expired point.
401
+ expect(await metrics.purgeExpired()).toBe(2);
402
+ const exemplars = await db
403
+ .prepare(`SELECT trace_id FROM metric_exemplars ORDER BY trace_id`)
404
+ .all<{ trace_id: string }>();
405
+ expect(exemplars.results).toEqual([{ trace_id: "trace-ex-live" }]);
406
+ const points = await db
407
+ .prepare(`SELECT COUNT(*) AS n FROM metric_point`)
408
+ .first<{ n: number }>();
409
+ expect(points?.n).toBe(1);
410
+ const live = await metrics.exemplarsForTrace("default", "trace-ex-live");
411
+ expect(live.map((e) => e.traceId)).toEqual(["trace-ex-live"]);
412
+ expect(live[0]?.receivedAt).toMatch(ISO_TEXT);
413
+ });
414
+
415
+ it("migration 043 converts legacy TIMESTAMPTZ metric_exemplars columns in place", async () => {
416
+ // max: 1 pins SET search_path / timezone to the one client the
417
+ // adapter will reuse, on PGlite and on real Postgres alike.
418
+ const legacyPool = new pg.Pool({ connectionString: databaseUrl, max: 1 });
419
+ try {
420
+ await legacyPool.query(`SET search_path TO ${legacySchema}`);
421
+ // A non-UTC session zone must not leak into the converted text.
422
+ await legacyPool.query(`SET timezone TO 'America/New_York'`);
423
+ // Shape of migrations-postgres/035 before the fix (metric_series /
424
+ // metric_point trimmed to the columns the FK and purge touch).
425
+ await legacyPool.query(`
426
+ CREATE TABLE metric_series (id TEXT PRIMARY KEY);
427
+ CREATE TABLE metric_point (id TEXT PRIMARY KEY, expires_at TEXT NOT NULL);
428
+ CREATE TABLE metric_exemplars (
429
+ id TEXT PRIMARY KEY,
430
+ point_id TEXT NOT NULL REFERENCES metric_point(id),
431
+ series_id TEXT NOT NULL REFERENCES metric_series(id),
432
+ project_id TEXT NOT NULL,
433
+ metric_name TEXT NOT NULL,
434
+ service_name TEXT,
435
+ trace_id TEXT,
436
+ span_id TEXT,
437
+ ts_ns TEXT NOT NULL,
438
+ value DOUBLE PRECISION NOT NULL,
439
+ received_at TIMESTAMPTZ NOT NULL,
440
+ expires_at TIMESTAMPTZ NOT NULL
441
+ );
442
+ CREATE INDEX idx_metric_exemplars_expires ON metric_exemplars (expires_at);
443
+ INSERT INTO metric_series VALUES ('s1');
444
+ INSERT INTO metric_point VALUES
445
+ ('p-expired', '2000-01-01T00:00:00.000Z'),
446
+ ('p-live', '2999-01-01T00:00:00.000Z');
447
+ `);
448
+ // The collector bound Date#toISOString() text, which Postgres
449
+ // parsed into timestamptz. Include a non-UTC offset to prove the
450
+ // conversion normalises to UTC rather than echoing input.
451
+ const insert = `INSERT INTO metric_exemplars
452
+ (id, point_id, series_id, project_id, metric_name, service_name, trace_id, span_id, ts_ns, value, received_at, expires_at)
453
+ VALUES ($1, $2, 's1', 'default', 'http.server.duration', 'checkout', $3, NULL, '1', 1, $4::timestamptz, $5::timestamptz)`;
454
+ await legacyPool.query(insert, [
455
+ "ex-expired",
456
+ "p-expired",
457
+ "trace-legacy-expired",
458
+ "2000-01-01T00:00:00.000Z",
459
+ "2000-01-02T00:00:00.000Z",
460
+ ]);
461
+ await legacyPool.query(insert, [
462
+ "ex-live",
463
+ "p-live",
464
+ "trace-legacy-live",
465
+ "2026-06-01T14:34:56.789+02:00",
466
+ "2999-01-01T00:00:00.000Z",
467
+ ]);
468
+ const before = await legacyPool.query(
469
+ `SELECT data_type FROM information_schema.columns
470
+ WHERE table_schema = $1 AND table_name = 'metric_exemplars' AND column_name = 'expires_at'`,
471
+ [legacySchema],
472
+ );
473
+ expect(before.rows[0]?.data_type).toBe("timestamp with time zone");
474
+
475
+ const legacyDb = new PostgresAdapter(legacyPool);
476
+ const metrics = new MetricsStore(legacyDb);
477
+ // The launch blocker: TEXT now() against TIMESTAMPTZ columns.
478
+ await expect(metrics.purgeExpired()).rejects.toThrow(
479
+ /operator does not exist: timestamp with time zone < text/,
480
+ );
481
+
482
+ const sql = await readFile(
483
+ path.join(migrationsDir, "043_metric_exemplars_text_timestamps.sql"),
484
+ "utf8",
485
+ );
486
+ await legacyPool.query(sql);
487
+ await legacyPool.query(sql); // re-running is a no-op
488
+
489
+ const types = await legacyPool.query<{
490
+ column_name: string;
491
+ data_type: string;
492
+ }>(
493
+ `SELECT column_name, data_type FROM information_schema.columns
494
+ WHERE table_schema = $1 AND table_name = 'metric_exemplars'
495
+ AND column_name IN ('received_at', 'expires_at')
496
+ ORDER BY column_name`,
497
+ [legacySchema],
498
+ );
499
+ expect(types.rows).toEqual([
500
+ { column_name: "expires_at", data_type: "text" },
501
+ { column_name: "received_at", data_type: "text" },
502
+ ]);
503
+ const converted = await legacyPool.query(
504
+ `SELECT id, received_at, expires_at FROM metric_exemplars ORDER BY id`,
505
+ );
506
+ expect(converted.rows).toEqual([
507
+ {
508
+ id: "ex-expired",
509
+ received_at: "2000-01-01T00:00:00.000Z",
510
+ expires_at: "2000-01-02T00:00:00.000Z",
511
+ },
512
+ {
513
+ id: "ex-live",
514
+ received_at: "2026-06-01T12:34:56.789Z",
515
+ expires_at: "2999-01-01T00:00:00.000Z",
516
+ },
517
+ ]);
518
+ const indexes = await legacyPool.query<{ indexname: string }>(
519
+ `SELECT indexname FROM pg_indexes WHERE schemaname = $1 AND tablename = 'metric_exemplars'`,
520
+ [legacySchema],
521
+ );
522
+ expect(indexes.rows.map((r) => r.indexname)).toContain(
523
+ "idx_metric_exemplars_expires",
524
+ );
525
+
526
+ // Retention now runs through the real store: 1 exemplar + 1 point.
527
+ expect(await metrics.purgeExpired()).toBe(2);
528
+ const remaining = await legacyPool.query(
529
+ `SELECT id FROM metric_exemplars ORDER BY id`,
530
+ );
531
+ expect(remaining.rows).toEqual([{ id: "ex-live" }]);
532
+ const points = await legacyPool.query(
533
+ `SELECT id FROM metric_point ORDER BY id`,
534
+ );
535
+ expect(points.rows).toEqual([{ id: "p-live" }]);
536
+ const recent = await metrics.recentExemplars({ projectId: "default" });
537
+ expect(recent.map((e) => [e.traceId, e.receivedAt])).toEqual([
538
+ ["trace-legacy-live", "2026-06-01T12:34:56.789Z"],
539
+ ]);
540
+ } finally {
541
+ // Single-session servers (PGlite) share GUCs across sockets, so put
542
+ // search_path / timezone back before the pooled adapter runs again.
543
+ await legacyPool.query("RESET timezone").catch(() => {});
544
+ await legacyPool.query("RESET search_path").catch(() => {});
545
+ await legacyPool.end();
546
+ }
547
+ });
548
+
549
+ it("migration 044 creates idx_actions_started_at and is a no-op when re-applied", async () => {
550
+ expect(migrationFiles).toContain("044_actions_started_at_index.sql");
551
+ const indexed = () =>
552
+ db
553
+ .prepare(
554
+ `SELECT indexname FROM pg_indexes
555
+ WHERE schemaname = current_schema() AND tablename = 'actions' AND indexname = 'idx_actions_started_at'`,
556
+ )
557
+ .first<{ indexname: string }>();
558
+ expect((await indexed())?.indexname).toBe("idx_actions_started_at");
559
+
560
+ // Upgrade path: an already-indexed database re-running 044 must not fail.
561
+ const admin = await adminClient();
562
+ try {
563
+ await admin.query(
564
+ await readFile(
565
+ path.join(migrationsDir, "044_actions_started_at_index.sql"),
566
+ "utf8",
567
+ ),
568
+ );
569
+ } finally {
570
+ await admin.end();
571
+ }
572
+ expect((await indexed())?.indexname).toBe("idx_actions_started_at");
573
+ });
574
+
575
+ it("migrations 045–048 key profiles, action graph, payloads and spans by project and re-apply cleanly", async () => {
576
+ const expected: Record<string, string> = {
577
+ user_profiles: "PRIMARY KEY (project_id, user_id)",
578
+ actions: "PRIMARY KEY (project_id, id)",
579
+ agent_runs: "PRIMARY KEY (project_id, id)",
580
+ tool_calls: "PRIMARY KEY (project_id, id)",
581
+ retrieval_events: "PRIMARY KEY (project_id, id)",
582
+ eval_results: "PRIMARY KEY (project_id, id)",
583
+ artifacts: "PRIMARY KEY (project_id, id)",
584
+ ai_span_payloads: "PRIMARY KEY (project_id, trace_id, span_id)",
585
+ telemetry_spans: "PRIMARY KEY (project_id, trace_id, span_id)",
586
+ };
587
+ const primaryKeys = async () => {
588
+ const rows = await db
589
+ .prepare(
590
+ `SELECT c.conrelid::regclass::text AS table_name,
591
+ pg_get_constraintdef(c.oid) AS pk
592
+ FROM pg_constraint c
593
+ JOIN pg_namespace n ON n.oid = c.connamespace
594
+ WHERE c.contype = 'p' AND n.nspname = current_schema()`,
595
+ )
596
+ .all<{ table_name: string; pk: string }>();
597
+ return Object.fromEntries(
598
+ rows.results
599
+ .filter((r) => r.table_name in expected)
600
+ .map((r) => [r.table_name, r.pk]),
601
+ );
602
+ };
603
+ expect(await primaryKeys()).toEqual(expected);
604
+
605
+ // Upgrade path: re-running the four files on a converted database is
606
+ // a no-op (each looks the current key up by definition first).
607
+ const admin = await adminClient();
608
+ try {
609
+ for (const file of migrationFiles.filter(
610
+ (f) => f >= "045_" && f < "049_",
611
+ )) {
612
+ await admin.query(
613
+ await readFile(path.join(migrationsDir, file), "utf8"),
614
+ );
615
+ }
616
+ } finally {
617
+ await admin.end();
618
+ }
619
+ expect(await primaryKeys()).toEqual(expected);
620
+ const spanIndexes = await db
621
+ .prepare(
622
+ `SELECT indexname FROM pg_indexes
623
+ WHERE schemaname = current_schema() AND tablename = 'telemetry_spans'
624
+ ORDER BY indexname`,
625
+ )
626
+ .all<{ indexname: string }>();
627
+ expect(spanIndexes.results.map((r) => r.indexname)).toContain(
628
+ "idx_spans_project_received",
629
+ );
630
+ });
631
+
632
+ it("keeps colliding ids separate per project through the real write paths", async () => {
633
+ const cleanup = async () => {
634
+ for (const table of [
635
+ "actions",
636
+ "telemetry_spans",
637
+ "ai_span_payloads",
638
+ "user_profiles",
639
+ ]) {
640
+ await db
641
+ .prepare(`DELETE FROM ${table} WHERE project_id IN (?, ?)`)
642
+ .bind("tenant-a", "tenant-b")
643
+ .run();
644
+ }
645
+ };
646
+ try {
647
+ const store = new ActionStore(db);
648
+ await store.upsertAction(
649
+ actionRecord({ id: "01SHARED", projectId: "tenant-a", name: "a run" }),
650
+ );
651
+ await store.upsertAction(
652
+ actionRecord({ id: "01SHARED", projectId: "tenant-b", name: "b run" }),
653
+ );
654
+ await store.upsertAction(
655
+ actionRecord({
656
+ id: "01SHARED",
657
+ projectId: "tenant-b",
658
+ name: "b run v2",
659
+ }),
660
+ );
661
+ expect((await store.getAction("tenant-a", "01SHARED"))?.name).toBe(
662
+ "a run",
663
+ );
664
+ expect((await store.getAction("tenant-b", "01SHARED"))?.name).toBe(
665
+ "b run v2",
666
+ );
667
+
668
+ const span = (projectId: string, spanName: string): StoredSpan => ({
669
+ projectId,
670
+ traceId: "trace-shared",
671
+ spanId: "span-shared",
672
+ parentSpanId: null,
673
+ traceState: null,
674
+ serviceName: "agent",
675
+ scopeName: null,
676
+ scopeVersion: null,
677
+ spanName,
678
+ spanKind: 1,
679
+ statusCode: 1,
680
+ statusMessage: null,
681
+ startTime: isoNow(),
682
+ endTime: isoNow(),
683
+ durationMs: 5,
684
+ attributesJson: JSON.stringify({
685
+ "openinference.span.kind": "LLM",
686
+ // An object: string payloads are stored verbatim, which JSONB rejects.
687
+ "ai.payload.input": { prompt: `${projectId} prompt` },
688
+ }),
689
+ droppedAttributesCount: 0,
690
+ resourceAttributesJson: "{}",
691
+ eventsJson: "[]",
692
+ droppedEventsCount: 0,
693
+ linksJson: "[]",
694
+ droppedLinksCount: 0,
695
+ receivedAt: isoNow(),
696
+ expiresAt: isoIn(60 * 60 * 1000),
697
+ sessionId: null,
698
+ interactionId: null,
699
+ });
700
+ // INSERT OR IGNORE → ON CONFLICT DO NOTHING used to drop tenant-b's span.
701
+ await ingestTelemetrySpans(db, [span("tenant-a", "a-llm")]);
702
+ await ingestTelemetrySpans(db, [span("tenant-b", "b-llm")]);
703
+ const names = await db
704
+ .prepare(
705
+ `SELECT project_id, span_name FROM telemetry_spans
706
+ WHERE trace_id = ? AND span_id = ? ORDER BY project_id`,
707
+ )
708
+ .bind("trace-shared", "span-shared")
709
+ .all<{ project_id: string; span_name: string }>();
710
+ expect(names.results).toEqual([
711
+ { project_id: "tenant-a", span_name: "a-llm" },
712
+ { project_id: "tenant-b", span_name: "b-llm" },
713
+ ]);
714
+
715
+ // Payload side table: the processor's upsert must stay per project.
716
+ const processors: SpanProcessorPlugin[] = [];
717
+ aiSpanPayloadsProcessorPlugin.register(
718
+ {} as Parameters<typeof aiSpanPayloadsProcessorPlugin.register>[0],
719
+ {
720
+ addSpanProcessor(p: SpanProcessorPlugin) {
721
+ processors.push(p);
722
+ },
723
+ } as unknown as CollectorRuntime,
724
+ );
725
+ const process = processors[0]?.process;
726
+ if (!process) throw new Error("span processor was not registered");
727
+ const ctx = {
728
+ env: { DB: db, OBS_PAYLOAD_CAPTURE_DEFAULT: "true" },
729
+ now: new Date(),
730
+ logger: console,
731
+ } as unknown as CollectorRouteContext;
732
+ await process([span("tenant-a", "a-llm")], ctx);
733
+ await process([span("tenant-b", "b-llm")], ctx);
734
+ const payloads = await db
735
+ .prepare(
736
+ `SELECT project_id, input_json FROM ai_span_payloads
737
+ WHERE trace_id = ? AND span_id = ? ORDER BY project_id`,
738
+ )
739
+ .bind("trace-shared", "span-shared")
740
+ .all<{ project_id: string; input_json: string }>();
741
+ expect(
742
+ payloads.results.map((r) => [r.project_id, JSON.parse(r.input_json)]),
743
+ ).toEqual([
744
+ ["tenant-a", { prompt: "tenant-a prompt" }],
745
+ ["tenant-b", { prompt: "tenant-b prompt" }],
746
+ ]);
747
+ const aOverview = await getAISpansOverview(db, {
748
+ projectId: "tenant-a",
749
+ hours: 24,
750
+ });
751
+ expect(aOverview.spans.map((s) => s.spanName)).toEqual(["a-llm"]);
752
+
753
+ // /v1/identify: same userId in two projects → two profiles.
754
+ const setProject: MiddlewareHandler<{
755
+ Bindings: CollectorEnv;
756
+ Variables: { projectId: string };
757
+ }> = async (c, next) => {
758
+ const header = c.req.header("x-project-id");
759
+ if (header) c.set("projectId", header);
760
+ await next();
761
+ };
762
+ const app = createTelemetryCollectorApp({
763
+ plugins: [identityReceiverPlugin],
764
+ auth: { middleware: setProject },
765
+ dashboardAuth: { middleware: setProject, registerRoutes: () => {} },
766
+ });
767
+ const identify = (projectId: string, body: Record<string, unknown>) =>
768
+ app.request(
769
+ "/v1/identify",
770
+ {
771
+ method: "POST",
772
+ headers: {
773
+ "content-type": "application/json",
774
+ "x-project-id": projectId,
775
+ },
776
+ body: JSON.stringify(body),
777
+ },
778
+ { DB: db as unknown as D1Database } as CollectorEnv,
779
+ );
780
+ const userId = "ada@example.com";
781
+ expect(
782
+ (
783
+ await identify("tenant-a", {
784
+ userId,
785
+ visitorId: "vis-a",
786
+ name: "Ada (a)",
787
+ properties: { plan: "a" },
788
+ })
789
+ ).status,
790
+ ).toBe(200);
791
+ expect(
792
+ (
793
+ await identify("tenant-b", {
794
+ userId,
795
+ visitorId: "vis-b",
796
+ name: "Ada (b)",
797
+ })
798
+ ).status,
799
+ ).toBe(200);
800
+ const profiles = await db
801
+ .prepare(
802
+ `SELECT project_id, visitor_id, name FROM user_profiles
803
+ WHERE user_id = ? ORDER BY project_id`,
804
+ )
805
+ .bind(userId)
806
+ .all<{ project_id: string; visitor_id: string; name: string }>();
807
+ expect(profiles.results).toEqual([
808
+ { project_id: "tenant-a", visitor_id: "vis-a", name: "Ada (a)" },
809
+ { project_id: "tenant-b", visitor_id: "vis-b", name: "Ada (b)" },
810
+ ]);
811
+ } finally {
812
+ // Leave no tenant rows behind for the purge / analysis tests below.
813
+ await cleanup();
814
+ }
815
+ });
816
+
817
+ it("purges expired action trees by TEXT started_at and keeps fresh, in-progress and eval-pinned trees", async () => {
818
+ const purge = (
819
+ over: {
820
+ maxActionsPerBatch?: number;
821
+ maxTreesPerBatch?: number;
822
+ maxBatches?: number;
823
+ } = {},
824
+ ) =>
825
+ purgeExpiredActionGraph(db, {
826
+ retentionHours: FIXTURE_RETENTION_HOURS,
827
+ now: () => FIXTURE_NOW_MS,
828
+ ...over,
829
+ });
830
+ const count = (table: string, column: string, value: string) =>
831
+ db
832
+ .prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE ${column} = ?`)
833
+ .bind(value)
834
+ .first<{ n: number | string }>()
835
+ .then((r) => Number(r?.n ?? 0));
836
+
837
+ const expired = await seedTree(db, {
838
+ projectId: "pg-ret",
839
+ rootId: "pg-old",
840
+ });
841
+ const fresh = await seedTree(db, {
842
+ projectId: "pg-ret",
843
+ rootId: "pg-new",
844
+ rootStartedAt: FRESH_START,
845
+ });
846
+ await seedTree(db, {
847
+ projectId: "pg-ret",
848
+ rootId: "pg-hung",
849
+ childEndedAt: null,
850
+ });
851
+ await seedTree(db, {
852
+ projectId: "pg-ret",
853
+ rootId: "pg-running",
854
+ agentRunStatus: "running",
855
+ });
856
+ const pinned = await seedTree(db, {
857
+ projectId: "pg-ret",
858
+ rootId: "pg-pinned",
859
+ });
860
+ await seedEvalCase(db, {
861
+ id: "pg-case",
862
+ projectId: "pg-ret",
863
+ sourceActionId: pinned.childId,
864
+ });
865
+ await seedTree(db, { projectId: "pg-ret-other", rootId: "pg-other-old" });
866
+ // Three more expired two-action trees to exercise packing and batching.
867
+ for (let i = 0; i < 3; i += 1) {
868
+ await seedTree(db, {
869
+ projectId: "pg-ret",
870
+ rootId: `pg-bulk-${i}`,
871
+ withLeaves: false,
872
+ });
873
+ }
874
+ // An expired tree larger than the per-batch cap used below (5 actions).
875
+ const bigStore = new ActionStore(db);
876
+ for (let i = 0; i < 5; i += 1) {
877
+ await bigStore.upsertAction(
878
+ actionRecord({
879
+ id: i === 0 ? "pg-big" : `pg-big-${i}`,
880
+ projectId: "pg-ret",
881
+ rootActionId: "pg-big",
882
+ causedByActionId: i === 0 ? null : "pg-big",
883
+ agentRunId: "pg-big",
884
+ }),
885
+ );
886
+ }
887
+ await bigStore.upsertAgentRun(
888
+ agentRunRecord({ id: "pg-big", projectId: "pg-ret" }),
889
+ );
890
+
891
+ // Cap of 4 actions per batch, one batch: two whole 2-action trees go,
892
+ // nothing is split, and the 5-action tree is reported, not touched.
893
+ const first = await purge({ maxActionsPerBatch: 4, maxBatches: 1 });
894
+ expect(first).toMatchObject({
895
+ actions: 4,
896
+ trees: 2,
897
+ batches: 1,
898
+ truncated: true,
899
+ oversized_trees: 1,
900
+ oversized: [
901
+ { project_id: "pg-ret", root_action_id: "pg-big", actions: 5 },
902
+ ],
903
+ });
904
+ expect(await count("actions", "root_action_id", "pg-big")).toBe(5);
905
+ for (const root of [
906
+ "pg-old",
907
+ "pg-other-old",
908
+ "pg-bulk-0",
909
+ "pg-bulk-1",
910
+ "pg-bulk-2",
911
+ ]) {
912
+ expect([0, 2]).toContain(await count("actions", "root_action_id", root));
913
+ }
914
+
915
+ const rest = await purge({ maxActionsPerBatch: 4 });
916
+ expect(rest).toMatchObject({ truncated: false, oversized_trees: 1 });
917
+ // 2 (pg-old) + 2 (pg-other-old) + 6 (bulk) spine rows in total.
918
+ expect(first.actions + rest.actions).toBe(10);
919
+ expect(first.trees + rest.trees).toBe(5);
920
+ expect(first.agent_runs + rest.agent_runs).toBe(5);
921
+ expect(first.tool_calls + rest.tool_calls).toBe(2);
922
+ expect(first.artifacts + rest.artifacts).toBe(2);
923
+
924
+ expect(await count("actions", "root_action_id", expired.rootId)).toBe(0);
925
+ expect(await count("tool_calls", "id", expired.toolCallId)).toBe(0);
926
+ expect(
927
+ await count("retrieval_events", "id", expired.retrievalEventId),
928
+ ).toBe(0);
929
+ expect(await count("eval_results", "id", expired.evalResultId)).toBe(0);
930
+ expect(await count("artifacts", "id", expired.artifactId)).toBe(0);
931
+ expect(await count("agent_runs", "id", expired.rootId)).toBe(0);
932
+ expect(await count("actions", "root_action_id", "pg-other-old")).toBe(0);
933
+
934
+ expect(await count("actions", "root_action_id", fresh.rootId)).toBe(2);
935
+ expect(await count("tool_calls", "id", fresh.toolCallId)).toBe(1);
936
+ expect(await count("actions", "root_action_id", "pg-hung")).toBe(2);
937
+ expect(await count("actions", "root_action_id", "pg-running")).toBe(2);
938
+ expect(await count("agent_runs", "id", "pg-running")).toBe(1);
939
+ expect(await count("actions", "root_action_id", pinned.rootId)).toBe(2);
940
+ expect(await count("eval_cases", "id", "pg-case")).toBe(1);
941
+ expect(await count("actions", "root_action_id", "pg-big")).toBe(5);
942
+ expect(await count("agent_runs", "id", "pg-big")).toBe(1);
943
+
944
+ // With a cap that fits it, the oversized tree goes whole.
945
+ const big = await purge({ maxActionsPerBatch: 5 });
946
+ expect(big).toMatchObject({
947
+ actions: 5,
948
+ trees: 1,
949
+ agent_runs: 1,
950
+ oversized_trees: 0,
951
+ });
952
+ expect(await count("actions", "root_action_id", "pg-big")).toBe(0);
953
+ expect(await countActionGraph(db, "pg-ret")).toEqual({
954
+ actions: 8,
955
+ agent_runs: 4,
956
+ tool_calls: 4,
957
+ retrieval_events: 4,
958
+ eval_results: 4,
959
+ artifacts: 4,
960
+ });
961
+
962
+ // Idempotent: nothing left to purge.
963
+ expect(await purge()).toMatchObject({
964
+ actions: 0,
965
+ batches: 0,
966
+ truncated: false,
967
+ });
968
+ });
969
+
970
+ it("ingests AI calls and summarises BOOLEAN is_error inside a TEXT time window", async () => {
971
+ await ingestAICallBatch(db, [
972
+ aiCall({ callId: "ai-ok" }),
973
+ aiCall({ callId: "ai-err", isError: true, errorMessage: "boom" }),
974
+ aiCall({
975
+ callId: "ai-old",
976
+ receivedAt: new Date(Date.now() - 48 * 3600 * 1000).toISOString(),
977
+ }),
978
+ ]);
979
+
980
+ const overview = await getAICallsOverview(db, {
981
+ projectId: "default",
982
+ hours: 24,
983
+ });
984
+ expect(overview.summary.totalCalls).toBe(2);
985
+ expect(overview.summary.errorCalls).toBe(1);
986
+ expect(overview.summary.totalPromptTokens).toBe(20);
987
+ expect(typeof overview.summary.totalCostUsd).toBe("number");
988
+ expect(overview.calls.map((c) => [c.callId, c.isError]).sort()).toEqual([
989
+ ["ai-err", true],
990
+ ["ai-ok", false],
991
+ ]);
992
+ // JSONB columns must still come back as JSON text (JSONB normalises
993
+ // whitespace, so compare the parsed value).
994
+ expect(typeof overview.calls[0]?.requestJson).toBe("string");
995
+ expect(JSON.parse(overview.calls[0]?.requestJson ?? "null")).toEqual({
996
+ messages: [],
997
+ });
998
+
999
+ const errorsOnly = await getAICallsOverview(db, {
1000
+ projectId: "default",
1001
+ hours: 24,
1002
+ isError: true,
1003
+ });
1004
+ expect(errorsOnly.calls.map((c) => c.callId)).toEqual(["ai-err"]);
1005
+
1006
+ const alerts = new AlertsStore(db);
1007
+ const rule = {
1008
+ id: "rule-ai-errors",
1009
+ projectId: "default",
1010
+ name: "AI errors",
1011
+ signal: "ai",
1012
+ query: { isError: true },
1013
+ threshold: 0,
1014
+ windowMins: 60,
1015
+ comparison: "gt",
1016
+ channels: [],
1017
+ enabled: true,
1018
+ createdAt: isoNow(),
1019
+ updatedAt: isoNow(),
1020
+ } as unknown as AlertRule;
1021
+ expect(await alerts.evaluateRule(rule)).toBe(1);
1022
+ });
1023
+
1024
+ it("migration 049 adds alert_state.notified with a delivered backfill, and the store drives it", async () => {
1025
+ // Legacy shape: alert_state as created by 014, before 049.
1026
+ const legacyPool = new pg.Pool({ connectionString: databaseUrl, max: 1 });
1027
+ try {
1028
+ await legacyPool.query(`SET search_path TO ${legacySchema}`);
1029
+ await legacyPool.query(`
1030
+ CREATE TABLE alert_state (
1031
+ rule_id TEXT PRIMARY KEY,
1032
+ project_id TEXT NOT NULL,
1033
+ current_state TEXT NOT NULL,
1034
+ last_state_change TEXT NOT NULL
1035
+ );
1036
+ INSERT INTO alert_state VALUES
1037
+ ('rule-legacy', 'default', 'firing', '2026-05-31T00:00:00.000Z');
1038
+ `);
1039
+ const sql = await readFile(
1040
+ path.join(migrationsDir, "049_alert_state_notified.sql"),
1041
+ "utf8",
1042
+ );
1043
+ await legacyPool.query(sql);
1044
+ // Re-running is a no-op. Postgres answers the IF NOT EXISTS skip
1045
+ // with a NOTICE, which PGlite's socket bridge does not relay
1046
+ // cleanly; silence it on this session (real Postgres is unaffected).
1047
+ await legacyPool.query(`SET client_min_messages TO WARNING`);
1048
+ await legacyPool.query(sql);
1049
+ const column = await legacyPool.query<{
1050
+ data_type: string;
1051
+ is_nullable: string;
1052
+ column_default: string;
1053
+ }>(
1054
+ `SELECT data_type, is_nullable, column_default FROM information_schema.columns
1055
+ WHERE table_schema = $1 AND table_name = 'alert_state' AND column_name = 'notified'`,
1056
+ [legacySchema],
1057
+ );
1058
+ expect(column.rows).toEqual([
1059
+ { data_type: "integer", is_nullable: "NO", column_default: "1" },
1060
+ ]);
1061
+ const backfilled = await legacyPool.query<{ notified: number }>(
1062
+ `SELECT notified FROM alert_state WHERE rule_id = 'rule-legacy'`,
1063
+ );
1064
+ expect(Number(backfilled.rows[0]?.notified)).toBe(1);
1065
+ } finally {
1066
+ // PGlite is one session for every socket: put search_path and the
1067
+ // message level back before the pooled adapter runs again.
1068
+ await legacyPool.query("RESET client_min_messages").catch(() => {});
1069
+ await legacyPool.query("RESET search_path").catch(() => {});
1070
+ await legacyPool.end();
1071
+ }
1072
+
1073
+ // Live schema: transitions start undelivered; only the exact
1074
+ // transition that was delivered is acknowledged.
1075
+ const projects = new ProjectsStore(db);
1076
+ const project = await projects.createProject({
1077
+ name: "Alert state",
1078
+ slug: `alert-state-${Date.now().toString(36)}`,
1079
+ });
1080
+ const alerts = new AlertsStore(db);
1081
+ const rule = await alerts.createRule(project.id, {
1082
+ name: "notified flag",
1083
+ signal: "spans",
1084
+ query: {},
1085
+ threshold: 1,
1086
+ windowMins: 5,
1087
+ comparison: ">",
1088
+ channels: [{ type: "webhook", url: "https://hooks.example.com/pg" }],
1089
+ enabled: true,
1090
+ });
1091
+ const t1 = "2026-05-31T00:00:00.000Z";
1092
+ await alerts.transitionState(rule.id, project.id, "firing", t1);
1093
+ expect(await alerts.getState(rule.id, project.id)).toMatchObject({
1094
+ current_state: "firing",
1095
+ last_state_change: t1,
1096
+ notified: 0,
1097
+ });
1098
+ expect(
1099
+ await alerts.markNotified(rule.id, "other-project", "firing", t1),
1100
+ ).toBe(false);
1101
+ expect(await alerts.markNotified(rule.id, project.id, "ok", t1)).toBe(
1102
+ false,
1103
+ );
1104
+ expect(await alerts.markNotified(rule.id, project.id, "firing", t1)).toBe(
1105
+ true,
1106
+ );
1107
+ expect((await alerts.getState(rule.id, project.id))?.notified).toBe(1);
1108
+ expect(await alerts.markNotified(rule.id, project.id, "firing", t1)).toBe(
1109
+ false,
1110
+ );
1111
+ });
1112
+
1113
+ it("purges expired AI data using the dialect's text-rendered now()", async () => {
1114
+ await ingestAICallBatch(db, [
1115
+ aiCall({ callId: "ai-expired", expiresAt: isoIn(-60_000) }),
1116
+ ]);
1117
+ expect(await purgeExpiredAIData(db)).toBe(1);
1118
+ const remaining = await db
1119
+ .prepare(`SELECT call_id FROM ai_calls WHERE call_id = ?`)
1120
+ .bind("ai-expired")
1121
+ .first();
1122
+ expect(remaining).toBeNull();
1123
+ });
1124
+
1125
+ it("ingests usage events with BOOLEAN is_bot and reads them back as D1 flags", async () => {
1126
+ const usage = new UsageStore(db);
1127
+ await usage.ingest([
1128
+ usageEvent({ eventId: "u-human" }),
1129
+ usageEvent({ eventId: "u-bot", sessionId: "sess-bot", isBot: true }),
1130
+ ]);
1131
+ const flags = await db
1132
+ .prepare(`SELECT event_id, is_bot FROM usage_events ORDER BY event_id`)
1133
+ .all<{ event_id: string; is_bot: unknown }>();
1134
+ expect(flags.results).toEqual([
1135
+ { event_id: "u-bot", is_bot: 1 },
1136
+ { event_id: "u-human", is_bot: 0 },
1137
+ ]);
1138
+
1139
+ const overview = await usage.getOverview({
1140
+ projectId: "default",
1141
+ hours: 24,
1142
+ });
1143
+ // Same semantics as D1: bots are counted (not dropped) by getOverview.
1144
+ expect(overview.botsFiltered).toBe(1);
1145
+ expect(overview.summary.totalEvents).toBe(2);
1146
+ });
1147
+
1148
+ it("runs every built-in and derived analysis definition", async () => {
1149
+ // Seed two linked spans so Tier 1 derivation emits the service,
1150
+ // edge, messaging-topic and AI analyses (julianday, json_extract …).
1151
+ const now = isoNow();
1152
+ const span = db.prepare(`
1153
+ INSERT INTO telemetry_spans (project_id, trace_id, span_id, parent_span_id, service_name,
1154
+ span_name, span_kind, status_code, start_time, end_time, duration_ms, attributes_json,
1155
+ received_at, expires_at)
1156
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
1157
+ await db.batch([
1158
+ span.bind(
1159
+ "default",
1160
+ "trace-1",
1161
+ "span-a",
1162
+ null,
1163
+ "svc-a",
1164
+ "publish orders",
1165
+ 4,
1166
+ 0,
1167
+ now,
1168
+ now,
1169
+ 12.5,
1170
+ JSON.stringify({ "messaging.destination": "orders" }),
1171
+ now,
1172
+ isoIn(3_600_000),
1173
+ ),
1174
+ db
1175
+ .prepare(`
1176
+ INSERT INTO telemetry_spans (project_id, trace_id, span_id, parent_span_id, service_name,
1177
+ span_name, span_kind, status_code, start_time, end_time, duration_ms, attributes_json,
1178
+ received_at, expires_at)
1179
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
1180
+ .bind(
1181
+ "default",
1182
+ "trace-1",
1183
+ "span-b",
1184
+ "span-a",
1185
+ "svc-b",
1186
+ "consume orders",
1187
+ 5,
1188
+ 2,
1189
+ now,
1190
+ now,
1191
+ 30,
1192
+ JSON.stringify({
1193
+ "messaging.destination": "orders",
1194
+ "gen_ai.system": "openai",
1195
+ }),
1196
+ now,
1197
+ isoIn(3_600_000),
1198
+ ),
1199
+ ]);
1200
+
1201
+ const derived = await deriveAnalysesForProject("default", db);
1202
+ expect(derived.map((d) => d.id)).toEqual(
1203
+ expect.arrayContaining(["ai_error_rate", "messaging_lag::orders"]),
1204
+ );
1205
+
1206
+ const defs = [...TIER0_ANALYSES, ...INVESTIGATION_ANALYSES, ...derived];
1207
+ for (const def of defs) {
1208
+ const result = await runSqlAnalysis(def, {
1209
+ db,
1210
+ projectId: "default",
1211
+ retentionHours: 24,
1212
+ }).catch((err: Error) => {
1213
+ throw new Error(`${def.id}: ${err.message}`);
1214
+ });
1215
+ expect(VALID_STATUSES.has(result.status), def.id).toBe(true);
1216
+ }
1217
+
1218
+ const errorRate = await runSqlAnalysis(
1219
+ TIER0_ANALYSES.find((d) => d.id === "overall_error_rate") as never,
1220
+ { db, projectId: "default", retentionHours: 24 },
1221
+ );
1222
+ expect(errorRate.primaryValue).toBe(0.5);
1223
+ expect(Array.isArray(errorRate.payload.sparkline)).toBe(true);
1224
+ });
1225
+ });