@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,680 @@
1
+ import type { StoredSpan } from "@obsunified/types";
2
+ import {
3
+ ACTION_CAUSED_BY_ID_KEY,
4
+ ACTION_CONFIDENCE_KEY,
5
+ ACTION_ID_KEY,
6
+ ACTION_KIND_KEY,
7
+ ACTION_ROOT_ID_KEY,
8
+ ACTOR_TYPE_KEY,
9
+ ActionConfidence,
10
+ ActionKind,
11
+ AGENT_RUN_ID_KEY,
12
+ OPENINFERENCE_SPAN_KIND_KEY,
13
+ OpenInferenceSpanKind,
14
+ TOOL_ARGS_KEY,
15
+ TOOL_CALL_ID_KEY,
16
+ TOOL_NAME_KEY,
17
+ TOOL_SIDE_EFFECT_KEY,
18
+ } from "@obsunified/types/constants";
19
+ import { describe, expect, it } from "vitest";
20
+ import type {
21
+ CollectorRuntime,
22
+ SpanProcessorPlugin,
23
+ } from "../framework/collector";
24
+ import type { CollectorRouteContext } from "../framework/env";
25
+ import { parseJsonRecord } from "../lib/json";
26
+ import { deriveActionId, genAiNormalizerPlugin } from "./gen-ai-normalizer";
27
+
28
+ describe("deriveActionId deterministic fallback ID hashing", () => {
29
+ it("should generate a stable 26-character Crockford base32 action ID", async () => {
30
+ const actionId1 = await deriveActionId("proj-123", "trace-abc", "span-xyz");
31
+ const actionId2 = await deriveActionId("proj-123", "trace-abc", "span-xyz");
32
+
33
+ expect(actionId1).toBe(actionId2);
34
+ expect(actionId1).toHaveLength(26);
35
+ expect(actionId1).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/);
36
+
37
+ const actionId3 = await deriveActionId(
38
+ "proj-123",
39
+ "trace-abc",
40
+ "span-different",
41
+ );
42
+ expect(actionId1).not.toBe(actionId3);
43
+ });
44
+ });
45
+
46
+ describe("genAiNormalizerPlugin - OTel GenAI normalization", () => {
47
+ const context = {
48
+ env: {},
49
+ now: new Date(),
50
+ logger: console,
51
+ } as unknown as CollectorRouteContext;
52
+
53
+ const registerNormalizer = () => {
54
+ const processors: SpanProcessorPlugin[] = [];
55
+ const app = {};
56
+ const runtime = {
57
+ addSpanProcessor(p: SpanProcessorPlugin) {
58
+ processors.push(p);
59
+ },
60
+ };
61
+
62
+ genAiNormalizerPlugin.register(
63
+ app as Parameters<typeof genAiNormalizerPlugin.register>[0],
64
+ runtime as unknown as CollectorRuntime,
65
+ );
66
+ return processors[0].process;
67
+ };
68
+
69
+ it("should normalize raw gen_ai.* spans and derive canonical Action Graph attributes", async () => {
70
+ const processFn = registerNormalizer();
71
+
72
+ const rawSpan: StoredSpan = {
73
+ projectId: "proj-123",
74
+ spanId: "span-456",
75
+ parentSpanId: "span-parent",
76
+ traceId: "trace-789",
77
+ traceState: null,
78
+ serviceName: "my-service",
79
+ scopeName: null,
80
+ scopeVersion: null,
81
+ spanName: "chat",
82
+ spanKind: 1,
83
+ statusCode: 1,
84
+ statusMessage: null,
85
+ startTime: "2026-05-31T22:00:00.000Z",
86
+ endTime: "2026-05-31T22:00:02.000Z",
87
+ durationMs: 2000,
88
+ attributesJson: JSON.stringify({
89
+ "gen_ai.operation.name": "chat",
90
+ "gen_ai.system": "openai",
91
+ "gen_ai.request.model": "gpt-4o",
92
+ "gen_ai.usage.input_tokens": 120,
93
+ "gen_ai.usage.output_tokens": 80,
94
+ "gen_ai.prompt": "Hello normalizer",
95
+ }),
96
+ droppedAttributesCount: 0,
97
+ resourceAttributesJson: "{}",
98
+ eventsJson: "[]",
99
+ droppedEventsCount: 0,
100
+ linksJson: "[]",
101
+ droppedLinksCount: 0,
102
+ receivedAt: "2026-05-31T22:00:02.000Z",
103
+ expiresAt: "2026-06-01T22:00:02.000Z",
104
+ sessionId: null,
105
+ interactionId: null,
106
+ };
107
+
108
+ const [processed] = await processFn([rawSpan], context);
109
+ const attrs = parseJsonRecord(processed.attributesJson);
110
+
111
+ // OpenInference conversions
112
+ expect(attrs[OPENINFERENCE_SPAN_KIND_KEY]).toBe(OpenInferenceSpanKind.LLM);
113
+ expect(attrs["llm.model_name"]).toBe("gpt-4o");
114
+ expect(attrs["llm.provider"]).toBe("openai");
115
+ expect(attrs["llm.token_count.prompt"]).toBe(120);
116
+ expect(attrs["llm.token_count.completion"]).toBe(80);
117
+ expect(attrs["llm.token_count.total"]).toBe(200);
118
+
119
+ // Action Graph Schema attributes
120
+ expect(attrs[ACTION_ID_KEY]).toBeDefined();
121
+ expect(attrs[ACTION_ID_KEY]).toHaveLength(26);
122
+ expect(attrs[ACTION_CONFIDENCE_KEY]).toBe(ActionConfidence.Fallback);
123
+
124
+ // Derived root and causal parent relationships
125
+ const derivedRoot = await deriveActionId(
126
+ "proj-123",
127
+ "trace-789",
128
+ "trace-789".substring(0, 16),
129
+ );
130
+ const derivedCausedBy = await deriveActionId(
131
+ "proj-123",
132
+ "trace-789",
133
+ "span-parent",
134
+ );
135
+
136
+ expect(attrs[ACTION_ROOT_ID_KEY]).toBe(derivedRoot);
137
+ expect(attrs[ACTION_CAUSED_BY_ID_KEY]).toBe(derivedCausedBy);
138
+ expect(attrs[ACTOR_TYPE_KEY]).toBe("agent");
139
+ expect(attrs[AGENT_RUN_ID_KEY]).toBe(derivedRoot);
140
+ expect(attrs[ACTION_KIND_KEY]).toBe(ActionKind.LlmCall);
141
+ });
142
+
143
+ it("should preserve explicitly passed Action Graph attributes", async () => {
144
+ const processFn = registerNormalizer();
145
+
146
+ const explicitSpan: StoredSpan = {
147
+ projectId: "proj-123",
148
+ spanId: "span-456",
149
+ parentSpanId: null,
150
+ traceId: "trace-789",
151
+ traceState: null,
152
+ serviceName: "my-service",
153
+ scopeName: null,
154
+ scopeVersion: null,
155
+ spanName: "chat",
156
+ spanKind: 1,
157
+ statusCode: 1,
158
+ statusMessage: null,
159
+ startTime: "2026-05-31T22:00:00.000Z",
160
+ endTime: "2026-05-31T22:00:02.000Z",
161
+ durationMs: 2000,
162
+ attributesJson: JSON.stringify({
163
+ "gen_ai.operation.name": "chat",
164
+ [ACTION_ID_KEY]: "01J3Y4Z5A6B7C8D9E0F1G2H3J4",
165
+ [ACTION_ROOT_ID_KEY]: "01J3Y4Z5A6B7C8D9E0F1G2H3J4",
166
+ [ACTION_CAUSED_BY_ID_KEY]: "01HZQ5W3K8M4P2X7N9B0CDEFGH",
167
+ [ACTOR_TYPE_KEY]: "system",
168
+ }),
169
+ droppedAttributesCount: 0,
170
+ resourceAttributesJson: "{}",
171
+ eventsJson: "[]",
172
+ droppedEventsCount: 0,
173
+ linksJson: "[]",
174
+ droppedLinksCount: 0,
175
+ receivedAt: "2026-05-31T22:00:02.000Z",
176
+ expiresAt: "2026-06-01T22:00:02.000Z",
177
+ sessionId: null,
178
+ interactionId: null,
179
+ };
180
+
181
+ const [processed] = await processFn([explicitSpan], context);
182
+ const attrs = parseJsonRecord(processed.attributesJson);
183
+
184
+ expect(attrs[ACTION_ID_KEY]).toBe("01J3Y4Z5A6B7C8D9E0F1G2H3J4");
185
+ expect(attrs[ACTION_ROOT_ID_KEY]).toBe("01J3Y4Z5A6B7C8D9E0F1G2H3J4");
186
+ expect(attrs[ACTION_CAUSED_BY_ID_KEY]).toBe("01HZQ5W3K8M4P2X7N9B0CDEFGH");
187
+ expect(attrs[ACTOR_TYPE_KEY]).toBe("system");
188
+ expect(attrs[ACTION_CONFIDENCE_KEY]).toBe(ActionConfidence.Explicit);
189
+ });
190
+
191
+ it("derives fallback action ids when explicit Action Graph attributes are malformed", async () => {
192
+ const processFn = registerNormalizer();
193
+
194
+ const invalidSpan: StoredSpan = {
195
+ projectId: "proj-123",
196
+ spanId: "span-456",
197
+ parentSpanId: "span-parent",
198
+ traceId: "trace-789",
199
+ traceState: null,
200
+ serviceName: "my-service",
201
+ scopeName: null,
202
+ scopeVersion: null,
203
+ spanName: "chat",
204
+ spanKind: 1,
205
+ statusCode: 1,
206
+ statusMessage: null,
207
+ startTime: "2026-05-31T22:00:00.000Z",
208
+ endTime: "2026-05-31T22:00:02.000Z",
209
+ durationMs: 2000,
210
+ attributesJson: JSON.stringify({
211
+ "gen_ai.operation.name": "chat",
212
+ [ACTION_ID_KEY]: "not-an-action-id",
213
+ [ACTION_ROOT_ID_KEY]: "also-invalid",
214
+ [ACTION_CAUSED_BY_ID_KEY]: "bad-parent",
215
+ }),
216
+ droppedAttributesCount: 0,
217
+ resourceAttributesJson: "{}",
218
+ eventsJson: "[]",
219
+ droppedEventsCount: 0,
220
+ linksJson: "[]",
221
+ droppedLinksCount: 0,
222
+ receivedAt: "2026-05-31T22:00:02.000Z",
223
+ expiresAt: "2026-06-01T22:00:02.000Z",
224
+ sessionId: null,
225
+ interactionId: null,
226
+ };
227
+
228
+ const [processed] = await processFn([invalidSpan], context);
229
+ const attrs = parseJsonRecord(processed.attributesJson);
230
+
231
+ expect(attrs[ACTION_ID_KEY]).toBe(
232
+ await deriveActionId("proj-123", "trace-789", "span-456"),
233
+ );
234
+ expect(attrs[ACTION_ROOT_ID_KEY]).toBe(
235
+ await deriveActionId(
236
+ "proj-123",
237
+ "trace-789",
238
+ "trace-789".substring(0, 16),
239
+ ),
240
+ );
241
+ expect(attrs[ACTION_CAUSED_BY_ID_KEY]).toBe(
242
+ await deriveActionId("proj-123", "trace-789", "span-parent"),
243
+ );
244
+ expect(attrs[ACTION_CONFIDENCE_KEY]).toBe(ActionConfidence.Fallback);
245
+ });
246
+ });
247
+
248
+ describe("genAiNormalizerPlugin - OTel MCP normalization", () => {
249
+ const context = {
250
+ env: {},
251
+ now: new Date(),
252
+ logger: console,
253
+ } as unknown as CollectorRouteContext;
254
+
255
+ const registerNormalizer = () => {
256
+ const processors: SpanProcessorPlugin[] = [];
257
+ const app = {};
258
+ const runtime = {
259
+ addSpanProcessor(p: SpanProcessorPlugin) {
260
+ processors.push(p);
261
+ },
262
+ };
263
+
264
+ genAiNormalizerPlugin.register(
265
+ app as Parameters<typeof genAiNormalizerPlugin.register>[0],
266
+ runtime as unknown as CollectorRuntime,
267
+ );
268
+ return processors[0].process;
269
+ };
270
+
271
+ it("should normalize mcp tools/call spans to TOOL kind and Action kind tool.call", async () => {
272
+ const processFn = registerNormalizer();
273
+
274
+ const mcpSpan: StoredSpan = {
275
+ projectId: "proj-123",
276
+ spanId: "span-456",
277
+ parentSpanId: null,
278
+ traceId: "trace-789",
279
+ traceState: null,
280
+ serviceName: "mcp-service",
281
+ scopeName: null,
282
+ scopeVersion: null,
283
+ spanName: "update_invoice_status",
284
+ spanKind: 1,
285
+ statusCode: 1,
286
+ statusMessage: null,
287
+ startTime: "2026-05-31T22:00:00.000Z",
288
+ endTime: "2026-05-31T22:00:02.000Z",
289
+ durationMs: 2000,
290
+ attributesJson: JSON.stringify({
291
+ "mcp.method.name": "tools/call",
292
+ "mcp.tool.name": "update_invoice_status",
293
+ "mcp.tool.arguments": { invoice_id: "INV-2026", status: "paid" },
294
+ }),
295
+ droppedAttributesCount: 0,
296
+ resourceAttributesJson: "{}",
297
+ eventsJson: "[]",
298
+ droppedEventsCount: 0,
299
+ linksJson: "[]",
300
+ droppedLinksCount: 0,
301
+ receivedAt: "2026-05-31T22:00:02.000Z",
302
+ expiresAt: "2026-06-01T22:00:02.000Z",
303
+ sessionId: null,
304
+ interactionId: null,
305
+ };
306
+
307
+ const [processed] = await processFn([mcpSpan], context);
308
+ const attrs = parseJsonRecord(processed.attributesJson);
309
+
310
+ expect(attrs[OPENINFERENCE_SPAN_KIND_KEY]).toBe(OpenInferenceSpanKind.TOOL);
311
+ expect(attrs[ACTION_KIND_KEY]).toBe(ActionKind.ToolCall);
312
+ expect(attrs[TOOL_CALL_ID_KEY]).toBe(attrs[ACTION_ID_KEY]);
313
+ expect(attrs[TOOL_NAME_KEY]).toBe("update_invoice_status");
314
+ expect(attrs["obs.tool_call.tool_name"]).toBe("update_invoice_status");
315
+ expect(attrs[TOOL_ARGS_KEY]).toBe(
316
+ JSON.stringify({ invoice_id: "INV-2026", status: "paid" }),
317
+ );
318
+ expect(attrs["obs.tool_call.args"]).toBe(
319
+ JSON.stringify({ invoice_id: "INV-2026", status: "paid" }),
320
+ );
321
+ expect(attrs[TOOL_SIDE_EFFECT_KEY]).toBe(1);
322
+ });
323
+
324
+ it("infers MCP side effects only from leading mutation verbs", async () => {
325
+ const processFn = registerNormalizer();
326
+
327
+ const baseSpan = {
328
+ projectId: "proj-123",
329
+ parentSpanId: null,
330
+ traceId: "trace-789",
331
+ traceState: null,
332
+ serviceName: "mcp-service",
333
+ scopeName: null,
334
+ scopeVersion: null,
335
+ spanKind: 1,
336
+ statusCode: 1,
337
+ statusMessage: null,
338
+ startTime: "2026-05-31T22:00:00.000Z",
339
+ endTime: "2026-05-31T22:00:02.000Z",
340
+ durationMs: 2000,
341
+ droppedAttributesCount: 0,
342
+ resourceAttributesJson: "{}",
343
+ eventsJson: "[]",
344
+ droppedEventsCount: 0,
345
+ linksJson: "[]",
346
+ droppedLinksCount: 0,
347
+ receivedAt: "2026-05-31T22:00:02.000Z",
348
+ expiresAt: "2026-06-01T22:00:02.000Z",
349
+ sessionId: null,
350
+ interactionId: null,
351
+ } satisfies Partial<StoredSpan>;
352
+
353
+ const spans: StoredSpan[] = [
354
+ {
355
+ ...baseSpan,
356
+ spanId: "span-write",
357
+ spanName: "write_file",
358
+ attributesJson: JSON.stringify({
359
+ "mcp.method.name": "tools/call",
360
+ "mcp.tool.name": "write_file",
361
+ }),
362
+ },
363
+ {
364
+ ...baseSpan,
365
+ spanId: "span-list",
366
+ spanName: "list_updates",
367
+ attributesJson: JSON.stringify({
368
+ "mcp.method.name": "tools/call",
369
+ "mcp.tool.name": "list_updates",
370
+ }),
371
+ },
372
+ {
373
+ ...baseSpan,
374
+ spanId: "span-status",
375
+ spanName: "get_write_status",
376
+ attributesJson: JSON.stringify({
377
+ "mcp.method.name": "tools/call",
378
+ "mcp.tool.name": "get_write_status",
379
+ }),
380
+ },
381
+ {
382
+ ...baseSpan,
383
+ spanId: "span-explicit",
384
+ spanName: "read_with_explicit_side_effect",
385
+ attributesJson: JSON.stringify({
386
+ "mcp.method.name": "tools/call",
387
+ "mcp.tool.name": "read_status",
388
+ "mcp.tool.side_effect": 1,
389
+ }),
390
+ },
391
+ ] as StoredSpan[];
392
+
393
+ const processed = await processFn(spans, context);
394
+ const attrs = processed.map((span) => parseJsonRecord(span.attributesJson));
395
+
396
+ expect(attrs[0][TOOL_SIDE_EFFECT_KEY]).toBe(1);
397
+ expect(attrs[1][TOOL_SIDE_EFFECT_KEY]).toBe(0);
398
+ expect(attrs[2][TOOL_SIDE_EFFECT_KEY]).toBe(0);
399
+ expect(attrs[3][TOOL_SIDE_EFFECT_KEY]).toBe(1);
400
+ });
401
+
402
+ it("should normalize mcp resources/read and prompts/get spans to RETRIEVER and PROMPT kinds", async () => {
403
+ const processFn = registerNormalizer();
404
+
405
+ const resourceSpan: StoredSpan = {
406
+ projectId: "proj-123",
407
+ spanId: "span-456",
408
+ parentSpanId: null,
409
+ traceId: "trace-789",
410
+ traceState: null,
411
+ serviceName: "mcp-service",
412
+ scopeName: null,
413
+ scopeVersion: null,
414
+ spanName: "read_logs",
415
+ spanKind: 1,
416
+ statusCode: 1,
417
+ statusMessage: null,
418
+ startTime: "2026-05-31T22:00:00.000Z",
419
+ endTime: "2026-05-31T22:00:02.000Z",
420
+ durationMs: 2000,
421
+ attributesJson: JSON.stringify({
422
+ "mcp.method.name": "resources/read",
423
+ }),
424
+ droppedAttributesCount: 0,
425
+ resourceAttributesJson: "{}",
426
+ eventsJson: "[]",
427
+ droppedEventsCount: 0,
428
+ linksJson: "[]",
429
+ droppedLinksCount: 0,
430
+ receivedAt: "2026-05-31T22:00:02.000Z",
431
+ expiresAt: "2026-06-01T22:00:02.000Z",
432
+ sessionId: null,
433
+ interactionId: null,
434
+ };
435
+
436
+ const promptSpan: StoredSpan = {
437
+ projectId: "proj-123",
438
+ spanId: "span-789",
439
+ parentSpanId: null,
440
+ traceId: "trace-789",
441
+ traceState: null,
442
+ serviceName: "mcp-service",
443
+ scopeName: null,
444
+ scopeVersion: null,
445
+ spanName: "summarize_doc",
446
+ spanKind: 1,
447
+ statusCode: 1,
448
+ statusMessage: null,
449
+ startTime: "2026-05-31T22:00:00.000Z",
450
+ endTime: "2026-05-31T22:00:02.000Z",
451
+ durationMs: 2000,
452
+ attributesJson: JSON.stringify({
453
+ "mcp.method.name": "prompts/get",
454
+ }),
455
+ droppedAttributesCount: 0,
456
+ resourceAttributesJson: "{}",
457
+ eventsJson: "[]",
458
+ droppedEventsCount: 0,
459
+ linksJson: "[]",
460
+ droppedLinksCount: 0,
461
+ receivedAt: "2026-05-31T22:00:02.000Z",
462
+ expiresAt: "2026-06-01T22:00:02.000Z",
463
+ sessionId: null,
464
+ interactionId: null,
465
+ };
466
+
467
+ const [processedRes, processedPrompt] = await processFn(
468
+ [resourceSpan, promptSpan],
469
+ context,
470
+ );
471
+
472
+ const attrsRes = parseJsonRecord(processedRes.attributesJson);
473
+ expect(attrsRes[OPENINFERENCE_SPAN_KIND_KEY]).toBe(
474
+ OpenInferenceSpanKind.RETRIEVER,
475
+ );
476
+ expect(attrsRes[ACTION_KIND_KEY]).toBe(ActionKind.Retrieval);
477
+
478
+ const attrsPrompt = parseJsonRecord(processedPrompt.attributesJson);
479
+ expect(attrsPrompt[OPENINFERENCE_SPAN_KIND_KEY]).toBe(
480
+ OpenInferenceSpanKind.PROMPT,
481
+ );
482
+ expect(attrsPrompt[ACTION_KIND_KEY]).toBe(ActionKind.AgentStep);
483
+ });
484
+ });
485
+
486
+ describe("genAiNormalizerPlugin - OpenInference span kinds and Phase 0 fixtures", () => {
487
+ const context = {
488
+ env: {},
489
+ now: new Date(),
490
+ logger: console,
491
+ } as unknown as CollectorRouteContext;
492
+
493
+ const registerNormalizer = () => {
494
+ const processors: SpanProcessorPlugin[] = [];
495
+ const app = {};
496
+ const runtime = {
497
+ addSpanProcessor(p: SpanProcessorPlugin) {
498
+ processors.push(p);
499
+ },
500
+ };
501
+
502
+ genAiNormalizerPlugin.register(
503
+ app as Parameters<typeof genAiNormalizerPlugin.register>[0],
504
+ runtime as unknown as CollectorRuntime,
505
+ );
506
+ return processors[0].process;
507
+ };
508
+
509
+ it("should map EMBEDDING, RERANKER, GUARDRAIL, EVALUATOR, AGENT, CHAIN to canonical action kinds", async () => {
510
+ const processFn = registerNormalizer();
511
+
512
+ const testSpans = [
513
+ "EMBEDDING",
514
+ "RERANKER",
515
+ "GUARDRAIL",
516
+ "EVALUATOR",
517
+ "AGENT",
518
+ "CHAIN",
519
+ ].map((oiKind, idx) => ({
520
+ projectId: "proj-123",
521
+ spanId: `span-oi-${idx}`,
522
+ parentSpanId: null,
523
+ traceId: "trace-oi",
524
+ traceState: null,
525
+ serviceName: "oi-service",
526
+ scopeName: null,
527
+ scopeVersion: null,
528
+ spanName: `test-${oiKind.toLowerCase()}`,
529
+ spanKind: 1,
530
+ statusCode: 1,
531
+ statusMessage: null,
532
+ startTime: "2026-05-31T22:00:00.000Z",
533
+ endTime: "2026-05-31T22:00:02.000Z",
534
+ durationMs: 2000,
535
+ attributesJson: JSON.stringify({
536
+ [OPENINFERENCE_SPAN_KIND_KEY]: oiKind,
537
+ }),
538
+ droppedAttributesCount: 0,
539
+ resourceAttributesJson: "{}",
540
+ eventsJson: "[]",
541
+ droppedEventsCount: 0,
542
+ linksJson: "[]",
543
+ droppedLinksCount: 0,
544
+ receivedAt: "2026-05-31T22:00:02.000Z",
545
+ expiresAt: "2026-06-01T22:00:02.000Z",
546
+ sessionId: null,
547
+ interactionId: null,
548
+ }));
549
+
550
+ const processed = await processFn(testSpans, context);
551
+ const kinds = processed.map(
552
+ (p) => parseJsonRecord(p.attributesJson)[ACTION_KIND_KEY],
553
+ );
554
+
555
+ expect(kinds[0]).toBe(ActionKind.AgentStep); // EMBEDDING
556
+ expect(kinds[1]).toBe(ActionKind.AgentStep); // RERANKER
557
+ expect(kinds[2]).toBe(ActionKind.AgentStep); // GUARDRAIL
558
+ expect(kinds[3]).toBe(ActionKind.Eval); // EVALUATOR
559
+ expect(kinds[4]).toBe(ActionKind.AgentStep); // AGENT
560
+ expect(kinds[5]).toBe(ActionKind.AgentStep); // CHAIN
561
+ });
562
+
563
+ it("verifies and loads Phase 0 action fixtures as conformance baseline", async () => {
564
+ const fs = await import("node:fs");
565
+ const path = await import("node:path");
566
+
567
+ let fixturesDir = path.resolve(process.cwd(), "tests/fixtures/actions");
568
+ if (!fs.existsSync(fixturesDir)) {
569
+ fixturesDir = path.resolve(process.cwd(), "../../tests/fixtures/actions");
570
+ }
571
+ const files = [
572
+ "browser-only-flow.json",
573
+ "click-triggered-agent-run.json",
574
+ "cron-triggered-agent-run.json",
575
+ "wrong-invoice-update.json",
576
+ ];
577
+
578
+ for (const file of files) {
579
+ const filePath = path.join(fixturesDir, file);
580
+ expect(fs.existsSync(filePath)).toBe(true);
581
+
582
+ const raw = fs.readFileSync(filePath, "utf8");
583
+ const json = JSON.parse(raw);
584
+
585
+ // Assert minimum required Action Graph envelope keys
586
+ expect(json.actions).toBeDefined();
587
+ expect(Array.isArray(json.actions)).toBe(true);
588
+ expect(json.agent_runs).toBeDefined();
589
+ expect(json.tool_calls).toBeDefined();
590
+
591
+ if (json.actions.length > 0) {
592
+ const firstAction = json.actions[0];
593
+ expect(firstAction.id).toBeDefined();
594
+ expect(firstAction.project_id).toBeDefined();
595
+ expect(firstAction.root_action_id).toBeDefined();
596
+ }
597
+
598
+ const fixtureSpans: StoredSpan[] = json.actions.map(
599
+ (action: Record<string, unknown>) => {
600
+ const attrs =
601
+ typeof action.attrs_json === "string"
602
+ ? parseJsonRecord(action.attrs_json)
603
+ : {};
604
+ const actionKind = String(action.action_kind ?? "agent.step");
605
+ const openInferenceKind = actionKind.includes("tool")
606
+ ? OpenInferenceSpanKind.TOOL
607
+ : actionKind.includes("llm")
608
+ ? OpenInferenceSpanKind.LLM
609
+ : actionKind.includes("retriev")
610
+ ? OpenInferenceSpanKind.RETRIEVER
611
+ : actionKind.includes("eval") ||
612
+ actionKind.includes("guardrail")
613
+ ? OpenInferenceSpanKind.EVALUATOR
614
+ : OpenInferenceSpanKind.CHAIN;
615
+ return {
616
+ projectId: String(action.project_id),
617
+ spanId: String(action.span_id),
618
+ parentSpanId:
619
+ typeof action.caused_by_action_id === "string"
620
+ ? String(action.caused_by_action_id).slice(0, 16)
621
+ : null,
622
+ traceId: String(action.trace_id),
623
+ traceState: null,
624
+ serviceName: "fixture",
625
+ scopeName: null,
626
+ scopeVersion: null,
627
+ spanName: String(action.name ?? action.id),
628
+ spanKind: 1,
629
+ statusCode: action.status === "error" ? 2 : 1,
630
+ statusMessage: null,
631
+ startTime: String(action.started_at),
632
+ endTime:
633
+ typeof action.ended_at === "string"
634
+ ? String(action.ended_at)
635
+ : String(action.started_at),
636
+ durationMs: Number(action.duration_ms ?? 0),
637
+ attributesJson: JSON.stringify({
638
+ ...attrs,
639
+ [OPENINFERENCE_SPAN_KIND_KEY]: openInferenceKind,
640
+ [ACTION_ID_KEY]: action.id,
641
+ [ACTION_ROOT_ID_KEY]: action.root_action_id,
642
+ [ACTION_CAUSED_BY_ID_KEY]: action.caused_by_action_id,
643
+ [ACTION_KIND_KEY]: action.action_kind,
644
+ [AGENT_RUN_ID_KEY]: action.agent_run_id,
645
+ }),
646
+ droppedAttributesCount: 0,
647
+ resourceAttributesJson: "{}",
648
+ eventsJson: "[]",
649
+ droppedEventsCount: 0,
650
+ linksJson: "[]",
651
+ droppedLinksCount: 0,
652
+ receivedAt: String(action.ended_at ?? action.started_at),
653
+ expiresAt: "2026-06-01T00:00:00.000Z",
654
+ sessionId:
655
+ typeof action.session_id === "string"
656
+ ? String(action.session_id)
657
+ : null,
658
+ interactionId:
659
+ typeof action.interaction_id === "string"
660
+ ? String(action.interaction_id)
661
+ : null,
662
+ };
663
+ },
664
+ );
665
+
666
+ const processed = await registerNormalizer()(fixtureSpans, context);
667
+ for (let i = 0; i < processed.length; i++) {
668
+ const attrs = parseJsonRecord(processed[i].attributesJson);
669
+ const action = json.actions[i];
670
+ expect(attrs[ACTION_ID_KEY]).toBe(action.id);
671
+ expect(attrs[ACTION_ROOT_ID_KEY]).toBe(action.root_action_id);
672
+ expect(attrs[ACTION_KIND_KEY]).toBe(action.action_kind);
673
+ expect(attrs[ACTION_CONFIDENCE_KEY]).toBe(ActionConfidence.Explicit);
674
+ if (action.agent_run_id) {
675
+ expect(attrs[AGENT_RUN_ID_KEY]).toBe(action.agent_run_id);
676
+ }
677
+ }
678
+ }
679
+ });
680
+ });