@obsunified/collector 2.2.1 → 2.3.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 (53) hide show
  1. package/dist/auth.d.ts +1 -1
  2. package/dist/auth.js +1 -1
  3. package/dist/{chunk-KMEZKGSO.js → chunk-2FALTDCE.js} +12 -4
  4. package/dist/chunk-2FALTDCE.js.map +1 -0
  5. package/dist/{chunk-ZSF5XP3B.js → chunk-RASSTGNB.js} +30 -22
  6. package/dist/chunk-RASSTGNB.js.map +1 -0
  7. package/dist/index.d.ts +10 -2
  8. package/dist/index.js +2892 -2240
  9. package/dist/index.js.map +1 -1
  10. package/dist/{ingest-auth-C5-8Mly5.d.ts → ingest-auth-BmP5-f96.d.ts} +32 -1
  11. package/dist/{openai-GSJZGFGL.js → openai-VDKVVZAV.js} +2 -2
  12. package/package.json +4 -3
  13. package/src/__tests__/autosre-closed-loop.test.ts +700 -0
  14. package/src/__tests__/db-parity.integration.test.ts +961 -0
  15. package/src/__tests__/internal-contracts-routes.test.ts +451 -0
  16. package/src/auth/dashboard-auth.test.ts +61 -0
  17. package/src/auth/dashboard-auth.ts +17 -2
  18. package/src/framework/env.ts +105 -0
  19. package/src/index.ts +13 -4
  20. package/src/lib/ai-store/calls.ts +43 -23
  21. package/src/lib/ai-store/evaluations.test.ts +3 -3
  22. package/src/lib/ai-store.ts +12 -1
  23. package/src/lib/analyses-store.test.ts +6 -6
  24. package/src/lib/autosre-findings.ts +169 -14
  25. package/src/lib/autosre-remediation-gate.ts +156 -26
  26. package/src/lib/db-parity.ts +883 -0
  27. package/src/lib/eval-cases-store.test.ts +1 -1
  28. package/src/lib/eval-cases-store.ts +16 -4
  29. package/src/lib/evidence-references.ts +30 -22
  30. package/src/lib/projects-store.test.ts +38 -1
  31. package/src/lib/projects-store.ts +23 -4
  32. package/src/lib/sql-db-postgres.integration.test.ts +75 -2
  33. package/src/lib/stage6.test.ts +1 -1
  34. package/src/lib/usage-store.ts +9 -3
  35. package/src/migrations/052_remediation_callback.sql +10 -0
  36. package/src/migrations/053_remediation_callback_delivery.sql +7 -0
  37. package/src/migrations/parity.test.ts +105 -3
  38. package/src/migrations-postgres/052_remediation_callback.sql +10 -0
  39. package/src/migrations-postgres/053_remediation_callback_delivery.sql +7 -0
  40. package/src/plugins/ai-call-by-id.test.ts +73 -0
  41. package/src/plugins/ai-receiver.ts +15 -0
  42. package/src/plugins/autosre-routes.ts +409 -147
  43. package/src/plugins/connected-routes/deep-link-shapes.test.ts +44 -0
  44. package/src/plugins/connected-routes/manifest.test.ts +224 -0
  45. package/src/plugins/connected-routes/manifest.ts +131 -28
  46. package/src/plugins/connected-routes-links.test.ts +96 -0
  47. package/src/plugins/connected-routes.test.ts +13 -11
  48. package/src/plugins/connected-routes.ts +53 -20
  49. package/src/plugins/eval-cases-routes.ts +33 -10
  50. package/src/plugins/evidence-retrieval-routes.ts +89 -24
  51. package/dist/chunk-KMEZKGSO.js.map +0 -1
  52. package/dist/chunk-ZSF5XP3B.js.map +0 -1
  53. /package/dist/{openai-GSJZGFGL.js.map → openai-VDKVVZAV.js.map} +0 -0
@@ -91,7 +91,7 @@ describe("EvalCasesStore", () => {
91
91
  expect.objectContaining({
92
92
  entityKind: "span",
93
93
  entityId: "trace-1:span-1",
94
- route: "#/traces/trace-1#span=span-1",
94
+ route: "#/traces?trace=trace-1&span=span-1",
95
95
  }),
96
96
  ]),
97
97
  );
@@ -3,7 +3,10 @@ import type {
3
3
  EvidenceReferenceContract as EvidenceReferenceContractType,
4
4
  JsonValue,
5
5
  } from "@obsunified/types";
6
- import { EVIDENCE_REFERENCE_CONTRACT } from "@obsunified/types";
6
+ import {
7
+ dashboardRouteToHref,
8
+ EVIDENCE_REFERENCE_CONTRACT,
9
+ } from "@obsunified/types";
7
10
  import { sourceLinkEvidenceReferences } from "./evidence-references";
8
11
  import { randomHex } from "./hash";
9
12
  import { parseJsonArray, parseJsonValue } from "./json";
@@ -345,7 +348,10 @@ const evalCaseEvidenceReferences = (evalCase: EvalCase): EvidenceReference[] =>
345
348
  sourceLabel: `Eval case "${evalCase.name}"`,
346
349
  sourceId: evalCase.id,
347
350
  sourceKind: "eval_case",
348
- sourceRoute: `#/evaluations?case=${encodeURIComponent(evalCase.id)}`,
351
+ sourceRoute: dashboardRouteToHref({
352
+ tab: "evaluations",
353
+ evaluationId: evalCase.id,
354
+ }),
349
355
  sourceName: evalCase.name,
350
356
  },
351
357
  evalCase,
@@ -384,7 +390,10 @@ const evalRunEvidenceReferences = (run: EvalRun): EvidenceReference[] => {
384
390
  sourceLabel: `Eval run "${run.id}"`,
385
391
  sourceId: run.id,
386
392
  sourceKind: "eval_run",
387
- sourceRoute: `#/evaluations?run=${encodeURIComponent(run.id)}`,
393
+ sourceRoute: dashboardRouteToHref({
394
+ tab: "evaluations",
395
+ evaluationId: run.evalCaseId ?? run.id,
396
+ }),
388
397
  sourceName: run.sourceEvalCase.name,
389
398
  },
390
399
  run.sourceEvalCase,
@@ -413,7 +422,10 @@ const rowToEvalRun = (row: EvalRunRow): EvalRun => {
413
422
  sourceLabel: `Eval case "${sourceEvalCase.name}"`,
414
423
  sourceId: sourceEvalCase.id,
415
424
  sourceKind: "eval_case",
416
- sourceRoute: `#/evaluations?case=${encodeURIComponent(sourceEvalCase.id)}`,
425
+ sourceRoute: dashboardRouteToHref({
426
+ tab: "evaluations",
427
+ evaluationId: sourceEvalCase.id,
428
+ }),
417
429
  sourceName: sourceEvalCase.name,
418
430
  },
419
431
  sourceEvalCase,
@@ -10,9 +10,10 @@ import type {
10
10
  EvidenceReference,
11
11
  JsonValue,
12
12
  } from "@obsunified/types";
13
+ import { dashboardRouteToHref, hrefForEntity } from "@obsunified/types";
13
14
 
14
15
  const analysisRoute = (analysisId: string) =>
15
- `#/investigate/${encodeURIComponent(analysisId)}`;
16
+ dashboardRouteToHref({ tab: "investigate", investigationId: analysisId });
16
17
 
17
18
  export const analysisEvidenceReference = (
18
19
  evidence: AskEvidence,
@@ -103,10 +104,10 @@ const valueFor = (
103
104
  };
104
105
 
105
106
  const traceRoute = (traceId: string) =>
106
- `#/traces/${encodeURIComponent(traceId)}`;
107
+ dashboardRouteToHref({ tab: "traces", traceId });
107
108
 
108
109
  const spanRoute = (traceId: string, spanId: string) =>
109
- `${traceRoute(traceId)}#span=${encodeURIComponent(spanId)}`;
110
+ dashboardRouteToHref({ tab: "traces", traceId, spanId });
110
111
 
111
112
  export const evidenceRouteFor = (
112
113
  entityKind: EvidenceEntityKind,
@@ -117,41 +118,48 @@ export const evidenceRouteFor = (
117
118
  case "analysis":
118
119
  return analysisRoute(entityId);
119
120
  case "action":
120
- return `#/actions/${encodeURIComponent(entityId)}`;
121
+ return dashboardRouteToHref({ tab: "actions", actionId: entityId });
121
122
  case "alert":
122
- return `#/alerts?alert=${encodeURIComponent(entityId)}`;
123
+ return dashboardRouteToHref({ tab: "alerts", alertId: entityId });
123
124
  case "agent_run":
124
- return `#/agent-runs/${encodeURIComponent(entityId)}`;
125
+ return dashboardRouteToHref({ tab: "agent-runs", agentRunId: entityId });
125
126
  case "ai_call":
126
- return `#/ai/calls/${encodeURIComponent(entityId)}`;
127
+ return dashboardRouteToHref({ tab: "ai", aiCallId: entityId });
127
128
  case "eval":
128
- return `#/evals/${encodeURIComponent(entityId)}`;
129
+ return dashboardRouteToHref({
130
+ tab: "evaluations",
131
+ evaluationId: entityId,
132
+ });
129
133
  case "log":
130
- return `#/logs?id=${encodeURIComponent(entityId)}`;
134
+ return dashboardRouteToHref({ tab: "logs", logId: entityId });
131
135
  case "profile":
132
- return traceId
133
- ? `#/profiles/${encodeURIComponent(entityId)}?trace_id=${encodeURIComponent(traceId)}`
134
- : `#/profiles/${encodeURIComponent(entityId)}`;
136
+ return dashboardRouteToHref({
137
+ tab: "profiles",
138
+ profileId: entityId,
139
+ ...(traceId ? { traceId } : {}),
140
+ });
135
141
  case "replay":
136
- return `#/replay?session=${encodeURIComponent(entityId)}`;
142
+ return dashboardRouteToHref({ tab: "replay", sessionId: entityId });
137
143
  case "service":
138
- return `#/traces?service=${encodeURIComponent(entityId)}`;
144
+ return dashboardRouteToHref({ tab: "service-map", service: entityId });
139
145
  case "span": {
140
146
  const [tracePart, spanPart] = entityId.split(":");
141
- return spanPart ? spanRoute(tracePart, spanPart) : `#/spans/${entityId}`;
147
+ return spanPart
148
+ ? spanRoute(tracePart, spanPart)
149
+ : hrefForEntity("span", entityId, traceId ? { traceId } : undefined);
142
150
  }
143
151
  case "tool_call":
144
- return `#/tool-calls/${encodeURIComponent(entityId)}`;
152
+ return dashboardRouteToHref({ tab: "tool-calls", toolCallId: entityId });
145
153
  case "trace":
146
154
  return traceRoute(entityId);
147
155
  case "docs":
148
- return entityId.startsWith("#/") ? entityId : `#/docs/${entityId}`;
156
+ return hrefForEntity("docs", entityId);
149
157
  case "change":
150
- return `#/changes/${encodeURIComponent(entityId)}`;
158
+ return dashboardRouteToHref({ tab: "changes", changeId: entityId });
151
159
  case "finding":
152
- return `#/findings/${encodeURIComponent(entityId)}`;
160
+ return dashboardRouteToHref({ tab: "findings", findingId: entityId });
153
161
  case "incident":
154
- return `#/incidents/${encodeURIComponent(entityId)}`;
162
+ return dashboardRouteToHref({ tab: "incidents", incidentId: entityId });
155
163
  }
156
164
  const exhaustive: never = entityKind;
157
165
  return exhaustive;
@@ -232,7 +240,7 @@ const instrumentationGapEvidenceReferences = (
232
240
  const thresholdText = thresholdVersion
233
241
  ? ` Thresholds: ${thresholdVersion}.`
234
242
  : "";
235
- const docsRoute = "#/docs/howto/ebpf";
243
+ const docsRoute = hrefForEntity("docs", "howto/ebpf");
236
244
  return {
237
245
  evidenceId: `analysis:${result.analysisId}:instrumentation-gap:${index}:span:${entityId}`,
238
246
  entityKind: "span",
@@ -262,7 +270,7 @@ const instrumentationGapEvidenceReferences = (
262
270
  label: "Open trace gap data",
263
271
  entityKind: "trace",
264
272
  entityId: traceId,
265
- route: `${traceRoute(traceId)}/gaps`,
273
+ route: traceRoute(traceId),
266
274
  reason:
267
275
  "Review all structured instrumentation blindspots for this trace.",
268
276
  },
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { sha256Hex } from "./hash";
3
- import { ProjectsStore } from "./projects-store";
3
+ import { displayPrefix, ProjectsStore } from "./projects-store";
4
4
  import { createMigratedSqliteDb } from "./test-utils/node-sqlite-db";
5
5
 
6
6
  describe("ProjectsStore.revokeKey", () => {
@@ -23,3 +23,40 @@ describe("ProjectsStore.revokeKey", () => {
23
23
  db.close();
24
24
  });
25
25
  });
26
+
27
+ describe("bootstrap key display prefix", () => {
28
+ it("never exposes a short bootstrap key in full", async () => {
29
+ const db = createMigratedSqliteDb();
30
+ const store = new ProjectsStore(db);
31
+ await store.bootstrapEnvKey("short-key-12345"); // 15 chars
32
+ const [key] = (await store.listKeys("default")).filter(
33
+ (k) => k.name === "bootstrap",
34
+ );
35
+ expect(key?.keyPrefix).toBe("short");
36
+ expect("short-key-12345".startsWith(key?.keyPrefix ?? "")).toBe(true);
37
+ expect(key?.keyPrefix.length).toBeLessThan("short-key-12345".length);
38
+ db.close();
39
+ });
40
+
41
+ it("keeps 16 chars for long keys, matching minted keys", () => {
42
+ expect(displayPrefix("a".repeat(64))).toHaveLength(16);
43
+ expect(displayPrefix("abc")).toBe("a");
44
+ expect(displayPrefix("")).toBe("");
45
+ });
46
+
47
+ it("shrinks a legacy full-length prefix on the next bootstrap", async () => {
48
+ const db = createMigratedSqliteDb();
49
+ const store = new ProjectsStore(db);
50
+ await store.bootstrapEnvKey("legacy-key-1234");
51
+ await db
52
+ .prepare(`UPDATE ingest_keys SET key_prefix = ? WHERE name = 'bootstrap'`)
53
+ .bind("legacy-key-1234")
54
+ .run();
55
+ await store.bootstrapEnvKey("legacy-key-1234");
56
+ const [key] = (await store.listKeys("default")).filter(
57
+ (k) => k.name === "bootstrap",
58
+ );
59
+ expect(key?.keyPrefix).toBe("legac");
60
+ db.close();
61
+ });
62
+ });
@@ -31,6 +31,15 @@ const rowToKey = (row: IngestKeyRow): IngestKey => ({
31
31
 
32
32
  const SLUG_PATTERN = /^[a-z0-9]([a-z0-9-]{0,48}[a-z0-9])?$/;
33
33
 
34
+ /**
35
+ * Display identifier for a secret: at most 16 chars (what minted keys show),
36
+ * but never more than a third of the secret, so a short operator-chosen
37
+ * INGEST_KEY can never be returned in full by `keys list`.
38
+ */
39
+ export function displayPrefix(plaintext: string): string {
40
+ return plaintext.slice(0, Math.min(16, Math.floor(plaintext.length / 3)));
41
+ }
42
+
34
43
  export class ProjectsStore {
35
44
  constructor(private readonly db: SqlDb) {}
36
45
 
@@ -189,14 +198,24 @@ export class ProjectsStore {
189
198
  if (!plaintext) return;
190
199
  await this.ensureDefaultProject();
191
200
  const keyHash = await sha256Hex(plaintext);
201
+ const keyPrefix = displayPrefix(plaintext);
192
202
  const existing = await this.db
193
- .prepare(`SELECT id FROM ingest_keys WHERE key_hash = ?`)
203
+ .prepare(`SELECT id, key_prefix FROM ingest_keys WHERE key_hash = ?`)
194
204
  .bind(keyHash)
195
- .first<{ id: string }>();
196
- if (existing) return;
205
+ .first<{ id: string; key_prefix: string }>();
206
+ if (existing) {
207
+ // Rows written before the prefix was length-capped may hold a short
208
+ // bootstrap key in full; shrink them to the safe display prefix.
209
+ if (existing.key_prefix.length > keyPrefix.length) {
210
+ await this.db
211
+ .prepare(`UPDATE ingest_keys SET key_prefix = ? WHERE id = ?`)
212
+ .bind(keyPrefix, existing.id)
213
+ .run();
214
+ }
215
+ return;
216
+ }
197
217
 
198
218
  const id = randomHex(16);
199
- const keyPrefix = plaintext.slice(0, Math.min(16, plaintext.length));
200
219
  const createdAt = new Date().toISOString();
201
220
  await this.db
202
221
  .prepare(
@@ -28,20 +28,22 @@ import type {
28
28
  StoredSpan,
29
29
  UsageEventRecord,
30
30
  } from "@obsunified/types";
31
- import type { MiddlewareHandler } from "hono";
31
+ import { Hono, type MiddlewareHandler } from "hono";
32
32
  import { afterAll, beforeAll, describe, expect, it } from "vitest";
33
33
  import { deriveAnalysesForProject } from "../analyses/derive";
34
34
  import { INVESTIGATION_ANALYSES } from "../analyses/investigations";
35
35
  import { TIER0_ANALYSES } from "../analyses/tier0";
36
36
  import {
37
- type CollectorRuntime,
37
+ CollectorRuntime,
38
38
  createTelemetryCollectorApp,
39
39
  type SpanProcessorPlugin,
40
40
  } from "../framework/collector";
41
41
  import type { CollectorEnv, CollectorRouteContext } from "../framework/env";
42
42
  import type { DecodedMetricPoint } from "../otlp/decode";
43
43
  import { aiSpanPayloadsProcessorPlugin } from "../plugins/ai-span-payloads-processor";
44
+ import { connectedRoutesPlugin } from "../plugins/connected-routes";
44
45
  import { identityReceiverPlugin } from "../plugins/identity-receiver";
46
+ import { usageQueryRoutesPlugin } from "../plugins/usage-query-routes";
45
47
  import { purgeExpiredActionGraph } from "./action-retention";
46
48
  import { ActionStore } from "./actions-store";
47
49
  import { getAICallsOverview, ingestAICallBatch } from "./ai-store/calls";
@@ -1222,4 +1224,75 @@ describe.skipIf(!databaseUrl)("PostgresAdapter against a live Postgres", () => {
1222
1224
  expect(errorRate.primaryValue).toBe(0.5);
1223
1225
  expect(Array.isArray(errorRate.payload.sparkline)).toBe(true);
1224
1226
  });
1227
+
1228
+ it("serves the change connected-rail and usage sessions routes without Postgres query errors", async () => {
1229
+ const now = isoNow();
1230
+ const later = isoIn(60 * 60 * 1000);
1231
+ const insertSpan = (spanId: string, startTime: string) =>
1232
+ db
1233
+ .prepare(
1234
+ `INSERT INTO telemetry_spans (
1235
+ project_id, environment, trace_id, span_id, service_name, service_version,
1236
+ span_name, status_code, start_time, end_time, duration_ms, received_at, expires_at
1237
+ ) VALUES ('default', 'default', 'trace-pg-1', ?, 'checkout', 'v9.9.9', 'POST /pay', 2, ?, ?, 5, ?, ?)`,
1238
+ )
1239
+ .bind(spanId, startTime, startTime, now, later)
1240
+ .run();
1241
+ await insertSpan("span-pg-a", "2026-01-01T00:00:00.000Z");
1242
+ await insertSpan("span-pg-b", "2026-01-01T00:00:01.000Z");
1243
+ await db
1244
+ .prepare(
1245
+ `INSERT INTO change_events (
1246
+ project_id, environment, id, target_node_id, kind, source, summary,
1247
+ service_name, version, occurred_at, received_at
1248
+ ) VALUES ('default', 'default', 'chg-pg-1', 'service:checkout', 'deploy', 'explicit', 'deploy v9.9.9', 'checkout', 'v9.9.9', ?, ?)`,
1249
+ )
1250
+ .bind(now, now)
1251
+ .run();
1252
+ await new UsageStore(db).ingest([
1253
+ usageEvent({
1254
+ eventId: "pg-s1-a",
1255
+ sessionId: "pg-sess-1",
1256
+ pagePath: "/a",
1257
+ }),
1258
+ usageEvent({
1259
+ eventId: "pg-s1-b",
1260
+ sessionId: "pg-sess-1",
1261
+ pagePath: "/b",
1262
+ eventType: "interaction",
1263
+ }),
1264
+ ]);
1265
+
1266
+ const app = new Hono<{ Bindings: CollectorEnv }>();
1267
+ const runtime = new CollectorRuntime();
1268
+ connectedRoutesPlugin.register(app, runtime);
1269
+ usageQueryRoutesPlugin.register(app, runtime);
1270
+ const env = { DB: db as unknown as D1Database } as CollectorEnv;
1271
+
1272
+ const change = await app.request(
1273
+ "/internal/connected/change/chg-pg-1",
1274
+ {},
1275
+ env,
1276
+ );
1277
+ expect(change.status).toBe(200);
1278
+ const manifest = (await change.json()) as {
1279
+ across: Array<{ links: Array<{ entityId: string }> }>;
1280
+ };
1281
+ const spanIds = manifest.across.flatMap((s) =>
1282
+ s.links.map((l) => l.entityId),
1283
+ );
1284
+ // Most recent first, one link per span.
1285
+ expect(spanIds).toEqual(["trace-pg-1:span-pg-b", "trace-pg-1:span-pg-a"]);
1286
+
1287
+ const sessions = await app.request(
1288
+ "/internal/usage/sessions?hours=24",
1289
+ {},
1290
+ env,
1291
+ );
1292
+ expect(sessions.status).toBe(200);
1293
+ const body = (await sessions.json()) as {
1294
+ sessions: Array<{ sessionId: string; lastPath: string | null }>;
1295
+ };
1296
+ expect(body.sessions.map((r) => r.sessionId)).toContain("pg-sess-1");
1297
+ });
1225
1298
  });
@@ -201,7 +201,7 @@ describe("AlertsStore.listEvaluations evidence references", () => {
201
201
  evidenceId: "alert:rule-1:rule",
202
202
  entityKind: "alert",
203
203
  entityId: "rule-1",
204
- route: "#/alerts?alert=rule-1",
204
+ route: "#/alerts?id=rule-1",
205
205
  source: "alerts.rule_evaluation_preview",
206
206
  }),
207
207
  expect.objectContaining({
@@ -503,15 +503,21 @@ export class UsageStore {
503
503
  SUM(CASE WHEN event_type = 'interaction' THEN 1 ELSE 0 END) AS interaction_count,
504
504
  SUM(CASE WHEN event_type = 'frontend_error' OR severity = 'error' THEN 1 ELSE 0 END) AS error_count,
505
505
  MAX(CASE WHEN event_type = 'page_view' THEN CAST(COALESCE(${loadMsExpr}, ${loadTimeMsExpr}, ${durationMsExpr}) AS REAL) ELSE NULL END) AS max_load_ms,
506
- (SELECT referrer FROM usage_events ref WHERE ref.project_id = ue.project_id AND ref.session_id = ue.session_id ORDER BY occurred_at ASC LIMIT 1) AS referrer,
507
- (SELECT page_path FROM usage_events lp WHERE lp.project_id = ue.project_id AND lp.session_id = ue.session_id ORDER BY occurred_at DESC LIMIT 1) AS last_path
506
+ (SELECT referrer FROM usage_events ref WHERE ref.project_id = ? AND ref.session_id = ue.session_id ORDER BY occurred_at ASC LIMIT 1) AS referrer,
507
+ (SELECT page_path FROM usage_events lp WHERE lp.project_id = ? AND lp.session_id = ue.session_id ORDER BY occurred_at DESC LIMIT 1) AS last_path
508
508
  FROM usage_events ue
509
509
  WHERE project_id = ? AND received_at >= ?
510
510
  GROUP BY session_id
511
511
  ORDER BY last_seen DESC
512
512
  LIMIT ?`,
513
513
  )
514
- .bind(options.projectId, cutoff, limit * 3)
514
+ .bind(
515
+ options.projectId,
516
+ options.projectId,
517
+ options.projectId,
518
+ cutoff,
519
+ limit * 3,
520
+ )
515
521
  .all<{
516
522
  session_id: string;
517
523
  visitor_id: string;
@@ -0,0 +1,10 @@
1
+ -- Persisted grant-handoff callback (RFC 0013 § 6.B, journey review J17).
2
+ --
3
+ -- A proposal parked for human approval may carry a webhook that receives the
4
+ -- one-time grant token when the approval happens later. `callback_url` is the
5
+ -- target (SSRF-guarded again at delivery time); `callback_secret` is the HMAC
6
+ -- signing secret, stored as supplied (the collector has no at-rest encryption
7
+ -- helper) and never returned by any GET. Both are cleared once the grant is
8
+ -- delivered, rejected or expired.
9
+ ALTER TABLE remediation_proposals ADD COLUMN callback_url TEXT;
10
+ ALTER TABLE remediation_proposals ADD COLUMN callback_secret TEXT;
@@ -0,0 +1,7 @@
1
+ -- Last grant-handoff webhook delivery outcome (RFC 0013 § 6.B).
2
+ --
3
+ -- JSON `{status: 'delivered'|'failed', attempts, lastError, at}` recorded after
4
+ -- the grant webhook is attempted (up to 3 attempts). It never contains the
5
+ -- callback URL or secret, so unlike callback_url it is kept after approval so
6
+ -- the dashboard can flag a failed handoff.
7
+ ALTER TABLE remediation_proposals ADD COLUMN callback_delivery TEXT;
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { readdirSync, readFileSync } from "node:fs";
10
10
  import path from "node:path";
11
+ import { DatabaseSync } from "node:sqlite";
11
12
  import { fileURLToPath } from "node:url";
12
13
  import { describe, expect, it } from "vitest";
13
14
 
@@ -34,13 +35,74 @@ const RETENTION_TABLES = [
34
35
  "remediation_proposals",
35
36
  ];
36
37
 
38
+ export function checkMigrationFilePairings(
39
+ sqliteFiles: readonly string[],
40
+ postgresFiles: readonly string[],
41
+ ): string[] {
42
+ const errors: string[] = [];
43
+ const sqliteSet = new Set(sqliteFiles);
44
+ const pgSet = new Set(postgresFiles);
45
+ for (const f of sqliteFiles) {
46
+ if (!pgSet.has(f)) {
47
+ errors.push(`Missing Postgres migration counterpart for ${f}`);
48
+ }
49
+ }
50
+ for (const f of postgresFiles) {
51
+ if (!sqliteSet.has(f)) {
52
+ errors.push(`Missing SQLite migration counterpart for ${f}`);
53
+ }
54
+ }
55
+ const seenPrefixes = new Set<string>();
56
+ for (const f of sqliteFiles) {
57
+ const prefix = f.slice(0, 3);
58
+ if (seenPrefixes.has(prefix)) {
59
+ errors.push(`Duplicate migration prefix ${prefix} in ${f}`);
60
+ }
61
+ seenPrefixes.add(prefix);
62
+ }
63
+ return errors;
64
+ }
65
+
66
+ const extractCreatedTables = (sql: string): string[] => {
67
+ const names: string[] = [];
68
+ for (const m of sql.matchAll(
69
+ /CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?\s+([a-zA-Z0-9_]+)/gi,
70
+ )) {
71
+ const table = m[1].toLowerCase();
72
+ if (!table.endsWith("_new")) {
73
+ names.push(table);
74
+ }
75
+ }
76
+ return [...new Set(names)].sort();
77
+ };
78
+
79
+ const extractAddedColumns = (sql: string): string[] => {
80
+ const cols: string[] = [];
81
+ for (const m of sql.matchAll(
82
+ /ALTER\s+TABLE\s+([a-zA-Z0-9_]+)\s+ADD\s+COLUMN(?:\s+IF\s+NOT\s+EXISTS)?\s+([a-zA-Z0-9_]+)/gi,
83
+ )) {
84
+ cols.push(`${m[1].toLowerCase()}.${m[2].toLowerCase()}`);
85
+ }
86
+ return [...new Set(cols)].sort();
87
+ };
88
+
37
89
  describe("migration parity", () => {
38
- it("has the same numbered files on SQLite/D1 and Postgres", () => {
90
+ it("has the same numbered files on SQLite/D1 and Postgres and catches missing pairs", () => {
39
91
  const sqlite = sqlFiles(sqliteDir);
40
92
  const postgres = sqlFiles(postgresDir);
41
93
  expect(postgres).toEqual(sqlite);
42
- const prefixes = sqlite.map((f) => f.slice(0, 3));
43
- expect(new Set(prefixes).size).toBe(prefixes.length);
94
+ expect(sqlite.length).toBeGreaterThanOrEqual(53);
95
+ expect(checkMigrationFilePairings(sqlite, postgres)).toEqual([]);
96
+
97
+ // Regression check: missing or duplicate migration files must be flagged.
98
+ expect(
99
+ checkMigrationFilePairings([...sqlite, "099_new_feature.sql"], postgres),
100
+ ).toEqual([
101
+ "Missing Postgres migration counterpart for 099_new_feature.sql",
102
+ ]);
103
+ expect(
104
+ checkMigrationFilePairings(sqlite, [...postgres, "099_pg_only.sql"]),
105
+ ).toEqual(["Missing SQLite migration counterpart for 099_pg_only.sql"]);
44
106
  });
45
107
 
46
108
  it("declares retention timestamps as TEXT in every Postgres CREATE TABLE", () => {
@@ -65,4 +127,44 @@ describe("migration parity", () => {
65
127
  }
66
128
  expect(offenders).toEqual([]);
67
129
  });
130
+
131
+ it("creates and alters the same tables and columns per migration file", () => {
132
+ const mismatches: string[] = [];
133
+ for (const file of sqlFiles(sqliteDir)) {
134
+ const sqliteSql = readFileSync(path.join(sqliteDir, file), "utf8");
135
+ const pgSql = readFileSync(path.join(postgresDir, file), "utf8");
136
+ const sTables = extractCreatedTables(sqliteSql);
137
+ const pTables = extractCreatedTables(pgSql);
138
+ if (JSON.stringify(sTables) !== JSON.stringify(pTables)) {
139
+ mismatches.push(
140
+ `${file} CREATE TABLE mismatch: sqlite=${JSON.stringify(sTables)} pg=${JSON.stringify(pTables)}`,
141
+ );
142
+ }
143
+ const sCols = extractAddedColumns(sqliteSql);
144
+ const pCols = extractAddedColumns(pgSql);
145
+ if (JSON.stringify(sCols) !== JSON.stringify(pCols)) {
146
+ mismatches.push(
147
+ `${file} ALTER TABLE ADD COLUMN mismatch: sqlite=${JSON.stringify(sCols)} pg=${JSON.stringify(pCols)}`,
148
+ );
149
+ }
150
+ }
151
+ expect(mismatches).toEqual([]);
152
+ });
153
+
154
+ it("applies all SQLite migrations cleanly in-memory without leftover staging tables", () => {
155
+ const db = new DatabaseSync(":memory:");
156
+ try {
157
+ for (const file of sqlFiles(sqliteDir)) {
158
+ db.exec(readFileSync(path.join(sqliteDir, file), "utf8"));
159
+ }
160
+ const leftover = db
161
+ .prepare(
162
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE '%_new'",
163
+ )
164
+ .all();
165
+ expect(leftover).toEqual([]);
166
+ } finally {
167
+ db.close();
168
+ }
169
+ });
68
170
  });
@@ -0,0 +1,10 @@
1
+ -- (PostgreSQL mirror) Persisted grant-handoff callback (RFC 0013 § 6.B, journey review J17).
2
+ --
3
+ -- A proposal parked for human approval may carry a webhook that receives the
4
+ -- one-time grant token when the approval happens later. `callback_url` is the
5
+ -- target (SSRF-guarded again at delivery time); `callback_secret` is the HMAC
6
+ -- signing secret, stored as supplied (the collector has no at-rest encryption
7
+ -- helper) and never returned by any GET. Both are cleared once the grant is
8
+ -- delivered, rejected or expired.
9
+ ALTER TABLE remediation_proposals ADD COLUMN IF NOT EXISTS callback_url TEXT;
10
+ ALTER TABLE remediation_proposals ADD COLUMN IF NOT EXISTS callback_secret TEXT;
@@ -0,0 +1,7 @@
1
+ -- (PostgreSQL mirror) Last grant-handoff webhook delivery outcome (RFC 0013 § 6.B).
2
+ --
3
+ -- JSON `{status: 'delivered'|'failed', attempts, lastError, at}` recorded after
4
+ -- the grant webhook is attempted (up to 3 attempts). It never contains the
5
+ -- callback URL or secret, so unlike callback_url it is kept after approval so
6
+ -- the dashboard can flag a failed handoff.
7
+ ALTER TABLE remediation_proposals ADD COLUMN IF NOT EXISTS callback_delivery TEXT;
@@ -0,0 +1,73 @@
1
+ import type { MiddlewareHandler } from "hono";
2
+ import { describe, expect, it } from "vitest";
3
+ import { createTelemetryCollectorApp } from "../framework/collector";
4
+ import type { CollectorEnv } from "../framework/env";
5
+ import { MemSqlDb } from "../lib/test-utils/mem-sql-db";
6
+ import { aiReceiverPlugin } from "./ai-receiver";
7
+
8
+ const passthrough: MiddlewareHandler<{
9
+ Bindings: CollectorEnv;
10
+ Variables: { projectId: string };
11
+ }> = async (c, next) => {
12
+ c.set("projectId", "default");
13
+ await next();
14
+ };
15
+
16
+ const app = createTelemetryCollectorApp({
17
+ plugins: [aiReceiverPlugin],
18
+ auth: { middleware: passthrough },
19
+ dashboardAuth: { middleware: passthrough, registerRoutes: () => {} },
20
+ });
21
+
22
+ const row = {
23
+ project_id: "default",
24
+ call_id: "call-1",
25
+ trace_id: "trace-1",
26
+ span_id: "span-1",
27
+ service_name: "svc",
28
+ model_name: "gpt-4.1",
29
+ provider: "openai",
30
+ call_type: "chat",
31
+ request_json: null,
32
+ response_json: null,
33
+ prompt_tokens: 10,
34
+ completion_tokens: 5,
35
+ total_cost_usd: 0.01,
36
+ latency_ms: 120,
37
+ is_error: 0,
38
+ error_message: null,
39
+ occurred_at: "2026-05-01T00:00:00.000Z",
40
+ received_at: "2026-05-01T00:00:01.000Z",
41
+ expires_at: "2026-05-02T00:00:00.000Z",
42
+ };
43
+
44
+ describe("GET /internal/ai/calls/:callId", () => {
45
+ it("returns the call with its trace and span ids", async () => {
46
+ const db = new MemSqlDb({
47
+ first: (sql, binds) =>
48
+ sql.includes("FROM ai_calls") && binds[1] === "call-1" ? row : null,
49
+ });
50
+ const res = await app.request("/internal/ai/calls/call-1", {}, {
51
+ DB: db,
52
+ } as unknown as CollectorEnv);
53
+ expect(res.status).toBe(200);
54
+ const body = (await res.json()) as {
55
+ call: { callId: string; modelName: string };
56
+ traceId: string;
57
+ spanId: string;
58
+ };
59
+ expect(body.call.callId).toBe("call-1");
60
+ expect(body.call.modelName).toBe("gpt-4.1");
61
+ expect(body.traceId).toBe("trace-1");
62
+ expect(body.spanId).toBe("span-1");
63
+ expect(db.calls[0].binds).toEqual(["default", "call-1"]);
64
+ });
65
+
66
+ it("answers 404 for an unknown call id", async () => {
67
+ const db = new MemSqlDb({ first: () => null });
68
+ const res = await app.request("/internal/ai/calls/nope", {}, {
69
+ DB: db,
70
+ } as unknown as CollectorEnv);
71
+ expect(res.status).toBe(404);
72
+ });
73
+ });
@@ -16,6 +16,7 @@ import { AIStore, type IngestEvaluation } from "../lib/ai-store";
16
16
  import { sqlDbFor } from "../lib/sql-db";
17
17
  import { getProjectId } from "./_context";
18
18
  import { readJsonObject } from "./_json";
19
+ import { respondWithContract } from "./autosre-routes";
19
20
 
20
21
  export const aiReceiverPlugin: CollectorPlugin = {
21
22
  name: "ai-receiver",
@@ -97,6 +98,20 @@ export const aiReceiverPlugin: CollectorPlugin = {
97
98
  return c.json(response);
98
99
  });
99
100
 
101
+ // One AI call by its call id, with the trace/span it belongs to. Backs
102
+ // the `#/ai/calls/<id>` deep link when the call is outside the loaded list.
103
+ app.get("/internal/ai/calls/:callId", async (c) => {
104
+ const projectId = getProjectId(c);
105
+ const store = new AIStore(sqlDbFor(c.env));
106
+ const call = await store.getAICall(projectId, c.req.param("callId"));
107
+ if (!call) return c.json({ error: "AI call not found" }, 404);
108
+ return respondWithContract(c, "GET /internal/ai/calls/:callId", {
109
+ call,
110
+ traceId: call.traceId,
111
+ spanId: call.spanId,
112
+ });
113
+ });
114
+
100
115
  // OpenInference-kind spans (LLM/TOOL/RETRIEVER/CHAIN/AGENT/...) joined
101
116
  // with their payload side-table. This is the replacement for the flat
102
117
  // ai_calls endpoint — it returns typed spans that live inside the