@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,148 @@
1
+ import type {
2
+ AICallRecord,
3
+ AICallsOverviewOptions,
4
+ AICallsOverviewResponse,
5
+ } from "@obsunified/types";
6
+ import { dialectFor, type SqlDb } from "../sql-db";
7
+ import type { AICallRow, AICallSummaryRow } from "./types";
8
+ import { clampInt } from "./types";
9
+
10
+ export async function ingestAICallBatch(
11
+ db: SqlDb,
12
+ calls: AICallRecord[],
13
+ ): Promise<void> {
14
+ if (calls.length === 0) return;
15
+
16
+ const stmt = db.prepare(`
17
+ INSERT OR IGNORE INTO ai_calls (
18
+ project_id, call_id, trace_id, span_id, service_name, model_name, provider, call_type,
19
+ request_json, response_json, prompt_tokens, completion_tokens, total_cost_usd,
20
+ latency_ms, is_error, error_message, occurred_at, received_at, expires_at,
21
+ session_id, interaction_id
22
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
23
+ `);
24
+
25
+ const batch = calls.map((call) => {
26
+ if (!call.projectId)
27
+ throw new Error("AIStore.ingestBatch: call.projectId is required");
28
+ return stmt.bind(
29
+ call.projectId,
30
+ call.callId,
31
+ call.traceId,
32
+ call.spanId,
33
+ call.serviceName,
34
+ call.modelName,
35
+ call.provider,
36
+ call.callType,
37
+ call.requestJson,
38
+ call.responseJson,
39
+ call.promptTokens,
40
+ call.completionTokens,
41
+ call.totalCostUsd,
42
+ call.latencyMs,
43
+ call.isError ? 1 : 0,
44
+ call.errorMessage,
45
+ call.occurredAt,
46
+ call.receivedAt,
47
+ call.expiresAt,
48
+ call.sessionId ?? null,
49
+ call.interactionId ?? null,
50
+ );
51
+ });
52
+
53
+ await db.batch(batch);
54
+ }
55
+
56
+ export async function getAICallsOverview(
57
+ db: SqlDb,
58
+ options: AICallsOverviewOptions,
59
+ ): Promise<AICallsOverviewResponse> {
60
+ if (!options.projectId)
61
+ throw new Error("AIStore.getAICalls: projectId is required");
62
+ const dialect = dialectFor(db);
63
+ const hours = clampInt(options.hours, 1, 720, 24);
64
+ const limit = clampInt(options.limit, 1, 1000, 100);
65
+
66
+ let sql = `SELECT * FROM ai_calls WHERE project_id = ? AND received_at >= ${dialect.sinceHours("?")}`;
67
+ const params: unknown[] = [options.projectId, hours];
68
+
69
+ if (options.service) {
70
+ sql += ` AND service_name = ?`;
71
+ params.push(options.service);
72
+ }
73
+ if (options.model) {
74
+ sql += ` AND model_name = ?`;
75
+ params.push(options.model);
76
+ }
77
+ if (options.isError !== undefined) {
78
+ sql += ` AND is_error = ?`;
79
+ params.push(options.isError ? 1 : 0);
80
+ }
81
+ if (options.traceId) {
82
+ sql += ` AND trace_id = ?`;
83
+ params.push(options.traceId);
84
+ }
85
+
86
+ sql += ` ORDER BY received_at DESC LIMIT ?`;
87
+ params.push(limit);
88
+
89
+ const results = await db
90
+ .prepare(sql)
91
+ .bind(...params)
92
+ .all<AICallRow>();
93
+
94
+ const summarySql = `
95
+ SELECT
96
+ COUNT(*) as totalCalls,
97
+ SUM(total_cost_usd) as totalCostUsd,
98
+ SUM(prompt_tokens) as totalPromptTokens,
99
+ SUM(completion_tokens) as totalCompletionTokens,
100
+ SUM(is_error) as errorCalls
101
+ FROM ai_calls WHERE project_id = ? AND received_at >= ${dialect.sinceHours("?")}
102
+ ${options.service ? "AND service_name = ?" : ""}
103
+ `;
104
+ const summaryParams: unknown[] = [options.projectId, hours];
105
+ if (options.service) summaryParams.push(options.service);
106
+ const summaryResult =
107
+ (await db
108
+ .prepare(summarySql)
109
+ .bind(...summaryParams)
110
+ .first<AICallSummaryRow>()) || {};
111
+
112
+ const calls: AICallRecord[] = (results.results || []).map((row) => ({
113
+ projectId: row.project_id ?? "default",
114
+ callId: row.call_id,
115
+ traceId: row.trace_id,
116
+ spanId: row.span_id,
117
+ serviceName: row.service_name,
118
+ modelName: row.model_name,
119
+ provider: row.provider,
120
+ callType: row.call_type,
121
+ requestJson: row.request_json,
122
+ responseJson: row.response_json,
123
+ promptTokens: row.prompt_tokens,
124
+ completionTokens: row.completion_tokens,
125
+ totalCostUsd: row.total_cost_usd,
126
+ latencyMs: row.latency_ms,
127
+ isError: row.is_error === 1,
128
+ errorMessage: row.error_message,
129
+ occurredAt: row.occurred_at,
130
+ receivedAt: row.received_at,
131
+ expiresAt: row.expires_at,
132
+ sessionId: row.session_id ?? null,
133
+ interactionId: row.interaction_id ?? null,
134
+ }));
135
+
136
+ return {
137
+ calls,
138
+ summary: {
139
+ totalCalls: summaryResult.totalCalls || 0,
140
+ totalCostUsd: summaryResult.totalCostUsd || 0,
141
+ totalPromptTokens: summaryResult.totalPromptTokens || 0,
142
+ totalCompletionTokens: summaryResult.totalCompletionTokens || 0,
143
+ errorCalls: summaryResult.errorCalls || 0,
144
+ },
145
+ windowHours: hours,
146
+ timestamp: new Date().toISOString(),
147
+ };
148
+ }
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { mapEvaluationRows } from "./evaluations";
3
+
4
+ describe("mapEvaluationRows", () => {
5
+ it("adds evidence references that pivot from AI evaluation to span and trace", () => {
6
+ const [evaluation] = mapEvaluationRows([
7
+ {
8
+ evaluation_id: "eval-1",
9
+ project_id: "default",
10
+ trace_id: "trace-1",
11
+ span_id: "span-1",
12
+ name: "groundedness",
13
+ score: 0.2,
14
+ label: "fail",
15
+ explanation: "unsupported answer",
16
+ source: "llm_judge",
17
+ metadata_json: "{}",
18
+ created_at: "2026-05-01T00:00:00.000Z",
19
+ expires_at: "2026-05-02T00:00:00.000Z",
20
+ },
21
+ ]);
22
+
23
+ expect(evaluation.evidenceReferences).toEqual([
24
+ expect.objectContaining({
25
+ evidenceId: "ai-evaluation:eval-1",
26
+ entityKind: "eval",
27
+ entityId: "eval-1",
28
+ route: "#/evals/eval-1",
29
+ source: "ai_span_evaluations.llm_judge",
30
+ citations: [
31
+ {
32
+ label: "span span-1",
33
+ entityKind: "span",
34
+ entityId: "trace-1:span-1",
35
+ route: "#/traces/trace-1#span=span-1",
36
+ },
37
+ {
38
+ label: "trace trace-1",
39
+ entityKind: "trace",
40
+ entityId: "trace-1",
41
+ route: "#/traces/trace-1",
42
+ },
43
+ ],
44
+ }),
45
+ ]);
46
+ });
47
+ });
@@ -0,0 +1,125 @@
1
+ import type {
2
+ AIEvaluationRecord,
3
+ AIEvaluationSource,
4
+ AIEvaluationsListOptions,
5
+ AIEvaluationsListResponse,
6
+ } from "@obsunified/types";
7
+ import { EVIDENCE_REFERENCE_CONTRACT } from "@obsunified/types";
8
+ import { aiEvaluationEvidenceReferences } from "../evidence-references";
9
+ import { parseJsonRecord } from "../json";
10
+ import type { SqlDb } from "../sql-db";
11
+ import type { AIEvaluationRow, IngestEvaluation } from "./types";
12
+ import { clampInt } from "./types";
13
+
14
+ export async function ingestAIEvaluations(
15
+ db: SqlDb,
16
+ evaluations: IngestEvaluation[],
17
+ ): Promise<void> {
18
+ if (evaluations.length === 0) return;
19
+ const stmt = db.prepare(`
20
+ INSERT INTO ai_span_evaluations (
21
+ evaluation_id, project_id, trace_id, span_id, name,
22
+ score, label, explanation, source, metadata_json,
23
+ created_at, expires_at
24
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
25
+ `);
26
+ await db.batch(
27
+ evaluations.map((evaluation) =>
28
+ stmt.bind(
29
+ evaluation.evaluationId,
30
+ evaluation.projectId,
31
+ evaluation.traceId,
32
+ evaluation.spanId,
33
+ evaluation.name,
34
+ evaluation.score,
35
+ evaluation.label,
36
+ evaluation.explanation,
37
+ evaluation.source,
38
+ evaluation.metadataJson,
39
+ evaluation.createdAt,
40
+ evaluation.expiresAt,
41
+ ),
42
+ ),
43
+ );
44
+ }
45
+
46
+ export async function listAIEvaluations(
47
+ db: SqlDb,
48
+ options: AIEvaluationsListOptions,
49
+ ): Promise<AIEvaluationsListResponse> {
50
+ if (!options.projectId)
51
+ throw new Error("AIStore.listEvaluations: projectId is required");
52
+ const limit = clampInt(options.limit, 1, 1000, 200);
53
+
54
+ let sql = `SELECT * FROM ai_span_evaluations WHERE project_id = ?`;
55
+ const params: unknown[] = [options.projectId];
56
+
57
+ if (options.traceId) {
58
+ sql += ` AND trace_id = ?`;
59
+ params.push(options.traceId);
60
+ }
61
+ if (options.spanId) {
62
+ sql += ` AND span_id = ?`;
63
+ params.push(options.spanId);
64
+ }
65
+ if (options.name) {
66
+ sql += ` AND name = ?`;
67
+ params.push(options.name);
68
+ }
69
+
70
+ sql += ` ORDER BY created_at DESC LIMIT ?`;
71
+ params.push(limit);
72
+
73
+ const results = await db
74
+ .prepare(sql)
75
+ .bind(...params)
76
+ .all<AIEvaluationRow>();
77
+
78
+ return {
79
+ evaluations: mapEvaluationRows(results.results || []),
80
+ timestamp: new Date().toISOString(),
81
+ };
82
+ }
83
+
84
+ export async function getAIEvaluation(
85
+ db: SqlDb,
86
+ projectId: string,
87
+ evaluationId: string,
88
+ ): Promise<AIEvaluationRecord | null> {
89
+ if (!projectId)
90
+ throw new Error("AIStore.getEvaluation: projectId is required");
91
+ const row = await db
92
+ .prepare(
93
+ `SELECT * FROM ai_span_evaluations
94
+ WHERE project_id = ? AND evaluation_id = ?
95
+ LIMIT 1`,
96
+ )
97
+ .bind(projectId, evaluationId)
98
+ .first<AIEvaluationRow>();
99
+
100
+ return row ? mapEvaluationRows([row])[0] : null;
101
+ }
102
+
103
+ export function mapEvaluationRows(
104
+ rows: AIEvaluationRow[],
105
+ ): AIEvaluationRecord[] {
106
+ return rows.map((row) => {
107
+ const evaluation: AIEvaluationRecord = {
108
+ evaluationId: row.evaluation_id,
109
+ projectId: row.project_id,
110
+ traceId: row.trace_id,
111
+ spanId: row.span_id,
112
+ name: row.name,
113
+ score: row.score,
114
+ label: row.label,
115
+ explanation: row.explanation,
116
+ source: row.source as AIEvaluationSource,
117
+ metadata: parseJsonRecord(row.metadata_json),
118
+ createdAt: row.created_at,
119
+ expiresAt: row.expires_at,
120
+ };
121
+ evaluation.evidenceReferences = aiEvaluationEvidenceReferences(evaluation);
122
+ evaluation.evidenceContract = EVIDENCE_REFERENCE_CONTRACT;
123
+ return evaluation;
124
+ });
125
+ }
@@ -0,0 +1,37 @@
1
+ import type { JsonValue } from "@obsunified/types";
2
+ import { computeCost } from "../ai-pricing";
3
+
4
+ export const attrNum = (
5
+ attrs: Record<string, JsonValue>,
6
+ key: string,
7
+ ): number | null => {
8
+ const v = attrs[key];
9
+ return typeof v === "number" && Number.isFinite(v) ? v : null;
10
+ };
11
+
12
+ export const attrStr = (
13
+ attrs: Record<string, JsonValue>,
14
+ key: string,
15
+ ): string | null => {
16
+ const v = attrs[key];
17
+ return typeof v === "string" && v.length > 0 ? v : null;
18
+ };
19
+
20
+ /**
21
+ * Enrich a span's attributes with a computed `llm.cost.total_usd` when the
22
+ * span has token counts but no reported cost. Mutates the passed attrs
23
+ * object and returns the final cost (or null).
24
+ */
25
+ export const enrichCost = (attrs: Record<string, JsonValue>): number | null => {
26
+ const existing = attrNum(attrs, "llm.cost.total_usd");
27
+ if (existing !== null) return existing;
28
+ const model = attrStr(attrs, "llm.model_name");
29
+ const prompt = attrNum(attrs, "llm.token_count.prompt");
30
+ const completion = attrNum(attrs, "llm.token_count.completion");
31
+ if (prompt === null && completion === null) return null;
32
+ const cost = computeCost(model, prompt, completion);
33
+ if (cost === null) return null;
34
+ attrs["llm.cost.total_usd"] = cost;
35
+ attrs["llm.cost.computed"] = true;
36
+ return cost;
37
+ };
@@ -0,0 +1,17 @@
1
+ import { dialectFor, type SqlDb } from "../sql-db";
2
+
3
+ export async function purgeExpiredAIData(db: SqlDb): Promise<number> {
4
+ const dialect = dialectFor(db);
5
+ const { meta } = await db
6
+ .prepare(`DELETE FROM ai_calls WHERE expires_at < ${dialect.now()}`)
7
+ .run();
8
+ const { meta: payloadMeta } = await db
9
+ .prepare(`DELETE FROM ai_span_payloads WHERE expires_at < ${dialect.now()}`)
10
+ .run();
11
+ const { meta: evalMeta } = await db
12
+ .prepare(
13
+ `DELETE FROM ai_span_evaluations WHERE expires_at < ${dialect.now()}`,
14
+ )
15
+ .run();
16
+ return meta.changes + (payloadMeta?.changes ?? 0) + (evalMeta?.changes ?? 0);
17
+ }
@@ -0,0 +1,277 @@
1
+ import type {
2
+ AISessionDetailResponse,
3
+ AISessionSummary,
4
+ AISessionsListOptions,
5
+ AISessionsListResponse,
6
+ AISpanRecord,
7
+ JsonValue,
8
+ } from "@obsunified/types";
9
+ import { parseJsonRecord } from "../json";
10
+ import { dialectFor, type SqlDb } from "../sql-db";
11
+ import { mapEvaluationRows } from "./evaluations";
12
+ import { attrNum, enrichCost } from "./helpers";
13
+ import type {
14
+ AIEvaluationRow,
15
+ AISessionPreviewRow,
16
+ AISessionRow,
17
+ AISpanRow,
18
+ } from "./types";
19
+ import { clampInt } from "./types";
20
+
21
+ export async function listAISessions(
22
+ db: SqlDb,
23
+ options: AISessionsListOptions,
24
+ ): Promise<AISessionsListResponse> {
25
+ if (!options.projectId)
26
+ throw new Error("AIStore.listSessions: projectId is required");
27
+ const dialect = dialectFor(db);
28
+ const hours = clampInt(options.hours, 1, 720, 24);
29
+ const limit = clampInt(options.limit, 1, 1000, 100);
30
+
31
+ let sql = `
32
+ SELECT
33
+ p.session_id AS session_id,
34
+ MAX(p.user_id) AS user_id,
35
+ COUNT(*) AS span_count,
36
+ SUM(CASE WHEN p.span_kind = 'LLM' THEN 1 ELSE 0 END) AS llm_span_count,
37
+ SUM(CASE WHEN s.status_code = 2 THEN 1 ELSE 0 END) AS error_count,
38
+ SUM(CAST(${dialect.jsonText("s.attributes_json", '$."llm.token_count.prompt"')} AS REAL)) AS prompt_tokens,
39
+ SUM(CAST(${dialect.jsonText("s.attributes_json", '$."llm.token_count.completion"')} AS REAL)) AS completion_tokens,
40
+ SUM(CAST(${dialect.jsonText("s.attributes_json", '$."llm.cost.total_usd"')} AS REAL)) AS cost_usd,
41
+ MIN(s.start_time) AS first_span_at,
42
+ MAX(s.start_time) AS last_span_at,
43
+ COUNT(DISTINCT s.trace_id) AS trace_count
44
+ FROM ai_span_payloads p
45
+ INNER JOIN telemetry_spans s
46
+ ON s.trace_id = p.trace_id AND s.span_id = p.span_id
47
+ WHERE p.project_id = ?
48
+ AND p.session_id IS NOT NULL
49
+ AND p.received_at >= ${dialect.sinceHours("?")}
50
+ `;
51
+ const params: unknown[] = [options.projectId, hours];
52
+ if (options.userId) {
53
+ sql += ` AND p.user_id = ?`;
54
+ params.push(options.userId);
55
+ }
56
+ sql += ` GROUP BY p.session_id ORDER BY last_span_at DESC LIMIT ?`;
57
+ params.push(limit);
58
+
59
+ const results = await db
60
+ .prepare(sql)
61
+ .bind(...params)
62
+ .all<AISessionRow>();
63
+ const sessionRows = results.results || [];
64
+ const previewBySession = await loadSessionPreviews(
65
+ db,
66
+ dialect,
67
+ options.projectId,
68
+ hours,
69
+ sessionRows.map((row) => row.session_id),
70
+ );
71
+
72
+ const sessions: AISessionSummary[] = sessionRows.map((row) => ({
73
+ sessionId: row.session_id,
74
+ userId: row.user_id ?? null,
75
+ spanCount: row.span_count ?? 0,
76
+ llmSpanCount: row.llm_span_count ?? 0,
77
+ errorCount: row.error_count ?? 0,
78
+ totalPromptTokens: row.prompt_tokens ?? 0,
79
+ totalCompletionTokens: row.completion_tokens ?? 0,
80
+ totalCostUsd: row.cost_usd ?? 0,
81
+ firstSpanAt: row.first_span_at,
82
+ lastSpanAt: row.last_span_at,
83
+ traceCount: row.trace_count ?? 0,
84
+ lastInputPreview: previewBySession.get(row.session_id) ?? null,
85
+ }));
86
+
87
+ return {
88
+ sessions,
89
+ windowHours: hours,
90
+ timestamp: new Date().toISOString(),
91
+ };
92
+ }
93
+
94
+ export async function getAISessionDetail(
95
+ db: SqlDb,
96
+ projectId: string,
97
+ sessionId: string,
98
+ ): Promise<AISessionDetailResponse> {
99
+ if (!projectId) throw new Error("AIStore.getSession: projectId is required");
100
+ if (!sessionId) throw new Error("AIStore.getSession: sessionId is required");
101
+
102
+ const sql = `
103
+ SELECT
104
+ s.trace_id AS trace_id,
105
+ s.span_id AS span_id,
106
+ s.parent_span_id AS parent_span_id,
107
+ s.service_name AS service_name,
108
+ s.span_name AS span_name,
109
+ s.status_code AS status_code,
110
+ s.status_message AS status_message,
111
+ s.start_time AS start_time,
112
+ s.end_time AS end_time,
113
+ s.duration_ms AS duration_ms,
114
+ s.attributes_json AS attributes_json,
115
+ p.span_kind AS span_kind,
116
+ p.input_json AS input_json,
117
+ p.output_json AS output_json,
118
+ p.user_id AS user_id
119
+ FROM ai_span_payloads p
120
+ INNER JOIN telemetry_spans s
121
+ ON s.trace_id = p.trace_id AND s.span_id = p.span_id
122
+ WHERE p.project_id = ? AND p.session_id = ?
123
+ ORDER BY s.start_time ASC
124
+ LIMIT 1000
125
+ `;
126
+ const results = await db
127
+ .prepare(sql)
128
+ .bind(projectId, sessionId)
129
+ .all<AISpanRow>();
130
+
131
+ const state = {
132
+ userId: null as string | null,
133
+ totalPromptTokens: 0,
134
+ totalCompletionTokens: 0,
135
+ totalCostUsd: 0,
136
+ errorCount: 0,
137
+ firstSpanAt: null as string | null,
138
+ lastSpanAt: null as string | null,
139
+ };
140
+
141
+ const spans: AISpanRecord[] = (results.results || []).map((row) =>
142
+ mapSessionSpan(row, state),
143
+ );
144
+ const evaluations = await loadSessionEvaluations(db, projectId, spans);
145
+
146
+ return {
147
+ sessionId,
148
+ userId: state.userId,
149
+ spans,
150
+ evaluations,
151
+ summary: {
152
+ spanCount: spans.length,
153
+ totalPromptTokens: state.totalPromptTokens,
154
+ totalCompletionTokens: state.totalCompletionTokens,
155
+ totalCostUsd: state.totalCostUsd,
156
+ errorCount: state.errorCount,
157
+ firstSpanAt: state.firstSpanAt,
158
+ lastSpanAt: state.lastSpanAt,
159
+ },
160
+ timestamp: new Date().toISOString(),
161
+ };
162
+ }
163
+
164
+ async function loadSessionPreviews(
165
+ db: SqlDb,
166
+ dialect: ReturnType<typeof dialectFor>,
167
+ projectId: string,
168
+ hours: number,
169
+ sessionIds: string[],
170
+ ) {
171
+ const previewBySession = new Map<string, string>();
172
+ if (sessionIds.length === 0) return previewBySession;
173
+
174
+ const previewRows = await db
175
+ .prepare(
176
+ `
177
+ SELECT p.session_id, p.input_json, s.start_time
178
+ FROM ai_span_payloads p
179
+ INNER JOIN telemetry_spans s
180
+ ON s.trace_id = p.trace_id AND s.span_id = p.span_id
181
+ WHERE p.project_id = ?
182
+ AND p.session_id IS NOT NULL
183
+ AND p.session_id IN (${sessionIds.map(() => "?").join(", ")})
184
+ AND p.received_at >= ${dialect.sinceHours("?")}
185
+ AND p.input_json IS NOT NULL
186
+ ORDER BY s.start_time DESC
187
+ LIMIT ?
188
+ `,
189
+ )
190
+ .bind(projectId, ...sessionIds, hours, sessionIds.length * 5)
191
+ .all<AISessionPreviewRow>();
192
+
193
+ for (const row of previewRows.results || []) {
194
+ if (!previewBySession.has(row.session_id)) {
195
+ const raw = typeof row.input_json === "string" ? row.input_json : "";
196
+ previewBySession.set(
197
+ row.session_id,
198
+ raw.length > 200 ? `${raw.slice(0, 200)}…` : raw,
199
+ );
200
+ }
201
+ }
202
+
203
+ return previewBySession;
204
+ }
205
+
206
+ function mapSessionSpan(
207
+ row: AISpanRow,
208
+ state: {
209
+ userId: string | null;
210
+ totalPromptTokens: number;
211
+ totalCompletionTokens: number;
212
+ totalCostUsd: number;
213
+ errorCount: number;
214
+ firstSpanAt: string | null;
215
+ lastSpanAt: string | null;
216
+ },
217
+ ): AISpanRecord {
218
+ const attrs = parseJsonRecord(row.attributes_json) as Record<
219
+ string,
220
+ JsonValue
221
+ >;
222
+ const cost = enrichCost(attrs);
223
+ if (!state.userId && row.user_id) state.userId = row.user_id;
224
+ state.totalPromptTokens += attrNum(attrs, "llm.token_count.prompt") ?? 0;
225
+ state.totalCompletionTokens +=
226
+ attrNum(attrs, "llm.token_count.completion") ?? 0;
227
+ if (cost !== null) state.totalCostUsd += cost;
228
+ if (row.status_code === 2) state.errorCount++;
229
+ if (!state.firstSpanAt || row.start_time < state.firstSpanAt) {
230
+ state.firstSpanAt = row.start_time;
231
+ }
232
+ if (!state.lastSpanAt || row.start_time > state.lastSpanAt) {
233
+ state.lastSpanAt = row.start_time;
234
+ }
235
+
236
+ return {
237
+ traceId: row.trace_id,
238
+ spanId: row.span_id,
239
+ parentSpanId: row.parent_span_id,
240
+ serviceName: row.service_name,
241
+ spanName: row.span_name,
242
+ spanKind: row.span_kind,
243
+ statusCode: row.status_code ?? 0,
244
+ statusMessage: row.status_message,
245
+ startTime: row.start_time,
246
+ endTime: row.end_time ?? row.start_time,
247
+ durationMs: row.duration_ms ?? 0,
248
+ attributes: attrs,
249
+ inputJson: row.input_json,
250
+ outputJson: row.output_json,
251
+ };
252
+ }
253
+
254
+ async function loadSessionEvaluations(
255
+ db: SqlDb,
256
+ projectId: string,
257
+ spans: AISpanRecord[],
258
+ ) {
259
+ if (spans.length === 0) return [];
260
+
261
+ const placeholders = spans.map(() => "(?, ?)").join(", ");
262
+ const bindings: unknown[] = [projectId];
263
+ for (const span of spans) {
264
+ bindings.push(span.traceId, span.spanId);
265
+ }
266
+ const sql = `
267
+ SELECT * FROM ai_span_evaluations
268
+ WHERE project_id = ?
269
+ AND (trace_id, span_id) IN (${placeholders})
270
+ ORDER BY created_at DESC
271
+ `;
272
+ const results = await db
273
+ .prepare(sql)
274
+ .bind(...bindings)
275
+ .all<AIEvaluationRow>();
276
+ return mapEvaluationRows(results.results || []);
277
+ }