@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,449 @@
1
+ /**
2
+ * AI-observability parity suite.
3
+ *
4
+ * Exercises the live collector end-to-end and asserts the wire contract we
5
+ * need to claim Phoenix/LangSmith-level AI observability. For each dimension
6
+ * the Phoenix docs list, this suite sends a realistic OTLP payload and
7
+ * verifies the collector exposes it through `/internal/ai/*`:
8
+ *
9
+ * 1. Typed OpenInference span kinds (LLM, TOOL, RETRIEVER) flow through
10
+ * telemetry_spans → ai_span_payloads and come back from /ai/spans
11
+ * 2. Payloads (input/output) are stripped from attributes_json and land
12
+ * in the payload side table without truncation
13
+ * 3. gen_ai.* auto-instrumentation attrs are normalized to OpenInference
14
+ * on ingest (no SDK required on the caller)
15
+ * 4. Token counts, cost, and provider metadata are queryable
16
+ * 5. Evaluations post via /v1/ai/evaluations and join back by (trace,span)
17
+ * 6. Sessions group spans across traces under one session.id
18
+ *
19
+ * Skipped unless AI_PARITY_URL is set (the runner script provides it).
20
+ */
21
+
22
+ import { describe, expect, it } from "vitest";
23
+
24
+ declare const process: { env: Record<string, string | undefined> };
25
+
26
+ const BASE = process.env.AI_PARITY_URL;
27
+
28
+ // Helper: build a minimal OTLP trace payload with a single span carrying
29
+ // the given attribute map.
30
+ const buildSpan = (opts: {
31
+ traceId: string;
32
+ spanId: string;
33
+ parentSpanId?: string;
34
+ name: string;
35
+ serviceName: string;
36
+ attributes: Record<string, unknown>;
37
+ startNano: string;
38
+ endNano: string;
39
+ statusCode?: number;
40
+ }) => {
41
+ const kv = (k: string, v: unknown) => {
42
+ if (typeof v === "string") return { key: k, value: { stringValue: v } };
43
+ if (typeof v === "boolean") return { key: k, value: { boolValue: v } };
44
+ if (typeof v === "number")
45
+ return Number.isInteger(v)
46
+ ? { key: k, value: { intValue: v } }
47
+ : { key: k, value: { doubleValue: v } };
48
+ return { key: k, value: { stringValue: String(v) } };
49
+ };
50
+ return {
51
+ resourceSpans: [
52
+ {
53
+ resource: { attributes: [kv("service.name", opts.serviceName)] },
54
+ scopeSpans: [
55
+ {
56
+ scope: { name: opts.serviceName },
57
+ spans: [
58
+ {
59
+ traceId: opts.traceId,
60
+ spanId: opts.spanId,
61
+ parentSpanId: opts.parentSpanId,
62
+ name: opts.name,
63
+ kind: 2,
64
+ startTimeUnixNano: opts.startNano,
65
+ endTimeUnixNano: opts.endNano,
66
+ attributes: Object.entries(opts.attributes).map(([k, v]) =>
67
+ kv(k, v),
68
+ ),
69
+ status: { code: opts.statusCode ?? 1 },
70
+ },
71
+ ],
72
+ },
73
+ ],
74
+ },
75
+ ],
76
+ };
77
+ };
78
+
79
+ const randomHex = (bytes: number): string => {
80
+ const arr = new Uint8Array(bytes);
81
+ crypto.getRandomValues(arr);
82
+ return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join("");
83
+ };
84
+
85
+ const postTrace = async (payload: unknown): Promise<Response> =>
86
+ fetch(`${BASE}/v1/traces`, {
87
+ method: "POST",
88
+ headers: { "Content-Type": "application/json" },
89
+ body: JSON.stringify(payload),
90
+ });
91
+
92
+ const nowNano = (): string => (BigInt(Date.now()) * 1_000_000n).toString();
93
+ const agoNano = (ms: number): string =>
94
+ (BigInt(Date.now() - ms) * 1_000_000n).toString();
95
+
96
+ interface AISpan {
97
+ traceId: string;
98
+ spanId: string;
99
+ parentSpanId: string | null;
100
+ spanKind: string;
101
+ spanName: string;
102
+ serviceName: string | null;
103
+ durationMs: number;
104
+ statusCode: number;
105
+ attributes: Record<string, unknown>;
106
+ inputJson: string | null;
107
+ outputJson: string | null;
108
+ }
109
+
110
+ interface AISpansResponse {
111
+ spans: AISpan[];
112
+ summary: {
113
+ totalSpans: number;
114
+ byKind: Record<string, number>;
115
+ errorSpans: number;
116
+ };
117
+ }
118
+
119
+ describe.skipIf(!BASE)("AI observability parity", () => {
120
+ it("1. LLM span with OpenInference attrs round-trips through /ai/spans", async () => {
121
+ const traceId = randomHex(16);
122
+ const spanId = randomHex(8);
123
+ const messages = [
124
+ { role: "system", content: "You are concise." },
125
+ { role: "user", content: "Capital of France?" },
126
+ ];
127
+ const output = { role: "assistant", content: "Paris." };
128
+
129
+ const payload = buildSpan({
130
+ traceId,
131
+ spanId,
132
+ name: "openai.chat.completions",
133
+ serviceName: "ai-parity",
134
+ startNano: agoNano(1200),
135
+ endNano: nowNano(),
136
+ attributes: {
137
+ "openinference.span.kind": "LLM",
138
+ "llm.model_name": "gpt-4o-mini",
139
+ "llm.provider": "openai",
140
+ "llm.token_count.prompt": 18,
141
+ "llm.token_count.completion": 2,
142
+ "llm.token_count.total": 20,
143
+ "ai.payload.input": JSON.stringify(messages),
144
+ "ai.payload.output": JSON.stringify(output),
145
+ },
146
+ });
147
+
148
+ const res = await postTrace(payload);
149
+ expect(res.status).toBe(200);
150
+
151
+ // Give the collector a beat — SpanProcessor runs during the ingest,
152
+ // so by the time POST returns the row should exist, but CI is noisy.
153
+ await new Promise((r) => setTimeout(r, 300));
154
+
155
+ const q = await fetch(
156
+ `${BASE}/internal/ai/spans?traceId=${traceId}&hours=1`,
157
+ );
158
+ expect(q.status).toBe(200);
159
+ const body = (await q.json()) as AISpansResponse;
160
+
161
+ const llm = body.spans.find((s) => s.spanId === spanId);
162
+ expect(llm, "LLM span not found in /ai/spans response").toBeTruthy();
163
+ expect(llm?.spanKind).toBe("LLM");
164
+ expect(llm?.attributes["llm.model_name"]).toBe("gpt-4o-mini");
165
+ expect(llm?.attributes["llm.provider"]).toBe("openai");
166
+ expect(llm?.attributes["llm.token_count.prompt"]).toBe(18);
167
+ expect(llm?.attributes["llm.token_count.completion"]).toBe(2);
168
+
169
+ // Payload blobs MUST be stripped from attributes and routed to the
170
+ // side table — Phoenix parity requires input/output be rehydratable
171
+ // for replay.
172
+ expect(llm?.attributes).not.toHaveProperty("ai.payload.input");
173
+ expect(llm?.attributes).not.toHaveProperty("ai.payload.output");
174
+ expect(llm?.inputJson).toBe(JSON.stringify(messages));
175
+ expect(llm?.outputJson).toBe(JSON.stringify(output));
176
+
177
+ // Cost must be auto-computed from the vendor pricing table since we
178
+ // didn't report it directly. gpt-4o-mini is in our table.
179
+ expect(llm?.attributes["llm.cost.total_usd"]).toBeGreaterThan(0);
180
+ expect(llm?.attributes["llm.cost.computed"]).toBe(true);
181
+ });
182
+
183
+ it("2. RETRIEVER span preserves document attributes for RAG debugging", async () => {
184
+ const traceId = randomHex(16);
185
+ const spanId = randomHex(8);
186
+
187
+ const payload = buildSpan({
188
+ traceId,
189
+ spanId,
190
+ name: "knowledge_base.search",
191
+ serviceName: "ai-parity",
192
+ startNano: agoNano(200),
193
+ endNano: nowNano(),
194
+ attributes: {
195
+ "openinference.span.kind": "RETRIEVER",
196
+ "retrieval.query": "capital of France",
197
+ "retrieval.documents.count": 2,
198
+ "retrieval.documents.0.document.id": "doc_001",
199
+ "retrieval.documents.0.document.score": 0.92,
200
+ "retrieval.documents.0.document.content":
201
+ "Paris is the capital of France.",
202
+ "retrieval.documents.1.document.id": "doc_042",
203
+ "retrieval.documents.1.document.score": 0.71,
204
+ "retrieval.documents.1.document.content":
205
+ "France is a country in Western Europe.",
206
+ },
207
+ });
208
+ await postTrace(payload);
209
+ await new Promise((r) => setTimeout(r, 300));
210
+
211
+ const q = await fetch(
212
+ `${BASE}/internal/ai/spans?traceId=${traceId}&hours=1`,
213
+ );
214
+ const body = (await q.json()) as AISpansResponse;
215
+ const retriever = body.spans.find((s) => s.spanId === spanId);
216
+
217
+ expect(retriever?.spanKind).toBe("RETRIEVER");
218
+ expect(retriever?.attributes["retrieval.query"]).toBe("capital of France");
219
+ expect(retriever?.attributes["retrieval.documents.count"]).toBe(2);
220
+ expect(retriever?.attributes["retrieval.documents.0.document.id"]).toBe(
221
+ "doc_001",
222
+ );
223
+ expect(
224
+ retriever?.attributes["retrieval.documents.0.document.score"],
225
+ ).toBeCloseTo(0.92, 2);
226
+ });
227
+
228
+ it("3. TOOL span captures name + parameters + output", async () => {
229
+ const traceId = randomHex(16);
230
+ const spanId = randomHex(8);
231
+ const params = { city: "Tokyo" };
232
+ const result = { city: "Tokyo", tempC: 18, condition: "partly cloudy" };
233
+
234
+ await postTrace(
235
+ buildSpan({
236
+ traceId,
237
+ spanId,
238
+ name: "get_weather",
239
+ serviceName: "ai-parity",
240
+ startNano: agoNano(80),
241
+ endNano: nowNano(),
242
+ attributes: {
243
+ "openinference.span.kind": "TOOL",
244
+ "tool.name": "get_weather",
245
+ "ai.payload.input": JSON.stringify(params),
246
+ "ai.payload.output": JSON.stringify(result),
247
+ },
248
+ }),
249
+ );
250
+ await new Promise((r) => setTimeout(r, 300));
251
+
252
+ const q = await fetch(
253
+ `${BASE}/internal/ai/spans?traceId=${traceId}&hours=1`,
254
+ );
255
+ const body = (await q.json()) as AISpansResponse;
256
+ const tool = body.spans.find((s) => s.spanId === spanId);
257
+
258
+ expect(tool?.spanKind).toBe("TOOL");
259
+ expect(tool?.attributes["tool.name"]).toBe("get_weather");
260
+ expect(tool?.inputJson).toBe(JSON.stringify(params));
261
+ expect(tool?.outputJson).toBe(JSON.stringify(result));
262
+ });
263
+
264
+ it("4. Auto-instrumentation: gen_ai.* attrs normalize to OpenInference", async () => {
265
+ const traceId = randomHex(16);
266
+ const spanId = randomHex(8);
267
+
268
+ // Simulate what the OpenAI SDK or Vercel AI SDK would emit natively —
269
+ // no OpenInference attrs set by the caller.
270
+ await postTrace(
271
+ buildSpan({
272
+ traceId,
273
+ spanId,
274
+ name: "chat",
275
+ serviceName: "ai-parity",
276
+ startNano: agoNano(1000),
277
+ endNano: nowNano(),
278
+ attributes: {
279
+ "gen_ai.system": "openai",
280
+ "gen_ai.operation.name": "chat",
281
+ "gen_ai.request.model": "gpt-4o",
282
+ "gen_ai.response.model": "gpt-4o-2024-11-20",
283
+ "gen_ai.usage.input_tokens": 42,
284
+ "gen_ai.usage.output_tokens": 120,
285
+ "gen_ai.prompt.0.role": "user",
286
+ "gen_ai.prompt.0.content": "hello",
287
+ "gen_ai.completion.0.role": "assistant",
288
+ "gen_ai.completion.0.content": "hi there",
289
+ },
290
+ }),
291
+ );
292
+ await new Promise((r) => setTimeout(r, 300));
293
+
294
+ const q = await fetch(
295
+ `${BASE}/internal/ai/spans?traceId=${traceId}&hours=1`,
296
+ );
297
+ const body = (await q.json()) as AISpansResponse;
298
+ const span = body.spans.find((s) => s.spanId === spanId);
299
+
300
+ expect(span, "gen_ai.* span not normalized to OpenInference").toBeTruthy();
301
+ expect(span?.spanKind).toBe("LLM");
302
+ // Normalization: gen_ai.response.model wins (actual served model) over
303
+ // gen_ai.request.model, and maps to llm.model_name.
304
+ expect(span?.attributes["llm.model_name"]).toBe("gpt-4o-2024-11-20");
305
+ expect(span?.attributes["llm.provider"]).toBe("openai");
306
+ expect(span?.attributes["llm.token_count.prompt"]).toBe(42);
307
+ expect(span?.attributes["llm.token_count.completion"]).toBe(120);
308
+ // Indexed prompt/completion arrays get reassembled into input/output
309
+ // payloads and routed to the side table, same as native SDK spans.
310
+ expect(span?.inputJson).toBeTruthy();
311
+ expect(span?.outputJson).toBeTruthy();
312
+ expect(span?.inputJson).toContain("hello");
313
+ expect(span?.outputJson).toContain("hi there");
314
+ });
315
+
316
+ it("5. Evaluation attaches to a span and returns via /ai/evaluations", async () => {
317
+ const traceId = randomHex(16);
318
+ const spanId = randomHex(8);
319
+
320
+ await postTrace(
321
+ buildSpan({
322
+ traceId,
323
+ spanId,
324
+ name: "openai.chat.completions",
325
+ serviceName: "ai-parity",
326
+ startNano: agoNano(500),
327
+ endNano: nowNano(),
328
+ attributes: {
329
+ "openinference.span.kind": "LLM",
330
+ "llm.model_name": "gpt-4o-mini",
331
+ "llm.provider": "openai",
332
+ "ai.payload.input": "[]",
333
+ "ai.payload.output": "{}",
334
+ },
335
+ }),
336
+ );
337
+ await new Promise((r) => setTimeout(r, 200));
338
+
339
+ const postEval = await fetch(`${BASE}/v1/ai/evaluations`, {
340
+ method: "POST",
341
+ headers: { "Content-Type": "application/json" },
342
+ body: JSON.stringify({
343
+ evaluations: [
344
+ {
345
+ traceId,
346
+ spanId,
347
+ name: "hallucination",
348
+ score: 0.08,
349
+ label: "grounded",
350
+ explanation: "Answer is consistent with retrieved context.",
351
+ source: "llm_judge",
352
+ },
353
+ ],
354
+ }),
355
+ });
356
+ expect(postEval.status).toBe(202);
357
+
358
+ const list = await fetch(
359
+ `${BASE}/internal/ai/evaluations?traceId=${traceId}`,
360
+ );
361
+ const body = (await list.json()) as {
362
+ evaluations: Array<{
363
+ name: string;
364
+ score: number | null;
365
+ label: string | null;
366
+ source: string;
367
+ spanId: string;
368
+ }>;
369
+ };
370
+ const evalRow = body.evaluations.find((e) => e.spanId === spanId);
371
+ expect(evalRow?.name).toBe("hallucination");
372
+ expect(evalRow?.score).toBeCloseTo(0.08, 4);
373
+ expect(evalRow?.label).toBe("grounded");
374
+ expect(evalRow?.source).toBe("llm_judge");
375
+ });
376
+
377
+ it("6. Sessions group multiple traces under one session.id", async () => {
378
+ const sessionId = `parity-session-${Date.now().toString(36)}`;
379
+ const userId = "parity-user";
380
+
381
+ // Emit three LLM spans across three DIFFERENT trace IDs — only the
382
+ // session.id stamps them together. This is the Phoenix Sessions model.
383
+ for (let i = 0; i < 3; i++) {
384
+ const traceId = randomHex(16);
385
+ const spanId = randomHex(8);
386
+ await postTrace(
387
+ buildSpan({
388
+ traceId,
389
+ spanId,
390
+ name: `turn-${i}`,
391
+ serviceName: "ai-parity",
392
+ startNano: agoNano(500 - i * 100),
393
+ endNano: agoNano(500 - i * 100 - 50),
394
+ attributes: {
395
+ "openinference.span.kind": "LLM",
396
+ "llm.model_name": "gpt-4o-mini",
397
+ "llm.provider": "openai",
398
+ "llm.token_count.prompt": 10 + i,
399
+ "llm.token_count.completion": 5 + i,
400
+ "session.id": sessionId,
401
+ "user.id": userId,
402
+ "ai.payload.input": JSON.stringify([
403
+ { role: "user", content: `message ${i}` },
404
+ ]),
405
+ "ai.payload.output": JSON.stringify({
406
+ role: "assistant",
407
+ content: `reply ${i}`,
408
+ }),
409
+ },
410
+ }),
411
+ );
412
+ }
413
+ await new Promise((r) => setTimeout(r, 400));
414
+
415
+ const detail = await fetch(
416
+ `${BASE}/internal/ai/sessions/${encodeURIComponent(sessionId)}`,
417
+ );
418
+ expect(detail.status).toBe(200);
419
+ const body = (await detail.json()) as {
420
+ sessionId: string;
421
+ userId: string | null;
422
+ spans: Array<{
423
+ spanId: string;
424
+ spanKind: string;
425
+ inputJson: string | null;
426
+ }>;
427
+ summary: { spanCount: number; totalPromptTokens: number };
428
+ };
429
+
430
+ expect(body.sessionId).toBe(sessionId);
431
+ expect(body.userId).toBe(userId);
432
+ expect(body.summary.spanCount).toBe(3);
433
+ expect(body.summary.totalPromptTokens).toBe(10 + 11 + 12);
434
+ // Each turn's input must be rehydratable for the chat view.
435
+ for (const span of body.spans) {
436
+ expect(span.inputJson).toMatch(/message \d/);
437
+ }
438
+ });
439
+
440
+ it("7. Summary endpoint reports by-kind breakdown (Phoenix-style)", async () => {
441
+ const q = await fetch(`${BASE}/internal/ai/spans?hours=1`);
442
+ const body = (await q.json()) as AISpansResponse;
443
+ // After the tests above we should have at least LLM, TOOL, RETRIEVER
444
+ // represented in the window.
445
+ expect(body.summary.byKind.LLM).toBeGreaterThanOrEqual(1);
446
+ expect(body.summary.byKind.RETRIEVER).toBeGreaterThanOrEqual(1);
447
+ expect(body.summary.byKind.TOOL).toBeGreaterThanOrEqual(1);
448
+ });
449
+ });