@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,343 @@
1
+ import {
2
+ DEFAULT_WINDOW_HOURS,
3
+ getConfiguredRetentionHours,
4
+ } from "@obsunified/types/constants";
5
+ import type { CollectorPlugin } from "../framework/collector";
6
+ import {
7
+ getAutonomousReviewAggregates,
8
+ getCostAttributionAggregates,
9
+ getToolReliabilityAggregates,
10
+ getVersionDiffAggregates,
11
+ } from "../lib/action-aggregates";
12
+ import type {
13
+ ActionRef,
14
+ EntityManifestExtended,
15
+ EvalResultRef,
16
+ ToolCallRef,
17
+ } from "../lib/identity-index";
18
+ import { IdentityIndex } from "../lib/identity-index";
19
+ import { sqlDbFor } from "../lib/sql-db";
20
+ import { getProjectId } from "./_context";
21
+
22
+ const parsePositiveInt = (
23
+ raw: string | undefined,
24
+ fallback: number,
25
+ min: number,
26
+ max: number,
27
+ ): number => {
28
+ const parsed = Number.parseInt(raw ?? "", 10);
29
+ if (!Number.isFinite(parsed)) return fallback;
30
+ return Math.min(max, Math.max(min, parsed));
31
+ };
32
+
33
+ const parseWindowHours = (
34
+ raw: string | undefined,
35
+ retentionHours: string | undefined,
36
+ ): number => {
37
+ const maxHours = getConfiguredRetentionHours(retentionHours);
38
+ return parsePositiveInt(raw, DEFAULT_WINDOW_HOURS, 1, maxHours);
39
+ };
40
+
41
+ interface CompareStep {
42
+ key: string;
43
+ index: number;
44
+ actionId: string;
45
+ parentActionId: string | null;
46
+ actionKind: string;
47
+ name: string | null;
48
+ status: string;
49
+ durationMs: number | null;
50
+ totalCostUsd: number | null;
51
+ toolName: string | null;
52
+ toolCallId: string | null;
53
+ evalPassed: boolean | null;
54
+ evalScore: number | null;
55
+ traceId: string | null;
56
+ spanId: string | null;
57
+ causalConfidence?: string;
58
+ }
59
+
60
+ const sortActionSteps = (actions: ActionRef[]): ActionRef[] =>
61
+ [...actions].sort((a, b) => {
62
+ const time = a.startedAt.localeCompare(b.startedAt);
63
+ if (time !== 0) return time;
64
+ return a.id.localeCompare(b.id);
65
+ });
66
+
67
+ const stepBaseKey = (
68
+ action: ActionRef,
69
+ tool: ToolCallRef | undefined,
70
+ ): string => {
71
+ const name = tool?.toolName ?? action.stepId ?? action.name ?? "unnamed";
72
+ return `${action.actionKind}:${name}`.toLowerCase();
73
+ };
74
+
75
+ const buildCompareSteps = (manifest: EntityManifestExtended): CompareStep[] => {
76
+ const toolsByAction = new Map<string, ToolCallRef[]>();
77
+ for (const tool of manifest.toolCalls) {
78
+ const tools = toolsByAction.get(tool.actionId) ?? [];
79
+ tools.push(tool);
80
+ toolsByAction.set(tool.actionId, tools);
81
+ }
82
+
83
+ const evalsByAction = new Map<string, EvalResultRef[]>();
84
+ for (const evalResult of manifest.evalResults) {
85
+ const evals = evalsByAction.get(evalResult.actionId) ?? [];
86
+ evals.push(evalResult);
87
+ evalsByAction.set(evalResult.actionId, evals);
88
+ }
89
+
90
+ const seen = new Map<string, number>();
91
+ return sortActionSteps(manifest.actions).map((action, index) => {
92
+ const tool = toolsByAction.get(action.id)?.[0];
93
+ const evalResult = evalsByAction.get(action.id)?.[0];
94
+ const baseKey = stepBaseKey(action, tool);
95
+ const count = seen.get(baseKey) ?? 0;
96
+ seen.set(baseKey, count + 1);
97
+ return {
98
+ key: `${baseKey}#${count}`,
99
+ index,
100
+ actionId: action.id,
101
+ parentActionId: action.causedByActionId,
102
+ actionKind: action.actionKind,
103
+ name: action.name,
104
+ status: action.status,
105
+ durationMs: action.durationMs,
106
+ totalCostUsd: action.totalCostUsd,
107
+ toolName: tool?.toolName ?? null,
108
+ toolCallId: tool?.id ?? action.toolCallId,
109
+ evalPassed: evalResult ? evalResult.passed !== 0 : null,
110
+ evalScore: evalResult?.score ?? null,
111
+ traceId: action.traceId,
112
+ spanId: action.spanId,
113
+ causalConfidence: action.causalConfidence,
114
+ };
115
+ });
116
+ };
117
+
118
+ const changedFields = (left: CompareStep, right: CompareStep): string[] => {
119
+ const fields: (keyof CompareStep)[] = [
120
+ "parentActionId",
121
+ "actionKind",
122
+ "name",
123
+ "status",
124
+ "durationMs",
125
+ "totalCostUsd",
126
+ "toolName",
127
+ "evalPassed",
128
+ "evalScore",
129
+ "traceId",
130
+ "causalConfidence",
131
+ ];
132
+ return fields.filter((field) => left[field] !== right[field]);
133
+ };
134
+
135
+ const compareStepSequences = (
136
+ leftSteps: CompareStep[],
137
+ rightSteps: CompareStep[],
138
+ ) => {
139
+ const leftByKey = new Map(leftSteps.map((step) => [step.key, step]));
140
+ const rightByKey = new Map(rightSteps.map((step) => [step.key, step]));
141
+ const keys = Array.from(new Set([...leftByKey.keys(), ...rightByKey.keys()]));
142
+ return keys.map((key) => {
143
+ const left = leftByKey.get(key) ?? null;
144
+ const right = rightByKey.get(key) ?? null;
145
+ const fields = left && right ? changedFields(left, right) : [];
146
+ return {
147
+ key,
148
+ changeType:
149
+ left && right
150
+ ? fields.length > 0
151
+ ? "changed"
152
+ : "same"
153
+ : left
154
+ ? "removed"
155
+ : "added",
156
+ changedFields: fields,
157
+ left,
158
+ right,
159
+ };
160
+ });
161
+ };
162
+
163
+ const manifestForComparableId = async (
164
+ index: IdentityIndex,
165
+ projectId: string,
166
+ id: string,
167
+ ): Promise<EntityManifestExtended> => {
168
+ const byAction = await index.byAction(projectId, id);
169
+ if (byAction.actions.some((action) => action.id === id)) return byAction;
170
+ return index.byAgentRun(projectId, id);
171
+ };
172
+
173
+ export const actionRoutesPlugin: CollectorPlugin = {
174
+ name: "action-routes",
175
+ register(app) {
176
+ app.get("/internal/actions/aggregates/tool-reliability", async (c) => {
177
+ const projectId = getProjectId(c);
178
+ const hours = parseWindowHours(
179
+ c.req.query("hours"),
180
+ c.env.RETENTION_HOURS,
181
+ );
182
+ const limit = parsePositiveInt(c.req.query("limit"), 20, 1, 100);
183
+ const result = await getToolReliabilityAggregates(
184
+ sqlDbFor(c.env),
185
+ projectId,
186
+ hours,
187
+ limit,
188
+ );
189
+ return c.json(result);
190
+ });
191
+
192
+ app.get("/internal/actions/aggregates/cost-attribution", async (c) => {
193
+ const projectId = getProjectId(c);
194
+ const hours = parseWindowHours(
195
+ c.req.query("hours"),
196
+ c.env.RETENTION_HOURS,
197
+ );
198
+ const limit = parsePositiveInt(c.req.query("limit"), 20, 1, 100);
199
+ const result = await getCostAttributionAggregates(
200
+ sqlDbFor(c.env),
201
+ projectId,
202
+ hours,
203
+ limit,
204
+ );
205
+ return c.json(result);
206
+ });
207
+
208
+ app.get("/internal/actions/aggregates/autonomous-review", async (c) => {
209
+ const projectId = getProjectId(c);
210
+ const hours = parseWindowHours(
211
+ c.req.query("hours"),
212
+ c.env.RETENTION_HOURS,
213
+ );
214
+ const limit = parsePositiveInt(c.req.query("limit"), 50, 1, 200);
215
+ const result = await getAutonomousReviewAggregates(
216
+ sqlDbFor(c.env),
217
+ projectId,
218
+ hours,
219
+ limit,
220
+ );
221
+ return c.json(result);
222
+ });
223
+
224
+ app.get("/internal/actions/aggregates/version-diff", async (c) => {
225
+ const projectId = getProjectId(c);
226
+ const result = await getVersionDiffAggregates(
227
+ sqlDbFor(c.env),
228
+ projectId,
229
+ c.req.query("baseline"),
230
+ c.req.query("target"),
231
+ );
232
+ return c.json(result);
233
+ });
234
+
235
+ app.get("/internal/actions/compare", async (c) => {
236
+ const projectId = getProjectId(c);
237
+ const leftId = c.req.query("left");
238
+ const rightId = c.req.query("right");
239
+ if (!leftId || !rightId) {
240
+ return c.json(
241
+ { error: "left and right query parameters required" },
242
+ 400,
243
+ );
244
+ }
245
+
246
+ const index = new IdentityIndex(sqlDbFor(c.env));
247
+ const [leftManifest, rightManifest] = await Promise.all([
248
+ manifestForComparableId(index, projectId, leftId),
249
+ manifestForComparableId(index, projectId, rightId),
250
+ ]);
251
+ const leftSteps = buildCompareSteps(leftManifest);
252
+ const rightSteps = buildCompareSteps(rightManifest);
253
+
254
+ if (leftSteps.length === 0 || rightSteps.length === 0) {
255
+ return c.json(
256
+ {
257
+ error: "Not Found",
258
+ message: "Both comparable action graphs must exist",
259
+ },
260
+ 404,
261
+ );
262
+ }
263
+
264
+ return c.json({
265
+ projectId,
266
+ leftId,
267
+ rightId,
268
+ leftManifest,
269
+ rightManifest,
270
+ leftSteps,
271
+ rightSteps,
272
+ stepComparisons: compareStepSequences(leftSteps, rightSteps),
273
+ generatedAt: new Date().toISOString(),
274
+ });
275
+ });
276
+
277
+ app.get("/internal/actions/:id", async (c) => {
278
+ const projectId = getProjectId(c);
279
+ const id = c.req.param("id");
280
+ if (!id) return c.json({ error: "id required" }, 400);
281
+
282
+ const index = new IdentityIndex(sqlDbFor(c.env));
283
+ const manifest = await index.byAction(projectId, id);
284
+ const action = manifest.actions.find((a) => a.id === id);
285
+ if (!action) {
286
+ return c.json({ error: "Not Found", message: "Action not found" }, 404);
287
+ }
288
+
289
+ return c.json({ action, manifest });
290
+ });
291
+
292
+ app.get("/internal/agent-runs/:id", async (c) => {
293
+ const projectId = getProjectId(c);
294
+ const id = c.req.param("id");
295
+ if (!id) return c.json({ error: "id required" }, 400);
296
+
297
+ const index = new IdentityIndex(sqlDbFor(c.env));
298
+ const manifest = await index.byAgentRun(projectId, id);
299
+ const agentRun = manifest.agentRuns.find((r) => r.id === id);
300
+ if (!agentRun) {
301
+ return c.json(
302
+ { error: "Not Found", message: "Agent run not found" },
303
+ 404,
304
+ );
305
+ }
306
+
307
+ return c.json({ agentRun, manifest });
308
+ });
309
+
310
+ app.get("/internal/tool-calls/:id", async (c) => {
311
+ const projectId = getProjectId(c);
312
+ const id = c.req.param("id");
313
+ if (!id) return c.json({ error: "id required" }, 400);
314
+
315
+ const db = sqlDbFor(c.env);
316
+ const toolCallRow = await db
317
+ .prepare(
318
+ `SELECT action_id FROM tool_calls
319
+ WHERE project_id = ? AND id = ? LIMIT 1`,
320
+ )
321
+ .bind(projectId, id)
322
+ .first<{ action_id: string }>();
323
+ if (!toolCallRow) {
324
+ return c.json(
325
+ { error: "Not Found", message: "Tool call not found" },
326
+ 404,
327
+ );
328
+ }
329
+
330
+ const index = new IdentityIndex(db);
331
+ const manifest = await index.byAction(projectId, toolCallRow.action_id);
332
+ const toolCall = manifest.toolCalls.find((t) => t.id === id);
333
+ if (!toolCall) {
334
+ return c.json(
335
+ { error: "Not Found", message: "Tool call not found" },
336
+ 404,
337
+ );
338
+ }
339
+
340
+ return c.json({ toolCall, manifest });
341
+ });
342
+ },
343
+ };
@@ -0,0 +1,254 @@
1
+ import type {
2
+ AICallPayload,
3
+ AICallRecord,
4
+ AIEvaluationPayload,
5
+ ToolResponseContract,
6
+ } from "@obsunified/types";
7
+ import {
8
+ EVIDENCE_REFERENCE_CONTRACT,
9
+ EVIDENCE_REFERENCE_SCHEMA_VERSION,
10
+ EvidenceReferenceJsonSchema,
11
+ TOOL_RESPONSE_CONTRACT_SCHEMA_VERSION,
12
+ } from "@obsunified/types";
13
+ import { getConfiguredRetentionHours } from "@obsunified/types/constants";
14
+ import type { CollectorPlugin } from "../framework/collector";
15
+ import { AIStore, type IngestEvaluation } from "../lib/ai-store";
16
+ import { sqlDbFor } from "../lib/sql-db";
17
+ import { getProjectId } from "./_context";
18
+
19
+ export const aiReceiverPlugin: CollectorPlugin = {
20
+ name: "ai-receiver",
21
+ register(app, _runtime) {
22
+ app.post("/v1/ai", async (c) => {
23
+ const projectId = getProjectId(c);
24
+ let payload: AICallPayload;
25
+ try {
26
+ payload = await c.req.json<AICallPayload>();
27
+ } catch {
28
+ return c.json({ error: "Invalid JSON body" }, 400);
29
+ }
30
+ if (!payload.calls || !Array.isArray(payload.calls)) {
31
+ return c.json({ error: "Missing calls array" }, 400);
32
+ }
33
+ if (payload.calls.length > 500) {
34
+ return c.json(
35
+ { error: `Too many AI calls: ${payload.calls.length} (max 500)` },
36
+ 413,
37
+ );
38
+ }
39
+ const store = new AIStore(sqlDbFor(c.env));
40
+
41
+ const retentionHours = getConfiguredRetentionHours(c.env.RETENTION_HOURS);
42
+ const now = new Date();
43
+ const nowStr = now.toISOString();
44
+ const expires = new Date(
45
+ now.getTime() + retentionHours * 60 * 60 * 1000,
46
+ ).toISOString();
47
+
48
+ const records: AICallRecord[] = payload.calls.map((call) => ({
49
+ projectId,
50
+ callId: crypto.randomUUID(),
51
+ traceId: call.traceId || null,
52
+ spanId: call.spanId || null,
53
+ serviceName: call.serviceName || null,
54
+ modelName: call.modelName,
55
+ provider: call.provider,
56
+ callType: call.callType,
57
+ requestJson: call.request ? JSON.stringify(call.request) : null,
58
+ responseJson: call.response ? JSON.stringify(call.response) : null,
59
+ promptTokens: call.promptTokens || null,
60
+ completionTokens: call.completionTokens || null,
61
+ totalCostUsd: call.totalCostUsd || null,
62
+ latencyMs: call.latencyMs || null,
63
+ isError: call.isError || false,
64
+ errorMessage: call.errorMessage || null,
65
+ occurredAt: call.occurredAt || nowStr,
66
+ receivedAt: nowStr,
67
+ expiresAt: expires,
68
+ // RFC 0004 — denormalized from the active root span, when the
69
+ // SDK was told to propagate it.
70
+ sessionId: call.sessionId ?? null,
71
+ interactionId: call.interactionId ?? null,
72
+ }));
73
+
74
+ await store.ingestBatch(records);
75
+
76
+ return c.json({ accepted: records.length }, 202);
77
+ });
78
+
79
+ app.get("/internal/ai/overview", async (c) => {
80
+ const projectId = getProjectId(c);
81
+ const store = new AIStore(sqlDbFor(c.env));
82
+ const query = c.req.query();
83
+ const hours = parseInt(query.hours || "24", 10);
84
+
85
+ const response = await store.getAICalls({
86
+ projectId,
87
+ hours,
88
+ service: query.service,
89
+ model: query.model,
90
+ isError:
91
+ query.isError === "true"
92
+ ? true
93
+ : query.isError === "false"
94
+ ? false
95
+ : undefined,
96
+ traceId: query.traceId,
97
+ limit: parseInt(query.limit || "100", 10),
98
+ });
99
+
100
+ return c.json(response);
101
+ });
102
+
103
+ // OpenInference-kind spans (LLM/TOOL/RETRIEVER/CHAIN/AGENT/...) joined
104
+ // with their payload side-table. This is the replacement for the flat
105
+ // ai_calls endpoint — it returns typed spans that live inside the
106
+ // regular trace tree.
107
+ app.get("/internal/ai/spans", async (c) => {
108
+ const projectId = getProjectId(c);
109
+ const store = new AIStore(sqlDbFor(c.env));
110
+ const query = c.req.query();
111
+
112
+ const response = await store.getAISpans({
113
+ projectId,
114
+ hours: parseInt(query.hours || "24", 10),
115
+ kind: query.kind,
116
+ service: query.service,
117
+ traceId: query.traceId,
118
+ limit: parseInt(query.limit || "100", 10),
119
+ });
120
+
121
+ return c.json(response);
122
+ });
123
+
124
+ // Attach evaluations to AI spans. Accepts a batch of evaluation records
125
+ // keyed by (traceId, spanId). Caller supplies name (e.g. "hallucination"),
126
+ // one or both of score/label, and optional explanation + metadata.
127
+ app.post("/v1/ai/evaluations", async (c) => {
128
+ const projectId = getProjectId(c);
129
+ let payload: AIEvaluationPayload;
130
+ try {
131
+ payload = await c.req.json<AIEvaluationPayload>();
132
+ } catch {
133
+ return c.json({ error: "Invalid JSON body" }, 400);
134
+ }
135
+ if (!payload.evaluations || !Array.isArray(payload.evaluations)) {
136
+ return c.json({ error: "Missing evaluations array" }, 400);
137
+ }
138
+ if (payload.evaluations.length > 500) {
139
+ return c.json(
140
+ {
141
+ error: `Too many evaluations: ${payload.evaluations.length} (max 500)`,
142
+ },
143
+ 413,
144
+ );
145
+ }
146
+
147
+ const retentionHours = getConfiguredRetentionHours(c.env.RETENTION_HOURS);
148
+ const now = new Date();
149
+ const nowStr = now.toISOString();
150
+ const expires = new Date(
151
+ now.getTime() + retentionHours * 60 * 60 * 1000,
152
+ ).toISOString();
153
+
154
+ const records: IngestEvaluation[] = [];
155
+ for (const e of payload.evaluations) {
156
+ if (!e.traceId || !e.spanId || !e.name || !e.source) {
157
+ return c.json(
158
+ { error: "Each evaluation needs traceId, spanId, name, source" },
159
+ 400,
160
+ );
161
+ }
162
+ records.push({
163
+ projectId,
164
+ evaluationId: crypto.randomUUID(),
165
+ traceId: e.traceId,
166
+ spanId: e.spanId,
167
+ name: e.name,
168
+ score: typeof e.score === "number" ? e.score : null,
169
+ label: e.label ?? null,
170
+ explanation: e.explanation ?? null,
171
+ source: e.source,
172
+ metadataJson: e.metadata ? JSON.stringify(e.metadata) : null,
173
+ createdAt: nowStr,
174
+ expiresAt: expires,
175
+ });
176
+ }
177
+
178
+ const store = new AIStore(sqlDbFor(c.env));
179
+ await store.ingestEvaluations(records);
180
+ return c.json({ accepted: records.length }, 202);
181
+ });
182
+
183
+ // List conversation sessions — one row per distinct session.id with
184
+ // aggregated stats (span count, tokens, cost, error count, etc.).
185
+ app.get("/internal/ai/sessions", async (c) => {
186
+ const projectId = getProjectId(c);
187
+ const store = new AIStore(sqlDbFor(c.env));
188
+ const query = c.req.query();
189
+ const response = await store.listSessions({
190
+ projectId,
191
+ hours: parseInt(query.hours || "24", 10),
192
+ userId: query.userId,
193
+ limit: parseInt(query.limit || "100", 10),
194
+ });
195
+ return c.json(response);
196
+ });
197
+
198
+ // Full detail for a single session: every AI span in timestamp order
199
+ // (across one or more traces) plus any evaluations attached to them.
200
+ app.get("/internal/ai/sessions/:sessionId", async (c) => {
201
+ const projectId = getProjectId(c);
202
+ const store = new AIStore(sqlDbFor(c.env));
203
+ const sessionId = c.req.param("sessionId");
204
+ const response = await store.getSession(projectId, sessionId);
205
+ return c.json(response);
206
+ });
207
+
208
+ // List evaluations, optionally scoped to a single span or trace.
209
+ app.get("/internal/ai/evaluations", async (c) => {
210
+ const projectId = getProjectId(c);
211
+ const store = new AIStore(sqlDbFor(c.env));
212
+ const query = c.req.query();
213
+
214
+ const response = await store.listEvaluations({
215
+ projectId,
216
+ traceId: query.traceId,
217
+ spanId: query.spanId,
218
+ name: query.name,
219
+ limit: parseInt(query.limit || "200", 10),
220
+ });
221
+
222
+ return c.json(response);
223
+ });
224
+
225
+ app.get("/internal/ai/evaluations/:evaluationId", async (c) => {
226
+ const projectId = getProjectId(c);
227
+ const evaluationId = c.req.param("evaluationId");
228
+ const store = new AIStore(sqlDbFor(c.env));
229
+ const evaluation = await store.getEvaluation(projectId, evaluationId);
230
+ if (!evaluation) {
231
+ return c.json(
232
+ { error: "Not Found", message: "Evaluation not found" },
233
+ 404,
234
+ );
235
+ }
236
+ const contract: ToolResponseContract = {
237
+ schemaVersion: TOOL_RESPONSE_CONTRACT_SCHEMA_VERSION,
238
+ transport: "http",
239
+ tool: "GET /internal/ai/evaluations/:evaluationId",
240
+ params: { projectId, evaluationId },
241
+ returns:
242
+ "{ evaluation: AIEvaluationRecord, evidenceContract: EVIDENCE_REFERENCE_CONTRACT, timestamp: string }",
243
+ evidenceReferenceSchemaVersion: EVIDENCE_REFERENCE_SCHEMA_VERSION,
244
+ evidenceReferenceJsonSchema: EvidenceReferenceJsonSchema,
245
+ };
246
+ return c.json({
247
+ evaluation,
248
+ evidenceContract: EVIDENCE_REFERENCE_CONTRACT,
249
+ contract,
250
+ timestamp: new Date().toISOString(),
251
+ });
252
+ });
253
+ },
254
+ };
@@ -0,0 +1,90 @@
1
+ import type { StoredSpan } from "@obsunified/types";
2
+ import { describe, expect, it } from "vitest";
3
+ import type {
4
+ CollectorRuntime,
5
+ SpanProcessorPlugin,
6
+ } from "../framework/collector";
7
+ import type { CollectorRouteContext } from "../framework/env";
8
+ import { sha256Hex } from "../lib/hash";
9
+ import { MemSqlDb } from "../lib/test-utils/mem-sql-db";
10
+ import { aiSpanPayloadsProcessorPlugin } from "./ai-span-payloads-processor";
11
+
12
+ const span = (): StoredSpan => ({
13
+ projectId: "proj-123",
14
+ spanId: "span-1",
15
+ parentSpanId: null,
16
+ traceId: "trace-1",
17
+ traceState: null,
18
+ serviceName: "agent-service",
19
+ scopeName: null,
20
+ scopeVersion: null,
21
+ spanName: "llm",
22
+ spanKind: 1,
23
+ statusCode: 1,
24
+ statusMessage: null,
25
+ startTime: "2026-05-22T00:00:00.000Z",
26
+ endTime: "2026-05-22T00:00:01.000Z",
27
+ durationMs: 1000,
28
+ attributesJson: JSON.stringify({
29
+ "openinference.span.kind": "LLM",
30
+ "ai.payload.input": "raw prompt",
31
+ "ai.payload.output": "raw completion",
32
+ "obs.action.id": "action-1",
33
+ }),
34
+ droppedAttributesCount: 0,
35
+ resourceAttributesJson: "{}",
36
+ eventsJson: "[]",
37
+ droppedEventsCount: 0,
38
+ linksJson: "[]",
39
+ droppedLinksCount: 0,
40
+ receivedAt: "2026-05-22T00:00:01.000Z",
41
+ expiresAt: "2026-05-23T00:00:01.000Z",
42
+ sessionId: null,
43
+ interactionId: null,
44
+ });
45
+
46
+ const processWith = async (db: MemSqlDb, env: Record<string, unknown> = {}) => {
47
+ const processors: SpanProcessorPlugin[] = [];
48
+ aiSpanPayloadsProcessorPlugin.register(
49
+ {} as Parameters<typeof aiSpanPayloadsProcessorPlugin.register>[0],
50
+ {
51
+ addSpanProcessor(p: SpanProcessorPlugin) {
52
+ processors.push(p);
53
+ },
54
+ } as unknown as CollectorRuntime,
55
+ );
56
+ const processFn = processors[0]?.process;
57
+ if (!processFn) throw new Error("span processor was not registered");
58
+ return processFn([span()], {
59
+ env: { DB: db, ...env },
60
+ now: new Date(),
61
+ logger: console,
62
+ } as unknown as CollectorRouteContext);
63
+ };
64
+
65
+ describe("aiSpanPayloadsProcessorPlugin payload capture policy", () => {
66
+ it("stores hashes but omits raw payloads when project capture is disabled", async () => {
67
+ const db = new MemSqlDb();
68
+ const processed = await processWith(db);
69
+
70
+ const insert = db.callsMatching("INSERT INTO ai_span_payloads")[0];
71
+ expect(insert.binds).toContain(null);
72
+ expect(insert.binds).toContain(await sha256Hex("raw prompt"));
73
+ expect(insert.binds).toContain(await sha256Hex("raw completion"));
74
+
75
+ const attrs = JSON.parse(processed[0].attributesJson);
76
+ expect(attrs["ai.payload.input"]).toBeUndefined();
77
+ expect(attrs["ai.payload.output"]).toBeUndefined();
78
+ });
79
+
80
+ it("stores raw payloads only when capture is explicitly enabled", async () => {
81
+ const db = new MemSqlDb();
82
+ await processWith(db, { OBS_PAYLOAD_CAPTURE_DEFAULT: "true" });
83
+
84
+ const insert = db.callsMatching("INSERT INTO ai_span_payloads")[0];
85
+ expect(insert.binds).toContain("raw prompt");
86
+ expect(insert.binds).toContain("raw completion");
87
+ expect(insert.binds).toContain(await sha256Hex("raw prompt"));
88
+ expect(insert.binds).toContain(await sha256Hex("raw completion"));
89
+ });
90
+ });