@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,302 @@
1
+ /**
2
+ * Normalize OpenTelemetry `gen_ai.*` semantic-convention attributes, OTel MCP attributes,
3
+ * and OpenInference span kinds into the canonical obs-unified Agent Action Graph schema.
4
+ *
5
+ * References:
6
+ * https://opentelemetry.io/docs/specs/semconv/gen-ai/
7
+ * https://github.com/Arize-ai/openinference/blob/main/spec/
8
+ *
9
+ * Must run BEFORE ai-span-payloads-processor — that processor assumes
10
+ * `openinference.span.kind` and `ai.payload.*` are already set.
11
+ */
12
+
13
+ import type { JsonValue, StoredSpan } from "@obsunified/types";
14
+ import {
15
+ ACTION_CAUSED_BY_ID_KEY,
16
+ ACTION_CONFIDENCE_KEY,
17
+ ACTION_ID_KEY,
18
+ ACTION_KIND_KEY,
19
+ ACTION_ROOT_ID_KEY,
20
+ ACTOR_TYPE_KEY,
21
+ ActionKind,
22
+ AGENT_RUN_ID_KEY,
23
+ AI_PAYLOAD_INPUT_KEY,
24
+ AI_PAYLOAD_OUTPUT_KEY,
25
+ OPENINFERENCE_SPAN_KIND_KEY,
26
+ OpenInferenceSpanKind,
27
+ TOOL_ARGS_KEY,
28
+ TOOL_CALL_ID_KEY,
29
+ TOOL_NAME_KEY,
30
+ TOOL_SIDE_EFFECT_KEY,
31
+ } from "@obsunified/types/constants";
32
+ import type { CollectorPlugin } from "../framework/collector";
33
+ import { deriveActionId, resolveActionIdentity } from "../lib/action-identity";
34
+ import { parseJsonRecord } from "../lib/json";
35
+
36
+ const GEN_AI_PREFIX = "gen_ai.";
37
+ const MCP_PREFIX = "mcp.";
38
+
39
+ export { deriveActionId };
40
+
41
+ // Map gen_ai operation.name → OpenInference span kind.
42
+ const operationToKind = (op: unknown): string => {
43
+ if (typeof op !== "string") return OpenInferenceSpanKind.LLM;
44
+ const lower = op.toLowerCase();
45
+ if (lower.includes("embed")) return OpenInferenceSpanKind.EMBEDDING;
46
+ if (lower.includes("tool")) return OpenInferenceSpanKind.TOOL;
47
+ if (lower.includes("agent")) return OpenInferenceSpanKind.AGENT;
48
+ return OpenInferenceSpanKind.LLM;
49
+ };
50
+
51
+ const asNumber = (value: unknown): number | undefined => {
52
+ if (typeof value === "number" && Number.isFinite(value)) return value;
53
+ if (typeof value === "string") {
54
+ const parsed = Number(value);
55
+ if (Number.isFinite(parsed)) return parsed;
56
+ }
57
+ return undefined;
58
+ };
59
+
60
+ const toJsonString = (value: unknown): string | null => {
61
+ if (value === undefined || value === null) return null;
62
+ if (typeof value === "string") return value;
63
+ try {
64
+ return JSON.stringify(value);
65
+ } catch {
66
+ return String(value);
67
+ }
68
+ };
69
+
70
+ const SIDE_EFFECT_VERBS = new Set([
71
+ "create",
72
+ "delete",
73
+ "insert",
74
+ "mutate",
75
+ "patch",
76
+ "remove",
77
+ "set",
78
+ "update",
79
+ "upsert",
80
+ "write",
81
+ ]);
82
+
83
+ const inferToolSideEffect = (toolName: unknown): 0 | 1 => {
84
+ if (typeof toolName !== "string") return 0;
85
+ const [firstToken] = toolName
86
+ .trim()
87
+ .toLowerCase()
88
+ .split(/[^a-z0-9]+/)
89
+ .filter(Boolean);
90
+ return firstToken && SIDE_EFFECT_VERBS.has(firstToken) ? 1 : 0;
91
+ };
92
+
93
+ const collectIndexed = (
94
+ attrs: Record<string, JsonValue>,
95
+ prefix: string,
96
+ ): unknown | undefined => {
97
+ const scalar = attrs[prefix];
98
+ if (scalar !== undefined) return scalar;
99
+
100
+ const messages: Array<Record<string, JsonValue>> = [];
101
+ const indexed = Object.keys(attrs).filter((k) => k.startsWith(`${prefix}.`));
102
+ if (indexed.length === 0) return undefined;
103
+
104
+ for (const key of indexed) {
105
+ const rest = key.slice(prefix.length + 1);
106
+ const dot = rest.indexOf(".");
107
+ const idx = Number.parseInt(rest.slice(0, dot), 10);
108
+ if (!Number.isFinite(idx)) continue;
109
+ const field = rest.slice(dot + 1);
110
+ messages[idx] = messages[idx] ?? {};
111
+ messages[idx][field] = attrs[key];
112
+ }
113
+
114
+ const compact = messages.filter(
115
+ (m): m is Record<string, JsonValue> => m !== undefined,
116
+ );
117
+ return compact.length > 0 ? compact : undefined;
118
+ };
119
+
120
+ export const genAiNormalizerPlugin: CollectorPlugin = {
121
+ name: "gen-ai-normalizer",
122
+ register(_app, runtime) {
123
+ runtime.addSpanProcessor({
124
+ name: "gen-ai-normalizer",
125
+ async process(spans) {
126
+ return Promise.all(
127
+ spans.map(async (span): Promise<StoredSpan> => {
128
+ const attrs = parseJsonRecord(span.attributesJson);
129
+
130
+ const mcpMethod = attrs["mcp.method.name"] || attrs["mcp.method"];
131
+ const jsonRpcId =
132
+ attrs["jsonrpc.request.id"] || attrs["jsonrpc.id"];
133
+ const hasMcp =
134
+ mcpMethod !== undefined ||
135
+ jsonRpcId !== undefined ||
136
+ Object.keys(attrs).some((k) => k.startsWith(MCP_PREFIX));
137
+
138
+ const hasGenAi = Object.keys(attrs).some((k) =>
139
+ k.startsWith(GEN_AI_PREFIX),
140
+ );
141
+
142
+ const openInferenceKind = attrs[OPENINFERENCE_SPAN_KIND_KEY];
143
+ const hasOpenInference = openInferenceKind !== undefined;
144
+
145
+ // If it's not a GenAI, MCP, or OpenInference span, keep it as is.
146
+ if (!hasGenAi && !hasMcp && !hasOpenInference) {
147
+ return span;
148
+ }
149
+
150
+ const normalized: Record<string, JsonValue> = { ...attrs };
151
+
152
+ // 1. Determine OpenInference Span Kind
153
+ let kind = openInferenceKind as string | undefined;
154
+ if (hasMcp && !kind) {
155
+ if (mcpMethod === "tools/call") {
156
+ kind = OpenInferenceSpanKind.TOOL;
157
+ } else if (mcpMethod === "resources/read") {
158
+ kind = OpenInferenceSpanKind.RETRIEVER;
159
+ } else if (mcpMethod === "prompts/get") {
160
+ kind = OpenInferenceSpanKind.PROMPT;
161
+ } else {
162
+ kind = OpenInferenceSpanKind.CHAIN;
163
+ }
164
+ } else if (hasGenAi && !kind) {
165
+ kind = operationToKind(attrs["gen_ai.operation.name"]);
166
+ }
167
+ if (kind) {
168
+ normalized[OPENINFERENCE_SPAN_KIND_KEY] = kind;
169
+ }
170
+
171
+ // 2. Set Action Graph Schema Attributes
172
+ const identity = await resolveActionIdentity(span, attrs);
173
+ const actionId = identity.actionId;
174
+
175
+ normalized[ACTION_ID_KEY] = actionId;
176
+ normalized[ACTION_CONFIDENCE_KEY] = identity.confidence;
177
+ normalized[ACTION_ROOT_ID_KEY] = identity.rootActionId;
178
+ normalized[ACTION_CAUSED_BY_ID_KEY] = identity.causedByActionId;
179
+
180
+ if (normalized[ACTOR_TYPE_KEY] === undefined) {
181
+ normalized[ACTOR_TYPE_KEY] = "agent";
182
+ }
183
+
184
+ if (normalized[AGENT_RUN_ID_KEY] === undefined) {
185
+ normalized[AGENT_RUN_ID_KEY] =
186
+ identity.agentRunId ?? identity.rootActionId;
187
+ }
188
+
189
+ // Determine Action Kind
190
+ let actionKind = attrs[ACTION_KIND_KEY] as string | undefined;
191
+ if (!actionKind && kind) {
192
+ if (kind === OpenInferenceSpanKind.AGENT) {
193
+ actionKind = ActionKind.AgentStep;
194
+ } else if (kind === OpenInferenceSpanKind.LLM) {
195
+ actionKind = ActionKind.LlmCall;
196
+ } else if (kind === OpenInferenceSpanKind.TOOL) {
197
+ actionKind = ActionKind.ToolCall;
198
+ } else if (kind === OpenInferenceSpanKind.RETRIEVER) {
199
+ actionKind = ActionKind.Retrieval;
200
+ } else if (kind === OpenInferenceSpanKind.EVALUATOR) {
201
+ actionKind = ActionKind.Eval;
202
+ } else {
203
+ actionKind = ActionKind.AgentStep;
204
+ }
205
+ }
206
+ if (actionKind) {
207
+ normalized[ACTION_KIND_KEY] = actionKind;
208
+ }
209
+
210
+ // 3. MCP Specific Mapping Details
211
+ if (hasMcp) {
212
+ if (kind === OpenInferenceSpanKind.TOOL) {
213
+ if (normalized[TOOL_CALL_ID_KEY] === undefined) {
214
+ normalized[TOOL_CALL_ID_KEY] = actionId;
215
+ }
216
+ if (normalized[TOOL_NAME_KEY] === undefined) {
217
+ const tName =
218
+ attrs["mcp.tool.name"] ??
219
+ attrs["mcp.operation.name"] ??
220
+ attrs["mcp.name"] ??
221
+ span.spanName;
222
+ normalized[TOOL_NAME_KEY] = tName;
223
+ normalized["obs.tool_call.tool_name"] = tName;
224
+ }
225
+ if (normalized[TOOL_ARGS_KEY] === undefined) {
226
+ const rawArgs =
227
+ attrs["mcp.tool.arguments"] ??
228
+ attrs["mcp.operation.arguments"] ??
229
+ attrs["mcp.arguments"];
230
+ const tArgs = toJsonString(rawArgs) ?? "{}";
231
+ normalized[TOOL_ARGS_KEY] = tArgs;
232
+ normalized["obs.tool_call.args"] = tArgs;
233
+ normalized[AI_PAYLOAD_INPUT_KEY] = tArgs;
234
+ }
235
+ if (normalized[TOOL_SIDE_EFFECT_KEY] === undefined) {
236
+ const sideEffect =
237
+ attrs["mcp.tool.side_effect"] ??
238
+ attrs["mcp.side_effect"] ??
239
+ inferToolSideEffect(attrs["mcp.tool.name"]);
240
+ normalized[TOOL_SIDE_EFFECT_KEY] = sideEffect;
241
+ normalized["obs.tool_call.side_effect"] = sideEffect;
242
+ }
243
+ }
244
+ }
245
+
246
+ // 4. GenAI Model + provider normalization
247
+ const model =
248
+ (attrs["gen_ai.response.model"] as string | undefined) ||
249
+ (attrs["gen_ai.request.model"] as string | undefined);
250
+ const provider = attrs["gen_ai.system"];
251
+ if (kind === OpenInferenceSpanKind.LLM) {
252
+ if (typeof model === "string")
253
+ normalized["llm.model_name"] = model;
254
+ if (typeof provider === "string")
255
+ normalized["llm.provider"] = provider;
256
+ } else if (kind === OpenInferenceSpanKind.EMBEDDING) {
257
+ if (typeof model === "string")
258
+ normalized["embedding.model_name"] = model;
259
+ if (typeof provider === "string")
260
+ normalized["embedding.provider"] = provider;
261
+ }
262
+
263
+ // GenAI Token Usage
264
+ const promptTokens =
265
+ asNumber(attrs["gen_ai.usage.input_tokens"]) ??
266
+ asNumber(attrs["gen_ai.usage.prompt_tokens"]);
267
+ const completionTokens =
268
+ asNumber(attrs["gen_ai.usage.output_tokens"]) ??
269
+ asNumber(attrs["gen_ai.usage.completion_tokens"]);
270
+ if (promptTokens !== undefined)
271
+ normalized["llm.token_count.prompt"] = promptTokens;
272
+ if (completionTokens !== undefined)
273
+ normalized["llm.token_count.completion"] = completionTokens;
274
+ if (promptTokens !== undefined && completionTokens !== undefined) {
275
+ normalized["llm.token_count.total"] =
276
+ promptTokens + completionTokens;
277
+ }
278
+
279
+ // GenAI Inputs/Outputs
280
+ if (normalized[AI_PAYLOAD_INPUT_KEY] === undefined) {
281
+ const input = collectIndexed(attrs, "gen_ai.prompt");
282
+ if (input !== undefined) {
283
+ normalized[AI_PAYLOAD_INPUT_KEY] = toJsonString(input) ?? "";
284
+ }
285
+ }
286
+ if (normalized[AI_PAYLOAD_OUTPUT_KEY] === undefined) {
287
+ const output = collectIndexed(attrs, "gen_ai.completion");
288
+ if (output !== undefined) {
289
+ normalized[AI_PAYLOAD_OUTPUT_KEY] = toJsonString(output) ?? "";
290
+ }
291
+ }
292
+
293
+ return {
294
+ ...span,
295
+ attributesJson: JSON.stringify(normalized),
296
+ };
297
+ }),
298
+ );
299
+ },
300
+ });
301
+ },
302
+ };
@@ -0,0 +1,96 @@
1
+ import type { IdentifyInput } from "@obsunified/types";
2
+ import type { CollectorPlugin } from "../framework/collector";
3
+ import { dialectFor, sqlDbFor } from "../lib/sql-db";
4
+ import { getProjectId } from "./_context";
5
+
6
+ export const identityReceiverPlugin: CollectorPlugin = {
7
+ name: "identity-receiver",
8
+ register(app) {
9
+ app.post("/v1/identify", async (c) => {
10
+ const projectId = getProjectId(c);
11
+ let payload: IdentifyInput;
12
+ try {
13
+ payload = await c.req.json<IdentifyInput>();
14
+ } catch {
15
+ return c.json({ error: "Invalid JSON body" }, 400);
16
+ }
17
+ if (!payload.userId || typeof payload.userId !== "string") {
18
+ return c.json({ error: "userId is required" }, 400);
19
+ }
20
+ if (payload.userId.length > 256) {
21
+ return c.json({ error: "userId too long (max 256)" }, 400);
22
+ }
23
+ if (
24
+ payload.email &&
25
+ (typeof payload.email !== "string" || payload.email.length > 320)
26
+ ) {
27
+ return c.json({ error: "Invalid email" }, 400);
28
+ }
29
+ if (
30
+ payload.name &&
31
+ (typeof payload.name !== "string" || payload.name.length > 256)
32
+ ) {
33
+ return c.json({ error: "Invalid name" }, 400);
34
+ }
35
+ const now = new Date().toISOString();
36
+
37
+ // Backfill window: callers (synthetic seeds, historical imports)
38
+ // can pass firstSeenAt to set the user's earliest observed
39
+ // activity. We accept anything in the past; future timestamps
40
+ // fall back to `now` so a clock-skew client can't poison the
41
+ // table.
42
+ let firstSeenAt = now;
43
+ if (payload.firstSeenAt) {
44
+ const parsed = new Date(payload.firstSeenAt);
45
+ if (!Number.isNaN(parsed.getTime()) && parsed.getTime() <= Date.now()) {
46
+ firstSeenAt = parsed.toISOString();
47
+ }
48
+ }
49
+
50
+ const propertiesJson = payload.properties
51
+ ? JSON.stringify(payload.properties)
52
+ : null;
53
+
54
+ // user_id is the PK today (single-tenant legacy). For MVP, we keep
55
+ // that contract — user_id is treated as globally unique and the
56
+ // first project to identify them "owns" the profile. project_id on
57
+ // the row reflects where the user was first seen.
58
+ //
59
+ // On conflict we keep the EARLIEST first_seen_at via MIN() — once
60
+ // observed, a user's first-seen is monotonic; an identify() call
61
+ // that runs at "now" must not overwrite a true historical value.
62
+ const db = sqlDbFor(c.env);
63
+ const firstSeenExpr =
64
+ dialectFor(db).name === "postgres"
65
+ ? "LEAST(user_profiles.first_seen_at, excluded.first_seen_at)"
66
+ : "MIN(user_profiles.first_seen_at, excluded.first_seen_at)";
67
+
68
+ await db
69
+ .prepare(
70
+ `INSERT INTO user_profiles (project_id, user_id, visitor_id, email, name, properties_json, first_seen_at, last_seen_at)
71
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
72
+ ON CONFLICT(user_id) DO UPDATE SET
73
+ visitor_id = excluded.visitor_id,
74
+ email = COALESCE(excluded.email, user_profiles.email),
75
+ name = COALESCE(excluded.name, user_profiles.name),
76
+ properties_json = COALESCE(excluded.properties_json, user_profiles.properties_json),
77
+ first_seen_at = ${firstSeenExpr},
78
+ last_seen_at = excluded.last_seen_at
79
+ `,
80
+ )
81
+ .bind(
82
+ projectId,
83
+ payload.userId,
84
+ payload.visitorId,
85
+ payload.email ?? null,
86
+ payload.name ?? null,
87
+ propertiesJson,
88
+ firstSeenAt,
89
+ now,
90
+ )
91
+ .run();
92
+
93
+ return c.json({ success: true, userId: payload.userId });
94
+ });
95
+ },
96
+ };
@@ -0,0 +1,132 @@
1
+ import type { JsonValue, StoredSpan } from "@obsunified/types";
2
+ import type { CollectorPlugin } from "../framework/collector";
3
+ import { parseJsonRecord } from "../lib/json";
4
+
5
+ const INTERNAL_PATH_PREFIXES = [
6
+ "/api/admin",
7
+ "/api/internal",
8
+ "/internal/telemetry",
9
+ "/v1/traces",
10
+ "/health",
11
+ ];
12
+
13
+ const getStringValue = (
14
+ record: Record<string, JsonValue>,
15
+ keys: string[],
16
+ ): string | null => {
17
+ for (const key of keys) {
18
+ const value = record[key];
19
+ if (typeof value === "string" && value.trim()) return value.trim();
20
+ }
21
+ return null;
22
+ };
23
+
24
+ const normalizePathSegment = (segment: string): string => {
25
+ if (!segment) return segment;
26
+ if (/^\d+$/.test(segment)) return ":id";
27
+ if (/^[0-9a-f]{16,}$/i.test(segment)) return ":id";
28
+ if (
29
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
30
+ segment,
31
+ )
32
+ )
33
+ return ":uuid";
34
+ return segment;
35
+ };
36
+
37
+ const normalizePath = (path: string): string => {
38
+ const [pathname] = path.split("?");
39
+ const segments = pathname
40
+ .split("/")
41
+ .map(normalizePathSegment)
42
+ .filter((segment, index) => !(index === 0 && segment === ""));
43
+ return `/${segments.join("/")}`.replace(/\/{2,}/g, "/");
44
+ };
45
+
46
+ const isInternalPath = (path: string): boolean =>
47
+ INTERNAL_PATH_PREFIXES.some(
48
+ (prefix) => path === prefix || path.startsWith(`${prefix}/`),
49
+ );
50
+
51
+ const deriveDependencyTarget = (
52
+ attributes: Record<string, JsonValue>,
53
+ resourceAttributes: Record<string, JsonValue>,
54
+ ): string | null => {
55
+ const dbSystem = getStringValue(attributes, ["db.system"]);
56
+ if (dbSystem) return dbSystem;
57
+ const peerService = getStringValue(attributes, [
58
+ "peer.service",
59
+ "server.address",
60
+ "http.host",
61
+ ]);
62
+ if (peerService) return peerService;
63
+ const urlValue = getStringValue(attributes, ["url.full", "http.url"]);
64
+ if (urlValue) {
65
+ try {
66
+ return new URL(urlValue).host;
67
+ } catch {
68
+ return urlValue;
69
+ }
70
+ }
71
+ return getStringValue(resourceAttributes, ["host.name"]);
72
+ };
73
+
74
+ export const issueEnrichmentPlugin: CollectorPlugin = {
75
+ name: "issue-enrichment",
76
+ register(_app, runtime) {
77
+ runtime.addSpanProcessor({
78
+ name: "issue-enrichment",
79
+ process(spans) {
80
+ return spans.map((span): StoredSpan => {
81
+ const attributes = parseJsonRecord(span.attributesJson);
82
+ const resourceAttributes = parseJsonRecord(
83
+ span.resourceAttributesJson,
84
+ );
85
+ const method = getStringValue(attributes, [
86
+ "http.request.method",
87
+ "http.method",
88
+ ]);
89
+ const path = getStringValue(attributes, [
90
+ "url.path",
91
+ "http.target",
92
+ "http.route",
93
+ ]);
94
+ const routePath = path ? normalizePath(path) : null;
95
+ const existingRouteLabel = getStringValue(attributes, [
96
+ "collector.route_label",
97
+ ]);
98
+ const dependencyTarget = deriveDependencyTarget(
99
+ attributes,
100
+ resourceAttributes,
101
+ );
102
+ const isDependencySpan = Boolean(
103
+ dependencyTarget ||
104
+ span.spanKind === 3 ||
105
+ span.spanKind === 4 ||
106
+ span.spanKind === 5,
107
+ );
108
+
109
+ const nextAttributes: Record<string, JsonValue> = { ...attributes };
110
+
111
+ if (routePath) {
112
+ nextAttributes["collector.route_key"] = routePath;
113
+ if (!existingRouteLabel) {
114
+ nextAttributes["collector.route_label"] = method
115
+ ? `${method} ${routePath}`
116
+ : routePath;
117
+ }
118
+ nextAttributes["collector.is_internal"] = isInternalPath(routePath);
119
+ }
120
+ if (dependencyTarget && isDependencySpan) {
121
+ nextAttributes["collector.dependency_target"] = dependencyTarget;
122
+ }
123
+ if (isDependencySpan) {
124
+ nextAttributes["collector.span_role"] = "dependency";
125
+ }
126
+
127
+ return { ...span, attributesJson: JSON.stringify(nextAttributes) };
128
+ });
129
+ },
130
+ });
131
+ },
132
+ };
@@ -0,0 +1,81 @@
1
+ import {
2
+ DEFAULT_WINDOW_HOURS,
3
+ MAX_ISSUE_ROWS,
4
+ RETENTION_HOURS,
5
+ } from "@obsunified/types/constants";
6
+ import type { CollectorPlugin } from "../framework/collector";
7
+ import { getProjectId } from "./_context";
8
+
9
+ const parseHours = (rawValue?: string): number =>
10
+ Math.min(
11
+ RETENTION_HOURS,
12
+ Math.max(
13
+ 1,
14
+ Number.parseInt(rawValue || String(DEFAULT_WINDOW_HOURS), 10) ||
15
+ DEFAULT_WINDOW_HOURS,
16
+ ),
17
+ );
18
+
19
+ const parseLimit = (rawValue?: string): number =>
20
+ Math.min(
21
+ MAX_ISSUE_ROWS,
22
+ Math.max(
23
+ 1,
24
+ Number.parseInt(rawValue || String(MAX_ISSUE_ROWS), 10) || MAX_ISSUE_ROWS,
25
+ ),
26
+ );
27
+
28
+ export const issueInsightsPlugin: CollectorPlugin = {
29
+ name: "issue-insights",
30
+ register(app, runtime) {
31
+ app.get("/internal/telemetry/issues", async (c) => {
32
+ const projectId = getProjectId(c);
33
+ const store = runtime.createStore(c.env);
34
+ const overview = await store.getIssueOverview({
35
+ projectId,
36
+ hours: parseHours(c.req.query("hours")),
37
+ service: c.req.query("service") || undefined,
38
+ category:
39
+ c.req.query("category") === "error" ||
40
+ c.req.query("category") === "latency" ||
41
+ c.req.query("category") === "dependency"
42
+ ? (c.req.query("category") as "error" | "latency" | "dependency")
43
+ : "all",
44
+ includeInternal: c.req.query("includeInternal") === "true",
45
+ limit: parseLimit(c.req.query("limit")),
46
+ });
47
+ return c.json({
48
+ ...overview,
49
+ plugins: runtime.getRegisteredPluginNames(),
50
+ });
51
+ });
52
+
53
+ app.get("/internal/telemetry/issues/detail", async (c) => {
54
+ const issueId = c.req.query("issueId");
55
+ if (!issueId)
56
+ return c.json(
57
+ { error: "Bad Request", message: "issueId is required" },
58
+ 400,
59
+ );
60
+
61
+ const projectId = getProjectId(c);
62
+ const store = runtime.createStore(c.env);
63
+ const detail = await store.getIssueDetail(issueId, {
64
+ projectId,
65
+ hours: parseHours(c.req.query("hours")),
66
+ service: c.req.query("service") || undefined,
67
+ category:
68
+ c.req.query("category") === "error" ||
69
+ c.req.query("category") === "latency" ||
70
+ c.req.query("category") === "dependency"
71
+ ? (c.req.query("category") as "error" | "latency" | "dependency")
72
+ : "all",
73
+ includeInternal: c.req.query("includeInternal") === "true",
74
+ });
75
+
76
+ if (!detail)
77
+ return c.json({ error: "Not Found", message: "Issue not found" }, 404);
78
+ return c.json({ ...detail, plugins: runtime.getRegisteredPluginNames() });
79
+ });
80
+ },
81
+ };