@obsunified/collector 2.0.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 (254) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +60 -0
  3. package/dist/auth.d.ts +2 -0
  4. package/dist/auth.js +9 -0
  5. package/dist/auth.js.map +1 -0
  6. package/dist/chunk-UGWDOGGH.js +319 -0
  7. package/dist/chunk-UGWDOGGH.js.map +1 -0
  8. package/dist/chunk-XKQTVIRE.js +671 -0
  9. package/dist/chunk-XKQTVIRE.js.map +1 -0
  10. package/dist/index.d.ts +1197 -0
  11. package/dist/index.js +17707 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/ingest-auth-HFVO1zIp.d.ts +165 -0
  14. package/dist/openai-FJYOQ7WO.js +384 -0
  15. package/dist/openai-FJYOQ7WO.js.map +1 -0
  16. package/package.json +73 -0
  17. package/src/ai-parity/ai-parity.acceptance.test.ts +449 -0
  18. package/src/analyses/derive.ts +642 -0
  19. package/src/analyses/index.ts +50 -0
  20. package/src/analyses/investigations.ts +277 -0
  21. package/src/analyses/tier0.ts +568 -0
  22. package/src/auth/dashboard-auth.ts +269 -0
  23. package/src/auth/ingest-auth.ts +147 -0
  24. package/src/durable-objects/tail-hub.test.ts +32 -0
  25. package/src/durable-objects/tail-hub.ts +158 -0
  26. package/src/framework/collector.ts +467 -0
  27. package/src/framework/env.ts +90 -0
  28. package/src/framework/logger.ts +81 -0
  29. package/src/index.ts +211 -0
  30. package/src/lib/action-aggregates.ts +1031 -0
  31. package/src/lib/action-identity.ts +106 -0
  32. package/src/lib/actions-store.ts +397 -0
  33. package/src/lib/ai-pricing.ts +80 -0
  34. package/src/lib/ai-store/calls.ts +148 -0
  35. package/src/lib/ai-store/evaluations.test.ts +47 -0
  36. package/src/lib/ai-store/evaluations.ts +125 -0
  37. package/src/lib/ai-store/helpers.ts +37 -0
  38. package/src/lib/ai-store/retention.ts +17 -0
  39. package/src/lib/ai-store/sessions.ts +277 -0
  40. package/src/lib/ai-store/spans.ts +109 -0
  41. package/src/lib/ai-store/types.ts +112 -0
  42. package/src/lib/ai-store.ts +76 -0
  43. package/src/lib/alerts-store.ts +458 -0
  44. package/src/lib/analyses-runner.ts +439 -0
  45. package/src/lib/analyses-store.test.ts +243 -0
  46. package/src/lib/analyses-store.ts +454 -0
  47. package/src/lib/ask.test.ts +302 -0
  48. package/src/lib/ask.ts +390 -0
  49. package/src/lib/blob-store-s3.ts +213 -0
  50. package/src/lib/blob-store.ts +366 -0
  51. package/src/lib/eval-cases-store.test.ts +171 -0
  52. package/src/lib/eval-cases-store.ts +1033 -0
  53. package/src/lib/evidence-references.test.ts +10 -0
  54. package/src/lib/evidence-references.ts +684 -0
  55. package/src/lib/hash.ts +26 -0
  56. package/src/lib/identity-index/action-lookups.ts +805 -0
  57. package/src/lib/identity-index/constants.ts +1 -0
  58. package/src/lib/identity-index/key-lookups.ts +199 -0
  59. package/src/lib/identity-index/mappers.ts +349 -0
  60. package/src/lib/identity-index/types.ts +196 -0
  61. package/src/lib/identity-index/user-lookup.ts +137 -0
  62. package/src/lib/identity-index.test.ts +624 -0
  63. package/src/lib/identity-index.ts +85 -0
  64. package/src/lib/json.ts +39 -0
  65. package/src/lib/llm.ts +263 -0
  66. package/src/lib/logs-store.ts +188 -0
  67. package/src/lib/metrics-store.test.ts +189 -0
  68. package/src/lib/metrics-store.ts +312 -0
  69. package/src/lib/narrate-gate.test.ts +260 -0
  70. package/src/lib/narrate-gate.ts +253 -0
  71. package/src/lib/openai.test.ts +300 -0
  72. package/src/lib/openai.ts +512 -0
  73. package/src/lib/otlp.ts +212 -0
  74. package/src/lib/parse-pprof.test.ts +64 -0
  75. package/src/lib/parse-pprof.ts +184 -0
  76. package/src/lib/payload-capture.ts +22 -0
  77. package/src/lib/projects-store.ts +207 -0
  78. package/src/lib/propagation-metric.test.ts +196 -0
  79. package/src/lib/propagation-metric.ts +175 -0
  80. package/src/lib/sql-db-dialect.test.ts +22 -0
  81. package/src/lib/sql-db-postgres.ts +229 -0
  82. package/src/lib/sql-db.ts +166 -0
  83. package/src/lib/stage6.test.ts +216 -0
  84. package/src/lib/store/export.ts +65 -0
  85. package/src/lib/store/helpers.ts +367 -0
  86. package/src/lib/store/ingest.ts +68 -0
  87. package/src/lib/store/issues.ts +158 -0
  88. package/src/lib/store/overview.ts +127 -0
  89. package/src/lib/store/retention.ts +10 -0
  90. package/src/lib/store/service-map.ts +384 -0
  91. package/src/lib/store/trace-detail.bench.ts +76 -0
  92. package/src/lib/store/trace-detail.test.ts +410 -0
  93. package/src/lib/store/trace-detail.ts +427 -0
  94. package/src/lib/store/trace-queries.test.ts +29 -0
  95. package/src/lib/store/trace-queries.ts +89 -0
  96. package/src/lib/store-overview.test.ts +77 -0
  97. package/src/lib/store.ts +96 -0
  98. package/src/lib/tail-publisher.ts +64 -0
  99. package/src/lib/test-utils/mem-sql-db.test.ts +93 -0
  100. package/src/lib/test-utils/mem-sql-db.ts +86 -0
  101. package/src/lib/ua-parser.ts +61 -0
  102. package/src/lib/usage-store.ts +567 -0
  103. package/src/migrations/001_telemetry_spans.sql +28 -0
  104. package/src/migrations/002_usage_events.sql +24 -0
  105. package/src/migrations/003_usage_analytics_columns.sql +13 -0
  106. package/src/migrations/004_logs.sql +19 -0
  107. package/src/migrations/005_ai_calls.sql +25 -0
  108. package/src/migrations/006_user_profiles.sql +11 -0
  109. package/src/migrations/007_session_replay_metadata.sql +10 -0
  110. package/src/migrations/008_session_replay_storage_bytes.sql +1 -0
  111. package/src/migrations/009_projects_and_keys.sql +22 -0
  112. package/src/migrations/010_signal_project_id.sql +15 -0
  113. package/src/migrations/011_default_project_seed.sql +3 -0
  114. package/src/migrations/012_alert_rules.sql +17 -0
  115. package/src/migrations/013_alert_evaluations.sql +13 -0
  116. package/src/migrations/014_alert_state.sql +9 -0
  117. package/src/migrations/015_metrics.sql +58 -0
  118. package/src/migrations/016_metrics_exp_histogram_summary.sql +5 -0
  119. package/src/migrations/017_span_dropped_counts.sql +11 -0
  120. package/src/migrations/018_log_flags.sql +8 -0
  121. package/src/migrations/019_ai_span_payloads.sql +30 -0
  122. package/src/migrations/020_ai_span_evaluations.sql +33 -0
  123. package/src/migrations/021_ai_span_session.sql +13 -0
  124. package/src/migrations/022_span_log_session_id.sql +13 -0
  125. package/src/migrations/023_service_map_indexes.sql +32 -0
  126. package/src/migrations/024_analysis_results.sql +49 -0
  127. package/src/migrations/025_stage6_autopinning_alerts.sql +38 -0
  128. package/src/migrations/026_obs_dashboard_project_seed.sql +18 -0
  129. package/src/migrations/027_identity_propagation.sql +50 -0
  130. package/src/migrations/028_profile_blobs.sql +57 -0
  131. package/src/migrations/029_telemetry_sdk_name.sql +20 -0
  132. package/src/migrations/030_analysis_claim.sql +18 -0
  133. package/src/migrations/031_agent_action_graph.sql +154 -0
  134. package/src/migrations/032_payload_capture_flags.sql +8 -0
  135. package/src/migrations/033_eval_cases.sql +52 -0
  136. package/src/migrations/034_eval_case_results.sql +23 -0
  137. package/src/migrations/035_metric_exemplars.sql +33 -0
  138. package/src/migrations/036_trace_instrumentation_gaps.sql +19 -0
  139. package/src/migrations/037_eval_runs.sql +37 -0
  140. package/src/migrations/038_tool_call_evidence.sql +11 -0
  141. package/src/migrations/039_drop_trace_instrumentation_gaps.sql +11 -0
  142. package/src/migrations/040_evidence_retrieval_refs.sql +50 -0
  143. package/src/migrations-postgres/001_telemetry_spans.sql +33 -0
  144. package/src/migrations-postgres/002_usage_events.sql +24 -0
  145. package/src/migrations-postgres/003_usage_analytics_columns.sql +13 -0
  146. package/src/migrations-postgres/004_logs.sql +19 -0
  147. package/src/migrations-postgres/005_ai_calls.sql +25 -0
  148. package/src/migrations-postgres/006_user_profiles.sql +11 -0
  149. package/src/migrations-postgres/007_session_replay_metadata.sql +10 -0
  150. package/src/migrations-postgres/008_session_replay_storage_bytes.sql +2 -0
  151. package/src/migrations-postgres/009_projects_and_keys.sql +25 -0
  152. package/src/migrations-postgres/010_signal_project_id.sql +18 -0
  153. package/src/migrations-postgres/011_default_project_seed.sql +5 -0
  154. package/src/migrations-postgres/012_alert_rules.sql +17 -0
  155. package/src/migrations-postgres/013_alert_evaluations.sql +13 -0
  156. package/src/migrations-postgres/014_alert_state.sql +9 -0
  157. package/src/migrations-postgres/015_metrics.sql +47 -0
  158. package/src/migrations-postgres/016_metrics_exp_histogram_summary.sql +1 -0
  159. package/src/migrations-postgres/017_span_dropped_counts.sql +4 -0
  160. package/src/migrations-postgres/018_log_flags.sql +2 -0
  161. package/src/migrations-postgres/019_ai_span_payloads.sql +18 -0
  162. package/src/migrations-postgres/020_ai_span_evaluations.sql +21 -0
  163. package/src/migrations-postgres/021_ai_span_session.sql +7 -0
  164. package/src/migrations-postgres/022_span_log_session_id.sql +7 -0
  165. package/src/migrations-postgres/023_service_map_indexes.sql +9 -0
  166. package/src/migrations-postgres/024_analysis_results.sql +41 -0
  167. package/src/migrations-postgres/025_stage6_autopinning_alerts.sql +17 -0
  168. package/src/migrations-postgres/026_obs_dashboard_project_seed.sql +14 -0
  169. package/src/migrations-postgres/027_identity_propagation.sql +27 -0
  170. package/src/migrations-postgres/028_profile_blobs.sql +48 -0
  171. package/src/migrations-postgres/029_telemetry_sdk_name.sql +5 -0
  172. package/src/migrations-postgres/030_analysis_claim.sql +5 -0
  173. package/src/migrations-postgres/031_agent_action_graph.sql +190 -0
  174. package/src/migrations-postgres/032_payload_capture_flags.sql +8 -0
  175. package/src/migrations-postgres/033_eval_cases.sql +55 -0
  176. package/src/migrations-postgres/034_eval_case_results.sql +26 -0
  177. package/src/migrations-postgres/035_metric_exemplars.sql +31 -0
  178. package/src/migrations-postgres/036_trace_instrumentation_gaps.sql +19 -0
  179. package/src/migrations-postgres/037_eval_runs.sql +40 -0
  180. package/src/migrations-postgres/038_tool_call_evidence.sql +11 -0
  181. package/src/migrations-postgres/039_drop_trace_instrumentation_gaps.sql +11 -0
  182. package/src/migrations-postgres/040_evidence_retrieval_refs.sql +47 -0
  183. package/src/migrations-postgres/README.md +40 -0
  184. package/src/otlp/acceptance.test.ts +373 -0
  185. package/src/otlp/decode/body.ts +127 -0
  186. package/src/otlp/decode/logs.ts +96 -0
  187. package/src/otlp/decode/metrics.ts +402 -0
  188. package/src/otlp/decode/traces.ts +92 -0
  189. package/src/otlp/decode/values.ts +150 -0
  190. package/src/otlp/decode.test.ts +800 -0
  191. package/src/otlp/decode.ts +11 -0
  192. package/src/otlp/gen/opentelemetry/proto/collector/logs/v1/logs_service_pb.ts +132 -0
  193. package/src/otlp/gen/opentelemetry/proto/collector/metrics/v1/metrics_service_pb.ts +158 -0
  194. package/src/otlp/gen/opentelemetry/proto/collector/trace/v1/trace_service_pb.ts +158 -0
  195. package/src/otlp/gen/opentelemetry/proto/common/v1/common_pb.ts +289 -0
  196. package/src/otlp/gen/opentelemetry/proto/logs/v1/logs_pb.ts +397 -0
  197. package/src/otlp/gen/opentelemetry/proto/metrics/v1/metrics_pb.ts +1329 -0
  198. package/src/otlp/gen/opentelemetry/proto/resource/v1/resource_pb.ts +86 -0
  199. package/src/otlp/gen/opentelemetry/proto/trace/v1/trace_pb.ts +710 -0
  200. package/src/otlp/response.test.ts +158 -0
  201. package/src/otlp/response.ts +129 -0
  202. package/src/plugins/_context.ts +15 -0
  203. package/src/plugins/action-graph-processor/enrichers.ts +45 -0
  204. package/src/plugins/action-graph-processor/redaction.ts +84 -0
  205. package/src/plugins/action-graph-processor.test.ts +1023 -0
  206. package/src/plugins/action-graph-processor.ts +1172 -0
  207. package/src/plugins/action-routes.test.ts +716 -0
  208. package/src/plugins/action-routes.ts +343 -0
  209. package/src/plugins/ai-receiver.ts +254 -0
  210. package/src/plugins/ai-span-payloads-processor.test.ts +90 -0
  211. package/src/plugins/ai-span-payloads-processor.ts +180 -0
  212. package/src/plugins/alerts-evaluator.test.ts +91 -0
  213. package/src/plugins/alerts-evaluator.ts +328 -0
  214. package/src/plugins/alerts-routes.ts +101 -0
  215. package/src/plugins/analyses-routes.ts +138 -0
  216. package/src/plugins/ask-routes.ts +182 -0
  217. package/src/plugins/bot-filter.ts +19 -0
  218. package/src/plugins/connected-routes/manifest.ts +531 -0
  219. package/src/plugins/connected-routes.test.ts +1791 -0
  220. package/src/plugins/connected-routes.ts +1103 -0
  221. package/src/plugins/dashboard-routes.ts +41 -0
  222. package/src/plugins/default-span-enrichment.ts +92 -0
  223. package/src/plugins/eval-cases-routes.test.ts +391 -0
  224. package/src/plugins/eval-cases-routes.ts +529 -0
  225. package/src/plugins/evidence-retrieval-routes.benchmark.test.ts +245 -0
  226. package/src/plugins/evidence-retrieval-routes.test.ts +932 -0
  227. package/src/plugins/evidence-retrieval-routes.ts +2179 -0
  228. package/src/plugins/gen-ai-normalizer.test.ts +680 -0
  229. package/src/plugins/gen-ai-normalizer.ts +302 -0
  230. package/src/plugins/identity-receiver.ts +96 -0
  231. package/src/plugins/issue-enrichment.ts +132 -0
  232. package/src/plugins/issue-insights.ts +81 -0
  233. package/src/plugins/logs-receiver.ts +160 -0
  234. package/src/plugins/metrics-receiver.ts +91 -0
  235. package/src/plugins/onboarding-routes.ts +54 -0
  236. package/src/plugins/otlp-receiver.ts +124 -0
  237. package/src/plugins/platform-routes.ts +165 -0
  238. package/src/plugins/profile-routes.ts +468 -0
  239. package/src/plugins/projects-routes.ts +92 -0
  240. package/src/plugins/query-routes.ts +220 -0
  241. package/src/plugins/redaction-processor.ts +132 -0
  242. package/src/plugins/replay-query-routes.test.ts +38 -0
  243. package/src/plugins/replay-query-routes.ts +199 -0
  244. package/src/plugins/replay-receiver.ts +81 -0
  245. package/src/plugins/tail-routes.test.ts +47 -0
  246. package/src/plugins/tail-routes.ts +42 -0
  247. package/src/plugins/timeline-routes.ts +244 -0
  248. package/src/plugins/ua-enrichment.ts +27 -0
  249. package/src/plugins/usage-normalization.ts +46 -0
  250. package/src/plugins/usage-privacy.ts +55 -0
  251. package/src/plugins/usage-query-routes.ts +102 -0
  252. package/src/plugins/usage-receiver.ts +129 -0
  253. package/src/plugins/users-query-routes.ts +78 -0
  254. package/src/plugins/utm-enrichment.ts +36 -0
@@ -0,0 +1,1103 @@
1
+ /**
2
+ * RFC 0006 Phase 3.1 — Connected rail manifest endpoint.
3
+ *
4
+ * GET /internal/connected/:kind/:id
5
+ *
6
+ * Returns the entity's identity-graph neighbors (Up / Across / Down) +
7
+ * topic neighbors (Related), bundled into the shape the dashboard's
8
+ * <ConnectedRail /> renders directly. Built on `IdentityIndex` so the
9
+ * underlying SQL is shared with `/internal/timeline/:sessionId` and any
10
+ * future cross-signal consumer.
11
+ *
12
+ * The handler is the single read path the rail uses. Sections that have
13
+ * no neighbors render as `links: []` with an `emptyReason` explaining
14
+ * why — never absent. This is the "informative absence" pattern from
15
+ * RFC 0006 § Connected rail / Empty-state.
16
+ */
17
+
18
+ import { ActionConfidence } from "@obsunified/types/constants";
19
+ import type { CollectorPlugin } from "../framework/collector";
20
+ import { IdentityIndex } from "../lib/identity-index";
21
+ import {
22
+ mapAction,
23
+ mapAgentRun,
24
+ mapEvalResult,
25
+ mapToolCall,
26
+ } from "../lib/identity-index/mappers";
27
+ import type {
28
+ ActionRef,
29
+ AgentRunRef,
30
+ EvalResultRef,
31
+ ToolCallRef,
32
+ } from "../lib/identity-index/types";
33
+ import type { SqlDb } from "../lib/sql-db";
34
+ import { sqlDbFor } from "../lib/sql-db";
35
+ import { getProjectId } from "./_context";
36
+
37
+ import {
38
+ type ActionContextLinkMetadata,
39
+ type ConnectedEntityKind,
40
+ type ConnectedManifest,
41
+ KNOWN_KINDS,
42
+ linksFromActions,
43
+ linksFromAi,
44
+ linksFromArtifacts,
45
+ linksFromEvalResults,
46
+ linksFromLogs,
47
+ linksFromMetricExemplars,
48
+ linksFromRetrievalEvents,
49
+ linksFromSpans,
50
+ linksFromToolCalls,
51
+ linksFromUsage,
52
+ linkToAction,
53
+ linkToAgentRun,
54
+ linkToSession,
55
+ linkToTrace,
56
+ MAX_LINKS_INLINE,
57
+ profileLinksForTrace,
58
+ } from "./connected-routes/manifest";
59
+
60
+ export type {
61
+ ConnectedEntityKind,
62
+ ConnectedLink,
63
+ ConnectedManifest,
64
+ ConnectedSection,
65
+ } from "./connected-routes/manifest";
66
+
67
+ interface SignalActionContext {
68
+ actions: ActionRef[];
69
+ agentRuns: AgentRunRef[];
70
+ toolCalls: ToolCallRef[];
71
+ evalResults: EvalResultRef[];
72
+ matchLevel: "span" | "trace" | "none";
73
+ }
74
+
75
+ const emptySignalActionContext = (): SignalActionContext => ({
76
+ actions: [],
77
+ agentRuns: [],
78
+ toolCalls: [],
79
+ evalResults: [],
80
+ matchLevel: "none",
81
+ });
82
+
83
+ const loadSignalActionContext = async (
84
+ db: SqlDb,
85
+ projectId: string,
86
+ traceId: string | null | undefined,
87
+ spanId?: string | null,
88
+ ): Promise<SignalActionContext> => {
89
+ if (!traceId) return emptySignalActionContext();
90
+
91
+ const exactRows = spanId
92
+ ? await db
93
+ .prepare(
94
+ `SELECT *
95
+ FROM actions
96
+ WHERE project_id = ? AND trace_id = ? AND span_id = ?
97
+ ORDER BY started_at ASC
98
+ LIMIT 20`,
99
+ )
100
+ .bind(projectId, traceId, spanId)
101
+ .all<Parameters<typeof mapAction>[0]>()
102
+ : { results: [] as Parameters<typeof mapAction>[0][] };
103
+
104
+ const traceRows =
105
+ exactRows.results.length > 0
106
+ ? exactRows
107
+ : await db
108
+ .prepare(
109
+ `SELECT *
110
+ FROM actions
111
+ WHERE project_id = ? AND trace_id = ?
112
+ ORDER BY started_at ASC
113
+ LIMIT 20`,
114
+ )
115
+ .bind(projectId, traceId)
116
+ .all<Parameters<typeof mapAction>[0]>();
117
+
118
+ const actionRows = traceRows.results;
119
+ if (actionRows.length === 0) return emptySignalActionContext();
120
+
121
+ const actionIds = actionRows.map((action) => action.id);
122
+ const rootActionIds = Array.from(
123
+ new Set(
124
+ actionRows
125
+ .map((action) => action.agent_run_id ?? action.root_action_id)
126
+ .filter((id): id is string => Boolean(id)),
127
+ ),
128
+ );
129
+ const actionPlaceholders = actionIds.map(() => "?").join(", ");
130
+ const rootPlaceholders = rootActionIds.map(() => "?").join(", ");
131
+
132
+ const [toolCalls, evalResults, agentRuns] = await Promise.all([
133
+ db
134
+ .prepare(
135
+ `SELECT *
136
+ FROM tool_calls
137
+ WHERE project_id = ? AND action_id IN (${actionPlaceholders})
138
+ ORDER BY id ASC
139
+ LIMIT 20`,
140
+ )
141
+ .bind(projectId, ...actionIds)
142
+ .all<Parameters<typeof mapToolCall>[0]>(),
143
+ db
144
+ .prepare(
145
+ `SELECT *
146
+ FROM eval_results
147
+ WHERE project_id = ? AND action_id IN (${actionPlaceholders})
148
+ ORDER BY id ASC
149
+ LIMIT 20`,
150
+ )
151
+ .bind(projectId, ...actionIds)
152
+ .all<Parameters<typeof mapEvalResult>[0]>(),
153
+ rootActionIds.length > 0
154
+ ? db
155
+ .prepare(
156
+ `SELECT *
157
+ FROM agent_runs
158
+ WHERE project_id = ? AND id IN (${rootPlaceholders})
159
+ ORDER BY id ASC
160
+ LIMIT 20`,
161
+ )
162
+ .bind(projectId, ...rootActionIds)
163
+ .all<Parameters<typeof mapAgentRun>[0]>()
164
+ : { results: [] as Parameters<typeof mapAgentRun>[0][] },
165
+ ]);
166
+
167
+ return {
168
+ actions: actionRows.map(mapAction),
169
+ agentRuns: agentRuns.results.map(mapAgentRun),
170
+ toolCalls: toolCalls.results.map(mapToolCall),
171
+ evalResults: evalResults.results.map(mapEvalResult),
172
+ matchLevel: exactRows.results.length > 0 ? "span" : "trace",
173
+ };
174
+ };
175
+
176
+ const actionMatchEmptyReason = (context: SignalActionContext): string =>
177
+ context.matchLevel === "trace"
178
+ ? "No exact span-level action existed, so these links use trace-level action context."
179
+ : "No action graph records share this signal's trace/span identity.";
180
+
181
+ const actionContextLinkMetadata = (
182
+ context: SignalActionContext,
183
+ entityLabel: string,
184
+ ): ActionContextLinkMetadata => {
185
+ if (context.matchLevel === "trace") {
186
+ return {
187
+ source: "trace_id",
188
+ causalConfidence: ActionConfidence.Fallback,
189
+ confidence: 0.55,
190
+ reason: `No exact span-level action matched this ${entityLabel}; linked by shared trace_id instead.`,
191
+ };
192
+ }
193
+ return {
194
+ source: "trace_id+span_id",
195
+ confidence: 0.95,
196
+ reason: `Action context matched this ${entityLabel} by exact trace_id and span_id.`,
197
+ };
198
+ };
199
+
200
+ const metadataForSignalActions = (
201
+ context: SignalActionContext,
202
+ entityLabel: string,
203
+ ): ((actionId: string) => ActionContextLinkMetadata | undefined) => {
204
+ const byActionId = new Map(
205
+ context.actions.map((action) => {
206
+ const metadata = actionContextLinkMetadata(context, entityLabel);
207
+ return [
208
+ action.id,
209
+ context.matchLevel === "span"
210
+ ? { ...metadata, causalConfidence: action.causalConfidence }
211
+ : metadata,
212
+ ] as const;
213
+ }),
214
+ );
215
+ return (actionId) => byActionId.get(actionId);
216
+ };
217
+
218
+ const traceLevelMetadata = (
219
+ entityLabel: string,
220
+ ): ActionContextLinkMetadata => ({
221
+ source: "trace_id",
222
+ causalConfidence: ActionConfidence.Fallback,
223
+ confidence: 0.55,
224
+ reason: `Linked ${entityLabel} through sampled trace_id context; no exact span-level causal edge is implied.`,
225
+ });
226
+
227
+ const linksForAgentRuns = (
228
+ runs: AgentRunRef[],
229
+ context: SignalActionContext,
230
+ entityLabel: string,
231
+ ) =>
232
+ runs.slice(0, MAX_LINKS_INLINE).map((run) => ({
233
+ ...linkToAgentRun(run.id, `${run.agentName} (v${run.agentVersion})`),
234
+ ...actionContextLinkMetadata(context, entityLabel),
235
+ }));
236
+
237
+ export const connectedRoutesPlugin: CollectorPlugin = {
238
+ name: "connected-routes",
239
+ register(app) {
240
+ app.get("/internal/connected/:kind/:id", async (c) => {
241
+ const projectId = getProjectId(c);
242
+ const rawKind = c.req.param("kind");
243
+ const id = c.req.param("id");
244
+ if (!id) {
245
+ return c.json({ error: "id required" }, 400);
246
+ }
247
+ if (!KNOWN_KINDS.has(rawKind)) {
248
+ // Unknown kinds used to fall through every `if` branch and
249
+ // return 200 + empty sections, which hid client bugs.
250
+ return c.json(
251
+ {
252
+ error: `unknown entity kind: ${rawKind}`,
253
+ known: [...KNOWN_KINDS],
254
+ },
255
+ 400,
256
+ );
257
+ }
258
+ const kind = rawKind as ConnectedEntityKind;
259
+
260
+ const index = new IdentityIndex(sqlDbFor(c.env));
261
+
262
+ // Each entity kind starts with a different lookup key. The
263
+ // helpers above shape ref arrays into ConnectedSection.
264
+ const manifest: ConnectedManifest = {
265
+ entity: { kind, id, projectId },
266
+ up: [],
267
+ across: [],
268
+ down: [],
269
+ related: [],
270
+ };
271
+
272
+ if (kind === "span") {
273
+ // Span: load by trace_id (its parent chain) + by interaction_id
274
+ // (the click that caused it). Then bucket.
275
+ const traceManifest = await index.byTrace(
276
+ projectId,
277
+ id.split(":")[0] ?? id,
278
+ );
279
+ const span = traceManifest.spans.find(
280
+ (s) => `${s.traceId}:${s.spanId}` === id,
281
+ );
282
+ if (span) {
283
+ const actionContext = await loadSignalActionContext(
284
+ sqlDbFor(c.env),
285
+ projectId,
286
+ span.traceId,
287
+ span.spanId,
288
+ );
289
+ const otherSpans = traceManifest.spans.filter(
290
+ (s) => s.spanId !== span.spanId,
291
+ );
292
+ manifest.up = [
293
+ {
294
+ label: "Trace",
295
+ links: [linkToTrace(span.traceId, "Parent trace")],
296
+ },
297
+ ];
298
+ manifest.across = [
299
+ linksFromSpans(otherSpans, `Other spans in this trace`),
300
+ linksFromLogs(traceManifest.logs, "Logs in this trace"),
301
+ linksFromAi(traceManifest.aiCalls, "AI calls in this trace"),
302
+ ];
303
+ // RFC 0009 acceptance #5 — surface pprof profiles
304
+ // (CPU + off-CPU) covering this trace's window in the
305
+ // rail's Down section. Each profile_type renders as
306
+ // its own link so a viewer can see at a glance whether
307
+ // only CPU was sampled or off-CPU is present too.
308
+ manifest.down = [
309
+ ...(await profileLinksForTrace(
310
+ sqlDbFor(c.env),
311
+ projectId,
312
+ span.traceId,
313
+ )),
314
+ linksFromActions(
315
+ actionContext.actions,
316
+ actionContext.matchLevel === "span"
317
+ ? "Causal actions for this span"
318
+ : "Trace-level actions for this span",
319
+ actionContextLinkMetadata(actionContext, "span"),
320
+ ),
321
+ linksFromToolCalls(
322
+ actionContext.toolCalls,
323
+ actionContext.matchLevel === "span"
324
+ ? "Tool calls for this span"
325
+ : "Trace-level tool calls for this span",
326
+ metadataForSignalActions(actionContext, "span"),
327
+ ),
328
+ linksFromEvalResults(
329
+ actionContext.evalResults,
330
+ actionContext.matchLevel === "span"
331
+ ? "Evaluations for this span"
332
+ : "Trace-level evaluations for this span",
333
+ metadataForSignalActions(actionContext, "span"),
334
+ ),
335
+ linksFromMetricExemplars(
336
+ traceManifest.metricExemplars.filter(
337
+ (e) => !e.spanId || e.spanId === span.spanId,
338
+ ),
339
+ "Metric exemplars in this trace",
340
+ ),
341
+ ];
342
+ if (span.interactionId) {
343
+ const interactionManifest = await index.byInteraction(
344
+ projectId,
345
+ span.interactionId,
346
+ );
347
+ manifest.related = [
348
+ linksFromUsage(
349
+ interactionManifest.usageEvents,
350
+ `Click that caused this trace`,
351
+ ),
352
+ ];
353
+ } else {
354
+ manifest.related = [
355
+ {
356
+ label: "Originating click",
357
+ links: [],
358
+ emptyReason:
359
+ "Server-originated work (cron, retry, queue consumer) — not bound to a user click.",
360
+ },
361
+ ];
362
+ }
363
+ if (actionContext.agentRuns.length > 0) {
364
+ manifest.related.push({
365
+ label:
366
+ actionContext.matchLevel === "span"
367
+ ? "Agent runs for this span"
368
+ : "Trace-level agent runs for this span",
369
+ links: linksForAgentRuns(
370
+ actionContext.agentRuns,
371
+ actionContext,
372
+ "span",
373
+ ),
374
+ });
375
+ } else {
376
+ manifest.related.push({
377
+ label: "Agent runs for this span",
378
+ links: [],
379
+ emptyReason: actionMatchEmptyReason(actionContext),
380
+ });
381
+ }
382
+ }
383
+ } else if (kind === "profile") {
384
+ const db = sqlDbFor(c.env);
385
+ const profile = await db
386
+ .prepare(
387
+ `SELECT id, service_name, profile_type, start_ts, end_ts,
388
+ duration_ms, sample_count, agent
389
+ FROM profile_blobs
390
+ WHERE project_id = ? AND id = ?
391
+ LIMIT 1`,
392
+ )
393
+ .bind(projectId, id)
394
+ .first<{
395
+ id: string;
396
+ service_name: string | null;
397
+ profile_type: string;
398
+ start_ts: string;
399
+ end_ts: string;
400
+ duration_ms: number;
401
+ sample_count: number | null;
402
+ agent: string | null;
403
+ }>();
404
+
405
+ if (profile) {
406
+ const traceRows = await db
407
+ .prepare(
408
+ `SELECT trace_id
409
+ FROM profile_trace_index
410
+ WHERE project_id = ? AND profile_id = ?
411
+ ORDER BY trace_id ASC
412
+ LIMIT 50`,
413
+ )
414
+ .bind(projectId, id)
415
+ .all<{ trace_id: string }>();
416
+ const traceIds = traceRows.results.map((row) => row.trace_id);
417
+ const sampledTraceIds = traceIds.slice(0, MAX_LINKS_INLINE);
418
+ const traceManifests = await Promise.all(
419
+ sampledTraceIds.map((traceId) => index.byTrace(projectId, traceId)),
420
+ );
421
+ const spans = traceManifests.flatMap((m) => m.spans);
422
+ const logs = traceManifests.flatMap((m) => m.logs);
423
+ const aiCalls = traceManifests.flatMap((m) => m.aiCalls);
424
+ const metricExemplars = traceManifests.flatMap(
425
+ (m) => m.metricExemplars,
426
+ );
427
+
428
+ manifest.up = [
429
+ {
430
+ label: "Profile window",
431
+ links: [],
432
+ emptyReason: `${profile.service_name ?? "unknown service"} · ${profile.profile_type} · ${profile.duration_ms}ms · ${profile.sample_count ?? "unknown"} samples`,
433
+ },
434
+ ];
435
+ manifest.across = [
436
+ traceIds.length > 0
437
+ ? {
438
+ label: "Sampled traces",
439
+ links:
440
+ traceIds.length > MAX_LINKS_INLINE
441
+ ? [
442
+ {
443
+ label: `${traceIds.length} sampled traces`,
444
+ href: `#/traces?trace=${encodeURIComponent(traceIds[0])}`,
445
+ count: traceIds.length,
446
+ sample: traceIds[0],
447
+ },
448
+ ]
449
+ : traceIds.map((traceId) => linkToTrace(traceId)),
450
+ }
451
+ : {
452
+ label: "Sampled traces",
453
+ links: [],
454
+ emptyReason:
455
+ "No trace_id labels were indexed for this profile. Push pprof samples with trace_id labels or x-obs-trace-ids to enable trace pivots.",
456
+ },
457
+ linksFromSpans(spans, "Sampled spans"),
458
+ linksFromLogs(logs, "Logs in sampled traces"),
459
+ linksFromAi(aiCalls, "AI calls in sampled traces"),
460
+ linksFromMetricExemplars(
461
+ metricExemplars,
462
+ "Metric exemplars in sampled traces",
463
+ ),
464
+ ];
465
+
466
+ if (traceIds.length > 0) {
467
+ const placeholders = traceIds.map(() => "?").join(", ");
468
+ const actions = await db
469
+ .prepare(
470
+ `SELECT id, action_kind, name
471
+ FROM actions
472
+ WHERE project_id = ? AND trace_id IN (${placeholders})
473
+ ORDER BY started_at ASC
474
+ LIMIT 20`,
475
+ )
476
+ .bind(projectId, ...traceIds)
477
+ .all<{
478
+ id: string;
479
+ action_kind: string;
480
+ name: string | null;
481
+ }>();
482
+ const actionIds = actions.results.map((action) => action.id);
483
+ const actionPlaceholders = actionIds.map(() => "?").join(", ");
484
+
485
+ const toolCalls =
486
+ actionIds.length > 0
487
+ ? await db
488
+ .prepare(
489
+ `SELECT id, tool_name
490
+ FROM tool_calls
491
+ WHERE project_id = ? AND action_id IN (${actionPlaceholders})
492
+ ORDER BY id ASC
493
+ LIMIT 20`,
494
+ )
495
+ .bind(projectId, ...actionIds)
496
+ .all<{ id: string; tool_name: string }>()
497
+ : { results: [] as Array<{ id: string; tool_name: string }> };
498
+
499
+ const agentRuns = await db
500
+ .prepare(
501
+ `SELECT DISTINCT ar.id, ar.agent_name, ar.agent_version
502
+ FROM actions a
503
+ JOIN agent_runs ar
504
+ ON ar.project_id = a.project_id
505
+ AND ar.id = COALESCE(a.agent_run_id, a.root_action_id)
506
+ WHERE a.project_id = ? AND a.trace_id IN (${placeholders})
507
+ ORDER BY ar.id ASC
508
+ LIMIT 20`,
509
+ )
510
+ .bind(projectId, ...traceIds)
511
+ .all<{
512
+ id: string;
513
+ agent_name: string;
514
+ agent_version: string;
515
+ }>();
516
+
517
+ manifest.down = [
518
+ actions.results.length > 0
519
+ ? {
520
+ label: "Causal actions in sampled traces",
521
+ links: actions.results
522
+ .slice(0, MAX_LINKS_INLINE)
523
+ .map((action) => ({
524
+ ...linkToAction(
525
+ action.id,
526
+ `[${action.action_kind}] ${action.name ?? action.id}`,
527
+ ),
528
+ ...traceLevelMetadata("profile action"),
529
+ })),
530
+ }
531
+ : {
532
+ label: "Causal actions in sampled traces",
533
+ links: [],
534
+ emptyReason:
535
+ "No action graph records share this profile's sampled trace IDs.",
536
+ },
537
+ toolCalls.results.length > 0
538
+ ? {
539
+ label: "Tool calls in sampled traces",
540
+ links: toolCalls.results
541
+ .slice(0, MAX_LINKS_INLINE)
542
+ .map((toolCall) => ({
543
+ label: `tool: ${toolCall.tool_name}`,
544
+ href: `#/tool-calls/${toolCall.id}`,
545
+ entityKind: "tool_call",
546
+ entityId: toolCall.id,
547
+ ...traceLevelMetadata("profile tool call"),
548
+ })),
549
+ }
550
+ : {
551
+ label: "Tool calls in sampled traces",
552
+ links: [],
553
+ emptyReason:
554
+ "No tool calls were attached to actions in this profile.",
555
+ },
556
+ ];
557
+
558
+ manifest.related =
559
+ agentRuns.results.length > 0
560
+ ? [
561
+ {
562
+ label: "Agent runs",
563
+ links: agentRuns.results
564
+ .slice(0, MAX_LINKS_INLINE)
565
+ .map((run) => ({
566
+ ...linkToAgentRun(
567
+ run.id,
568
+ `${run.agent_name} (v${run.agent_version})`,
569
+ ),
570
+ ...traceLevelMetadata("profile agent run"),
571
+ })),
572
+ },
573
+ ]
574
+ : [
575
+ {
576
+ label: "Agent runs",
577
+ links: [],
578
+ emptyReason:
579
+ "No agent runs were attached to actions in this profile.",
580
+ },
581
+ ];
582
+ } else {
583
+ manifest.down = [
584
+ {
585
+ label: "Causal actions in sampled traces",
586
+ links: [],
587
+ emptyReason:
588
+ "No action graph records can be joined because this profile has no indexed trace IDs.",
589
+ },
590
+ {
591
+ label: "Tool calls in sampled traces",
592
+ links: [],
593
+ emptyReason:
594
+ "No tool calls can be joined because this profile has no indexed trace IDs.",
595
+ },
596
+ ];
597
+ manifest.related = [
598
+ {
599
+ label: "Agent runs",
600
+ links: [],
601
+ emptyReason:
602
+ "No agent runs can be joined because this profile has no indexed trace IDs.",
603
+ },
604
+ ];
605
+ }
606
+ }
607
+ } else if (kind === "log") {
608
+ // Log: resolve direct log ids when the caller did not already carry
609
+ // trace/session params, then add exact action graph pivots.
610
+ const db = sqlDbFor(c.env);
611
+ const logRow = await db
612
+ .prepare(
613
+ `SELECT trace_id, span_id, session_id
614
+ FROM logs
615
+ WHERE project_id = ? AND log_id = ?
616
+ LIMIT 1`,
617
+ )
618
+ .bind(projectId, id)
619
+ .first<{
620
+ trace_id: string | null;
621
+ span_id: string | null;
622
+ session_id: string | null;
623
+ }>();
624
+ const traceId =
625
+ c.req.query("trace_id") ?? logRow?.trace_id ?? undefined;
626
+ const spanId = c.req.query("span_id") ?? logRow?.span_id ?? undefined;
627
+ const sessionId =
628
+ c.req.query("session_id") ?? logRow?.session_id ?? undefined;
629
+ if (traceId) {
630
+ const traceManifest = await index.byTrace(projectId, traceId);
631
+ const actionContext = await loadSignalActionContext(
632
+ db,
633
+ projectId,
634
+ traceId,
635
+ spanId,
636
+ );
637
+ manifest.up = [
638
+ {
639
+ label: "Trace",
640
+ links: [linkToTrace(traceId, "Parent trace")],
641
+ },
642
+ ];
643
+ manifest.across = [
644
+ linksFromLogs(
645
+ traceManifest.logs.filter((l) => l.logId !== id),
646
+ "Other logs in this trace",
647
+ ),
648
+ ];
649
+ manifest.down = [
650
+ linksFromActions(
651
+ actionContext.actions,
652
+ actionContext.matchLevel === "span"
653
+ ? "Causal actions for this log"
654
+ : "Trace-level actions for this log",
655
+ actionContextLinkMetadata(actionContext, "log"),
656
+ ),
657
+ linksFromToolCalls(
658
+ actionContext.toolCalls,
659
+ actionContext.matchLevel === "span"
660
+ ? "Tool calls for this log"
661
+ : "Trace-level tool calls for this log",
662
+ metadataForSignalActions(actionContext, "log"),
663
+ ),
664
+ linksFromEvalResults(
665
+ actionContext.evalResults,
666
+ actionContext.matchLevel === "span"
667
+ ? "Evaluations for this log"
668
+ : "Trace-level evaluations for this log",
669
+ metadataForSignalActions(actionContext, "log"),
670
+ ),
671
+ ];
672
+ manifest.related = [
673
+ actionContext.agentRuns.length > 0
674
+ ? {
675
+ label:
676
+ actionContext.matchLevel === "span"
677
+ ? "Agent runs for this log"
678
+ : "Trace-level agent runs for this log",
679
+ links: actionContext.agentRuns
680
+ .slice(0, MAX_LINKS_INLINE)
681
+ .map((run) => ({
682
+ ...linkToAgentRun(
683
+ run.id,
684
+ `${run.agentName} (v${run.agentVersion})`,
685
+ ),
686
+ ...actionContextLinkMetadata(actionContext, "log"),
687
+ })),
688
+ }
689
+ : {
690
+ label: "Agent runs for this log",
691
+ links: [],
692
+ emptyReason: actionMatchEmptyReason(actionContext),
693
+ },
694
+ ];
695
+ }
696
+ if (sessionId) {
697
+ const sessionManifest = await index.bySession(projectId, sessionId);
698
+ manifest.across.push(
699
+ linksFromUsage(
700
+ sessionManifest.usageEvents,
701
+ "Usage events in this session",
702
+ ),
703
+ );
704
+ if (sessionManifest.replay) {
705
+ manifest.across.push({
706
+ label: "Replay",
707
+ links: [linkToSession(sessionId)],
708
+ });
709
+ }
710
+ }
711
+ } else if (kind === "usage" || kind === "replay") {
712
+ // Usage / replay events are session-scoped — load by session_id
713
+ // from the query string.
714
+ const sessionId = c.req.query("session_id") ?? id;
715
+ const sessionManifest = await index.bySession(projectId, sessionId);
716
+ manifest.up = [
717
+ {
718
+ label: "Session",
719
+ links: [linkToSession(sessionId)],
720
+ },
721
+ ];
722
+ manifest.across = [
723
+ linksFromSpans(sessionManifest.spans, "Spans in this session"),
724
+ linksFromLogs(sessionManifest.logs, "Logs in this session"),
725
+ linksFromAi(sessionManifest.aiCalls, "AI calls in this session"),
726
+ ];
727
+ manifest.related = [
728
+ sessionManifest.replay
729
+ ? {
730
+ label: "Replay",
731
+ links: [linkToSession(sessionId)],
732
+ }
733
+ : {
734
+ label: "Replay",
735
+ links: [],
736
+ emptyReason: "No rrweb replay recorded for this session.",
737
+ },
738
+ ];
739
+ } else if (kind === "ai_call") {
740
+ const db = sqlDbFor(c.env);
741
+ const callRow = await db
742
+ .prepare(
743
+ `SELECT trace_id, span_id, session_id
744
+ FROM ai_calls
745
+ WHERE project_id = ? AND call_id = ?
746
+ LIMIT 1`,
747
+ )
748
+ .bind(projectId, id)
749
+ .first<{
750
+ trace_id: string | null;
751
+ span_id: string | null;
752
+ session_id: string | null;
753
+ }>();
754
+ const traceId =
755
+ c.req.query("trace_id") ?? callRow?.trace_id ?? undefined;
756
+ const spanId = c.req.query("span_id") ?? callRow?.span_id ?? undefined;
757
+ if (traceId) {
758
+ const traceManifest = await index.byTrace(projectId, traceId);
759
+ const actionContext = await loadSignalActionContext(
760
+ db,
761
+ projectId,
762
+ traceId,
763
+ spanId,
764
+ );
765
+ manifest.up = [
766
+ {
767
+ label: "Trace",
768
+ links: [linkToTrace(traceId, "Parent trace")],
769
+ },
770
+ ];
771
+ manifest.across = [
772
+ linksFromSpans(traceManifest.spans, "Spans in this trace"),
773
+ linksFromAi(
774
+ traceManifest.aiCalls.filter((a) => a.callId !== id),
775
+ "Other AI calls in this trace",
776
+ ),
777
+ linksFromMetricExemplars(
778
+ traceManifest.metricExemplars,
779
+ "Metric exemplars in this trace",
780
+ ),
781
+ ];
782
+ manifest.down = [
783
+ linksFromActions(
784
+ actionContext.actions,
785
+ actionContext.matchLevel === "span"
786
+ ? "Causal actions for this AI call"
787
+ : "Trace-level actions for this AI call",
788
+ actionContextLinkMetadata(actionContext, "AI call"),
789
+ ),
790
+ linksFromToolCalls(
791
+ actionContext.toolCalls,
792
+ actionContext.matchLevel === "span"
793
+ ? "Tool calls for this AI call"
794
+ : "Trace-level tool calls for this AI call",
795
+ metadataForSignalActions(actionContext, "AI call"),
796
+ ),
797
+ linksFromEvalResults(
798
+ actionContext.evalResults,
799
+ actionContext.matchLevel === "span"
800
+ ? "Evaluations for this AI call"
801
+ : "Trace-level evaluations for this AI call",
802
+ metadataForSignalActions(actionContext, "AI call"),
803
+ ),
804
+ ];
805
+ manifest.related = [
806
+ actionContext.agentRuns.length > 0
807
+ ? {
808
+ label:
809
+ actionContext.matchLevel === "span"
810
+ ? "Agent runs for this AI call"
811
+ : "Trace-level agent runs for this AI call",
812
+ links: actionContext.agentRuns
813
+ .slice(0, MAX_LINKS_INLINE)
814
+ .map((run) => ({
815
+ ...linkToAgentRun(
816
+ run.id,
817
+ `${run.agentName} (v${run.agentVersion})`,
818
+ ),
819
+ ...actionContextLinkMetadata(actionContext, "AI call"),
820
+ })),
821
+ }
822
+ : {
823
+ label: "Agent runs for this AI call",
824
+ links: [],
825
+ emptyReason: actionMatchEmptyReason(actionContext),
826
+ },
827
+ ];
828
+ }
829
+ const sessionId =
830
+ c.req.query("session_id") ?? callRow?.session_id ?? undefined;
831
+ if (sessionId) {
832
+ manifest.across.push({
833
+ label: "Session",
834
+ links: [linkToSession(sessionId)],
835
+ });
836
+ }
837
+ } else if (kind === "user") {
838
+ // RFC 0006 Scenario B — pivot from a user. Loads the user's
839
+ // most recent sessions and surfaces top-line activity in
840
+ // `across`. The "first" session is the canonical "latest
841
+ // session" link used by the AI-cost-spike walkthrough.
842
+ const userManifest = await index.byUser(projectId, id, {
843
+ sessions: 5,
844
+ limit: 50,
845
+ });
846
+ const recentSessions = Array.from(
847
+ new Set(
848
+ userManifest.usageEvents
849
+ .map((e) => e.sessionId)
850
+ .filter((s): s is string => Boolean(s)),
851
+ ),
852
+ );
853
+ manifest.up = [
854
+ {
855
+ label: "User",
856
+ links: [],
857
+ emptyReason: "User is the root identity — no parent context above.",
858
+ },
859
+ ];
860
+ if (recentSessions.length > 0) {
861
+ manifest.across = [
862
+ {
863
+ label: "Latest session",
864
+ links: [linkToSession(recentSessions[0])],
865
+ },
866
+ {
867
+ label: "Recent sessions",
868
+ links: recentSessions
869
+ .slice(0, MAX_LINKS_INLINE)
870
+ .map((sid) => linkToSession(sid)),
871
+ },
872
+ linksFromSpans(userManifest.spans, "Recent traces"),
873
+ linksFromAi(userManifest.aiCalls, "Recent AI calls"),
874
+ ];
875
+ } else {
876
+ manifest.across = [
877
+ {
878
+ label: "Sessions",
879
+ links: [],
880
+ emptyReason:
881
+ "No sessions found for this user. user_profiles needs a visitor_id, and that visitor needs at least one usage_event with a session_id.",
882
+ },
883
+ ];
884
+ }
885
+ if (userManifest.replay) {
886
+ manifest.related = [
887
+ {
888
+ label: "Replay",
889
+ links: [linkToSession(userManifest.replay.sessionId)],
890
+ },
891
+ ];
892
+ }
893
+ } else if (kind === "action") {
894
+ const actionManifest = await index.byAction(projectId, id);
895
+ const action = actionManifest.actions.find((a) => a.id === id);
896
+ if (action) {
897
+ manifest.up = [];
898
+ if (action.causedByActionId) {
899
+ manifest.up.push({
900
+ label: "Parent Action",
901
+ links: [linkToAction(action.causedByActionId)],
902
+ });
903
+ }
904
+ if (action.agentRunId) {
905
+ manifest.up.push({
906
+ label: "Agent Run",
907
+ links: [linkToAgentRun(action.agentRunId)],
908
+ });
909
+ }
910
+ if (manifest.up.length === 0) {
911
+ manifest.up.push({
912
+ label: "Up",
913
+ links: [],
914
+ emptyReason:
915
+ "No parent context: this action is the root of its execution tree.",
916
+ });
917
+ }
918
+
919
+ manifest.across = [
920
+ linksFromActions(
921
+ actionManifest.actions.filter(
922
+ (a) =>
923
+ a.id !== id && a.causedByActionId === action.causedByActionId,
924
+ ),
925
+ "Sibling Actions",
926
+ ),
927
+ linksFromLogs(actionManifest.logs, "Logs in this action context"),
928
+ linksFromAi(actionManifest.aiCalls, "AI Calls in this action"),
929
+ ];
930
+
931
+ manifest.down = [
932
+ linksFromActions(
933
+ actionManifest.actions.filter((a) => a.causedByActionId === id),
934
+ "Sub-actions",
935
+ ),
936
+ linksFromToolCalls(
937
+ actionManifest.toolCalls.filter((t) => t.actionId === id),
938
+ "Tool Calls",
939
+ ),
940
+ linksFromRetrievalEvents(
941
+ actionManifest.retrievalEvents.filter((r) => r.actionId === id),
942
+ "Retrievals",
943
+ ),
944
+ linksFromEvalResults(
945
+ actionManifest.evalResults.filter((e) => e.actionId === id),
946
+ "Evaluations",
947
+ ),
948
+ linksFromArtifacts(
949
+ actionManifest.artifacts.filter((a) => a.actionId === id),
950
+ "Artifacts",
951
+ ),
952
+ ];
953
+
954
+ if (action.traceId) {
955
+ manifest.related = [
956
+ {
957
+ label: "OTel Trace",
958
+ links: [linkToTrace(action.traceId)],
959
+ },
960
+ ];
961
+ }
962
+ manifest.rawManifest = actionManifest;
963
+ }
964
+ } else if (kind === "agent_run") {
965
+ const runManifest = await index.byAgentRun(projectId, id);
966
+ const run = runManifest.agentRuns.find((r) => r.id === id);
967
+ if (run) {
968
+ manifest.up = [
969
+ {
970
+ label: "Agent",
971
+ links: [
972
+ {
973
+ label: `${run.agentName} (v${run.agentVersion})`,
974
+ href: `#/agents/${run.agentId}`,
975
+ },
976
+ ],
977
+ },
978
+ ];
979
+
980
+ manifest.across = [
981
+ linksFromSpans(runManifest.spans, "Traces triggered by this run"),
982
+ linksFromAi(runManifest.aiCalls, "AI Calls in this run"),
983
+ ];
984
+
985
+ manifest.down = [
986
+ linksFromActions(
987
+ runManifest.actions.filter((a) => a.rootActionId === id),
988
+ "Actions (Decision Spine)",
989
+ ),
990
+ linksFromToolCalls(runManifest.toolCalls, "Tool Calls Executed"),
991
+ ];
992
+
993
+ if (runManifest.replay) {
994
+ manifest.related = [
995
+ {
996
+ label: "User Session",
997
+ links: [linkToSession(runManifest.replay.sessionId)],
998
+ },
999
+ ];
1000
+ }
1001
+ manifest.rawManifest = runManifest;
1002
+ }
1003
+ } else if (kind === "tool_call") {
1004
+ const db = sqlDbFor(c.env);
1005
+ const toolCallRow = await db
1006
+ .prepare(
1007
+ `SELECT action_id, tool_name FROM tool_calls WHERE project_id = ? AND id = ? LIMIT 1`,
1008
+ )
1009
+ .bind(projectId, id)
1010
+ .first<{ action_id: string; tool_name: string }>();
1011
+
1012
+ if (toolCallRow) {
1013
+ const actionManifest = await index.byAction(
1014
+ projectId,
1015
+ toolCallRow.action_id,
1016
+ );
1017
+ const toolCall = actionManifest.toolCalls.find((t) => t.id === id);
1018
+ if (toolCall) {
1019
+ manifest.up = [
1020
+ {
1021
+ label: "Causal Action",
1022
+ links: [linkToAction(toolCall.actionId)],
1023
+ },
1024
+ ];
1025
+
1026
+ manifest.across = [
1027
+ linksFromToolCalls(
1028
+ actionManifest.toolCalls.filter((t) => t.id !== id),
1029
+ "Other tool calls in this action",
1030
+ ),
1031
+ ];
1032
+
1033
+ manifest.down = [
1034
+ {
1035
+ label: "Tool Details",
1036
+ links: [],
1037
+ emptyReason: `Tool: ${toolCall.toolName} (hash: ${toolCall.argsHash.slice(0, 8)})`,
1038
+ },
1039
+ ];
1040
+ manifest.rawManifest = actionManifest;
1041
+ }
1042
+ }
1043
+ } else if (kind === "alert" || kind === "analysis") {
1044
+ // Alerts and Analyses are topic-related, not identity-related.
1045
+ // Their connections come from the alert_rules.analysis_id and
1046
+ // analysis_results join, not from the identity graph.
1047
+ // For now, return empty-explanatory sections. A follow-up
1048
+ // can route through AlertsStore + AnalysesStore for the
1049
+ // topic links.
1050
+ manifest.related = [
1051
+ {
1052
+ label: "Topic neighbors",
1053
+ links: [],
1054
+ emptyReason:
1055
+ "Alert/Analysis topic links are surfaced inside their detail views directly — the connected rail focuses on identity-graph neighbors here.",
1056
+ },
1057
+ ];
1058
+ }
1059
+
1060
+ // Always at least set placeholder labels so the rail renders
1061
+ // section headers consistently across kinds.
1062
+ if (manifest.up.length === 0) {
1063
+ manifest.up = [
1064
+ {
1065
+ label: "Up",
1066
+ links: [],
1067
+ emptyReason: "No parent context for this entity.",
1068
+ },
1069
+ ];
1070
+ }
1071
+ if (manifest.across.length === 0) {
1072
+ manifest.across = [
1073
+ {
1074
+ label: "Across",
1075
+ links: [],
1076
+ emptyReason: "No identity-graph peers under this entity.",
1077
+ },
1078
+ ];
1079
+ }
1080
+ if (manifest.down.length === 0) {
1081
+ manifest.down = [
1082
+ {
1083
+ label: "Down",
1084
+ links: [],
1085
+ emptyReason:
1086
+ "No child entities yet (profiles + kernel events arrive with later RFCs).",
1087
+ },
1088
+ ];
1089
+ }
1090
+ if (manifest.related.length === 0) {
1091
+ manifest.related = [
1092
+ {
1093
+ label: "Related",
1094
+ links: [],
1095
+ emptyReason: "No topic neighbors found.",
1096
+ },
1097
+ ];
1098
+ }
1099
+
1100
+ return c.json(manifest);
1101
+ });
1102
+ },
1103
+ };