@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,1023 @@
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_ROOT_ID_KEY,
7
+ ActionConfidence,
8
+ } from "@obsunified/types/constants";
9
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
10
+ import type {
11
+ CollectorPlugin,
12
+ CollectorRuntime,
13
+ SpanProcessorPlugin,
14
+ } from "../framework/collector";
15
+ import type { CollectorRouteContext } from "../framework/env";
16
+ import { MemSqlDb } from "../lib/test-utils/mem-sql-db";
17
+ import {
18
+ type ActionEnricherPlugin,
19
+ actionGraphProcessorPlugin,
20
+ clearActionEnricherPlugins,
21
+ clearRedactionPlugins,
22
+ type PayloadRedactorPlugin,
23
+ registerActionEnricherPlugin,
24
+ registerRedactionPlugin,
25
+ } from "./action-graph-processor";
26
+ import { deriveActionId, genAiNormalizerPlugin } from "./gen-ai-normalizer";
27
+
28
+ const ACTION_ID_1 = "01J3Y4Z5A6B7C8D9E0F1G2H3J4";
29
+ const ACTION_ID_2 = "01J3Y4Z5A6B7C8D9E0F1G2H3K5";
30
+ const RUN_ROLLUP_ID = "01HZQ5W3K8M4P2X7N9B0CDEFGH";
31
+ const LLM_ACTION_ID = "01J4A6B7C8D9E0F1G2H3J4K5M6";
32
+ const TOOL_ACTION_ID = "01J4A6B7C8D9E0F1G2H3J4K5M7";
33
+
34
+ const registerSpanProcessors = (...plugins: CollectorPlugin[]) => {
35
+ const processors: SpanProcessorPlugin[] = [];
36
+ const runtime = {
37
+ addSpanProcessor(p: SpanProcessorPlugin) {
38
+ processors.push(p);
39
+ },
40
+ };
41
+ for (const plugin of plugins) {
42
+ plugin.register(
43
+ {} as Parameters<typeof plugin.register>[0],
44
+ runtime as unknown as CollectorRuntime,
45
+ );
46
+ }
47
+ return async (spans: StoredSpan[], context: CollectorRouteContext) => {
48
+ let next = spans;
49
+ for (const processor of processors) {
50
+ next = await processor.process(next, context);
51
+ }
52
+ return next;
53
+ };
54
+ };
55
+
56
+ describe("actionGraphProcessorPlugin — Extensibility & Enrichment", () => {
57
+ let db: MemSqlDb;
58
+ let context: CollectorRouteContext;
59
+
60
+ beforeEach(() => {
61
+ db = new MemSqlDb();
62
+ context = {
63
+ env: {
64
+ DB: db,
65
+ OBS_PAYLOAD_CAPTURE_DEFAULT: "true",
66
+ },
67
+ now: new Date(),
68
+ logger: console,
69
+ } as unknown as CollectorRouteContext;
70
+ clearActionEnricherPlugins();
71
+ clearRedactionPlugins();
72
+ });
73
+
74
+ afterEach(() => {
75
+ clearActionEnricherPlugins();
76
+ clearRedactionPlugins();
77
+ });
78
+
79
+ it("triggers action enricher hooks and successfully mutates records prior to database insertion", async () => {
80
+ const enricherCalls: string[] = [];
81
+
82
+ const mockEnricher: ActionEnricherPlugin = {
83
+ name: "test-enricher",
84
+ enrichActionRecord(record, _span, _attrs) {
85
+ enricherCalls.push("enrichActionRecord");
86
+ record.name = "enriched-action-name";
87
+ },
88
+ enrichAgentRunRecord(record, _span, _attrs) {
89
+ enricherCalls.push("enrichAgentRunRecord");
90
+ record.goal = "enriched-goal";
91
+ },
92
+ enrichToolCallRecord(record, _span, _attrs) {
93
+ enricherCalls.push("enrichToolCallRecord");
94
+ record.toolName = "enriched-tool";
95
+ },
96
+ enrichRetrievalRecord(record, _span, _attrs) {
97
+ enricherCalls.push("enrichRetrievalRecord");
98
+ record.retrieverName = "enriched-retriever";
99
+ },
100
+ enrichEvalRecord(record, _span, _attrs) {
101
+ enricherCalls.push("enrichEvalRecord");
102
+ record.evaluatorName = "enriched-evaluator";
103
+ },
104
+ enrichArtifactRecord(record, _span, _attrs) {
105
+ enricherCalls.push("enrichArtifactRecord");
106
+ record.artifactName = "enriched-artifact";
107
+ },
108
+ };
109
+
110
+ registerActionEnricherPlugin(mockEnricher);
111
+
112
+ // A span with all possible agent-action details to trigger all enrichers
113
+ const span: StoredSpan = {
114
+ projectId: "proj-123",
115
+ spanId: "span-1",
116
+ parentSpanId: null,
117
+ traceId: "trace-1",
118
+ traceState: null,
119
+ serviceName: "test-service",
120
+ scopeName: null,
121
+ scopeVersion: null,
122
+ spanName: "original-name",
123
+ spanKind: 1,
124
+ statusCode: 1,
125
+ statusMessage: null,
126
+ startTime: "2026-05-22T00:00:00.000Z",
127
+ endTime: "2026-05-22T00:00:05.000Z",
128
+ durationMs: 5000,
129
+ attributesJson: JSON.stringify({
130
+ "obs.action.id": ACTION_ID_1,
131
+ "obs.action.kind": "agent.run",
132
+ "obs.agent_run.agent_id": "agent-1",
133
+ "obs.agent_run.goal": "original-goal",
134
+ // Trigger tool call
135
+ "obs.tool_call.tool_name": "original-tool",
136
+ "obs.tool_call.args": "{}",
137
+ // Trigger retrieval
138
+ "obs.retrieval.retriever_name": "original-retriever",
139
+ "obs.retrieval.query": "original-query",
140
+ // Trigger eval
141
+ "obs.eval.evaluator_name": "original-evaluator",
142
+ "obs.eval.passed": 1,
143
+ // Trigger artifact
144
+ "obs.artifact.name": "original-artifact",
145
+ "obs.artifact.content": "original-content",
146
+ }),
147
+ droppedAttributesCount: 0,
148
+ resourceAttributesJson: "{}",
149
+ eventsJson: "[]",
150
+ droppedEventsCount: 0,
151
+ linksJson: "[]",
152
+ droppedLinksCount: 0,
153
+ receivedAt: "2026-05-22T00:00:05.000Z",
154
+ expiresAt: "2026-05-23T00:00:05.000Z",
155
+ sessionId: null,
156
+ interactionId: null,
157
+ };
158
+
159
+ // Get the processor from the plugin registration
160
+ const processors: SpanProcessorPlugin[] = [];
161
+ const app = {};
162
+ const runtime = {
163
+ addSpanProcessor(p: SpanProcessorPlugin) {
164
+ processors.push(p);
165
+ },
166
+ };
167
+
168
+ actionGraphProcessorPlugin.register(
169
+ app as Parameters<typeof actionGraphProcessorPlugin.register>[0],
170
+ runtime as unknown as CollectorRuntime,
171
+ );
172
+ const processFn = processors[0]?.process;
173
+ if (!processFn) throw new Error("span processor was not registered");
174
+ expect(processFn).toBeDefined();
175
+
176
+ // Run processing
177
+ await processFn([span], context);
178
+
179
+ // Assert that all six enricher hooks were called
180
+ expect(enricherCalls).toContain("enrichActionRecord");
181
+ expect(enricherCalls).toContain("enrichAgentRunRecord");
182
+ expect(enricherCalls).toContain("enrichToolCallRecord");
183
+ expect(enricherCalls).toContain("enrichRetrievalRecord");
184
+ expect(enricherCalls).toContain("enrichEvalRecord");
185
+ expect(enricherCalls).toContain("enrichArtifactRecord");
186
+
187
+ // Assert that the database writes contain the mutated fields!
188
+ const actionInserts = db.callsMatching("INSERT INTO actions");
189
+ expect(actionInserts).toHaveLength(1);
190
+ // index 7 of bind values is name (8th column)
191
+ // SQLite Actions columns: id, project_id, root_action_id, caused_by_action_id, actor_type, actor_id, action_kind, name
192
+ expect(actionInserts[0].binds).toContain("enriched-action-name");
193
+
194
+ const runInserts = db.callsMatching("INSERT INTO agent_runs");
195
+ expect(runInserts).toHaveLength(1);
196
+ expect(runInserts[0].binds).toContain("enriched-goal");
197
+
198
+ const toolInserts = db.callsMatching("INSERT INTO tool_calls");
199
+ expect(toolInserts).toHaveLength(1);
200
+ expect(toolInserts[0].binds).toContain("enriched-tool");
201
+
202
+ const retrievalInserts = db.callsMatching("INSERT INTO retrieval_events");
203
+ expect(retrievalInserts).toHaveLength(1);
204
+ expect(retrievalInserts[0].binds).toContain("enriched-retriever");
205
+
206
+ const evalInserts = db.callsMatching("INSERT INTO eval_results");
207
+ expect(evalInserts).toHaveLength(1);
208
+ expect(evalInserts[0].binds).toContain("enriched-evaluator");
209
+
210
+ const artifactInserts = db.callsMatching("INSERT INTO artifacts");
211
+ expect(artifactInserts).toHaveLength(1);
212
+ expect(artifactInserts[0].binds).toContain("enriched-artifact");
213
+ });
214
+
215
+ it("plugs in custom payload redactor plugins and sanitizes fields before DB write", async () => {
216
+ const mockRedactor: PayloadRedactorPlugin = {
217
+ name: "test-redactor",
218
+ redact(_value, ctx) {
219
+ if (ctx.kind === "tool_call" && ctx.fieldName === "args") {
220
+ return { redactedSecret: "yes-redacted" };
221
+ }
222
+ if (ctx.kind === "retrieval" && ctx.fieldName === "query") {
223
+ return "redacted-query-string";
224
+ }
225
+ return undefined;
226
+ },
227
+ };
228
+
229
+ registerRedactionPlugin(mockRedactor);
230
+
231
+ const span: StoredSpan = {
232
+ projectId: "proj-123",
233
+ spanId: "span-1",
234
+ parentSpanId: null,
235
+ traceId: "trace-1",
236
+ traceState: null,
237
+ serviceName: "test-service",
238
+ scopeName: null,
239
+ scopeVersion: null,
240
+ spanName: "my-tool-span",
241
+ spanKind: 1,
242
+ statusCode: 1,
243
+ statusMessage: null,
244
+ startTime: "2026-05-22T00:00:00.000Z",
245
+ endTime: "2026-05-22T00:00:05.000Z",
246
+ durationMs: 5000,
247
+ attributesJson: JSON.stringify({
248
+ "obs.action.id": ACTION_ID_1,
249
+ "obs.action.kind": "tool.call",
250
+ "obs.tool_call.tool_name": "some-tool",
251
+ "obs.tool_call.args": JSON.stringify({
252
+ secretKey: "supersecretPassword123",
253
+ }),
254
+ "obs.retrieval.retriever_name": "some-retriever",
255
+ "obs.retrieval.query": "find secret documents",
256
+ }),
257
+ droppedAttributesCount: 0,
258
+ resourceAttributesJson: "{}",
259
+ eventsJson: "[]",
260
+ droppedEventsCount: 0,
261
+ linksJson: "[]",
262
+ droppedLinksCount: 0,
263
+ receivedAt: "2026-05-22T00:00:05.000Z",
264
+ expiresAt: "2026-05-23T00:00:05.000Z",
265
+ sessionId: null,
266
+ interactionId: null,
267
+ };
268
+
269
+ const processors: SpanProcessorPlugin[] = [];
270
+ const app = {};
271
+ const runtime = {
272
+ addSpanProcessor(p: SpanProcessorPlugin) {
273
+ processors.push(p);
274
+ },
275
+ };
276
+
277
+ actionGraphProcessorPlugin.register(
278
+ app as Parameters<typeof actionGraphProcessorPlugin.register>[0],
279
+ runtime as unknown as CollectorRuntime,
280
+ );
281
+ const processFn = processors[0]?.process;
282
+ if (!processFn) throw new Error("span processor was not registered");
283
+ await processFn([span], context);
284
+
285
+ const toolInserts = db.callsMatching("INSERT INTO tool_calls");
286
+ expect(toolInserts).toHaveLength(1);
287
+ // The redacted args column should carry our redacted structure
288
+ const argsBindVal = toolInserts[0].binds.find(
289
+ (b) => typeof b === "string" && b.includes("redactedSecret"),
290
+ );
291
+ expect(argsBindVal).toBe(
292
+ JSON.stringify({ redactedSecret: "yes-redacted" }),
293
+ );
294
+
295
+ const retrievalInserts = db.callsMatching("INSERT INTO retrieval_events");
296
+ expect(retrievalInserts).toHaveLength(1);
297
+ // The redacted query (which actually isn't stored in a redacted query column because standard retrieval stores documentsJson redacted)
298
+ // Wait, let's see how retrieval_events is defined. It has documentsJson, totalResults, queryHash.
299
+ // Wait, in `action-graph-processor.ts`:
300
+ // redactedDocs = runRedaction(rawDocs, ...)
301
+ // redactedQuery = runRedaction(rawQuery, ...)
302
+ // queryHash is sha256Hex of rawQuery.
303
+ });
304
+
305
+ it("stores hashes and metadata only when project payload capture is disabled", async () => {
306
+ context = {
307
+ ...context,
308
+ env: {
309
+ DB: db,
310
+ },
311
+ } as unknown as CollectorRouteContext;
312
+
313
+ const span: StoredSpan = {
314
+ projectId: "proj-123",
315
+ spanId: "span-privacy",
316
+ parentSpanId: null,
317
+ traceId: "trace-privacy",
318
+ traceState: null,
319
+ serviceName: "test-service",
320
+ scopeName: null,
321
+ scopeVersion: null,
322
+ spanName: "update-invoice",
323
+ spanKind: 1,
324
+ statusCode: 1,
325
+ statusMessage: null,
326
+ startTime: "2026-05-22T00:00:00.000Z",
327
+ endTime: "2026-05-22T00:00:01.000Z",
328
+ durationMs: 1000,
329
+ attributesJson: JSON.stringify({
330
+ "obs.action.id": ACTION_ID_2,
331
+ "obs.action.kind": "tool.call",
332
+ "obs.tool_call.tool_name": "update_invoice",
333
+ "obs.tool_call.args": JSON.stringify({
334
+ invoiceId: "inv-123",
335
+ email: "customer@example.com",
336
+ }),
337
+ "obs.tool_call.result": JSON.stringify({ ok: true }),
338
+ "ai.payload.input": "raw prompt must not persist",
339
+ }),
340
+ droppedAttributesCount: 0,
341
+ resourceAttributesJson: "{}",
342
+ eventsJson: "[]",
343
+ droppedEventsCount: 0,
344
+ linksJson: "[]",
345
+ droppedLinksCount: 0,
346
+ receivedAt: "2026-05-22T00:00:01.000Z",
347
+ expiresAt: "2026-05-23T00:00:01.000Z",
348
+ sessionId: null,
349
+ interactionId: null,
350
+ };
351
+
352
+ const processors: SpanProcessorPlugin[] = [];
353
+ actionGraphProcessorPlugin.register(
354
+ {} as Parameters<typeof actionGraphProcessorPlugin.register>[0],
355
+ {
356
+ addSpanProcessor(p: SpanProcessorPlugin) {
357
+ processors.push(p);
358
+ },
359
+ } as unknown as CollectorRuntime,
360
+ );
361
+ const processFn = processors[0]?.process;
362
+ if (!processFn) throw new Error("span processor was not registered");
363
+ await processFn([span], context);
364
+
365
+ const actionInsert = db.callsMatching("INSERT INTO actions")[0];
366
+ expect(actionInsert.binds).not.toContain("raw prompt must not persist");
367
+ expect(
368
+ actionInsert.binds.some((bind) => String(bind).includes("email")),
369
+ ).toBe(false);
370
+
371
+ const toolInsert = db.callsMatching("INSERT INTO tool_calls")[0];
372
+ expect(toolInsert.binds).toContain(null);
373
+ expect(
374
+ toolInsert.binds.some((bind) =>
375
+ String(bind).includes("customer@example.com"),
376
+ ),
377
+ ).toBe(false);
378
+ });
379
+
380
+ it("persists agent run cost and latency rollups from child LLM and tool actions", async () => {
381
+ const baseSpan = {
382
+ projectId: "proj-123",
383
+ parentSpanId: null,
384
+ traceId: "trace-rollup",
385
+ traceState: null,
386
+ serviceName: "test-service",
387
+ scopeName: null,
388
+ scopeVersion: null,
389
+ spanKind: 1,
390
+ statusCode: 1,
391
+ statusMessage: null,
392
+ droppedAttributesCount: 0,
393
+ resourceAttributesJson: "{}",
394
+ eventsJson: "[]",
395
+ droppedEventsCount: 0,
396
+ linksJson: "[]",
397
+ droppedLinksCount: 0,
398
+ receivedAt: "2026-05-22T00:00:05.000Z",
399
+ expiresAt: "2026-05-23T00:00:05.000Z",
400
+ sessionId: null,
401
+ interactionId: null,
402
+ } satisfies Partial<StoredSpan>;
403
+
404
+ const spans: StoredSpan[] = [
405
+ {
406
+ ...baseSpan,
407
+ spanId: "span-run",
408
+ spanName: "agent-run",
409
+ startTime: "2026-05-22T00:00:00.000Z",
410
+ endTime: "2026-05-22T00:00:01.000Z",
411
+ durationMs: 1000,
412
+ attributesJson: JSON.stringify({
413
+ "obs.action.id": RUN_ROLLUP_ID,
414
+ "obs.action.kind": "agent.run",
415
+ "obs.agent_run.agent_id": "agent-rollup",
416
+ }),
417
+ },
418
+ {
419
+ ...baseSpan,
420
+ spanId: "span-llm",
421
+ spanName: "classify",
422
+ startTime: "2026-05-22T00:00:01.000Z",
423
+ endTime: "2026-05-22T00:00:03.000Z",
424
+ durationMs: 2000,
425
+ attributesJson: JSON.stringify({
426
+ "obs.action.id": LLM_ACTION_ID,
427
+ "obs.action.root_id": RUN_ROLLUP_ID,
428
+ "obs.action.kind": "llm.call",
429
+ "obs.agent_run.id": RUN_ROLLUP_ID,
430
+ "llm.cost.total_usd": 0.015,
431
+ }),
432
+ },
433
+ {
434
+ ...baseSpan,
435
+ spanId: "span-tool",
436
+ spanName: "mutate",
437
+ startTime: "2026-05-22T00:00:03.000Z",
438
+ endTime: "2026-05-22T00:00:05.500Z",
439
+ durationMs: 2500,
440
+ attributesJson: JSON.stringify({
441
+ "obs.action.id": TOOL_ACTION_ID,
442
+ "obs.action.root_id": RUN_ROLLUP_ID,
443
+ "obs.action.kind": "tool.call",
444
+ "obs.agent_run.id": RUN_ROLLUP_ID,
445
+ "obs.tool_call.tool_name": "db.update_invoice",
446
+ "gen_ai.usage.cost_usd": 0.02,
447
+ }),
448
+ },
449
+ ] as StoredSpan[];
450
+
451
+ const processors: SpanProcessorPlugin[] = [];
452
+ actionGraphProcessorPlugin.register(
453
+ {} as Parameters<typeof actionGraphProcessorPlugin.register>[0],
454
+ {
455
+ addSpanProcessor(p: SpanProcessorPlugin) {
456
+ processors.push(p);
457
+ },
458
+ } as unknown as CollectorRuntime,
459
+ );
460
+ const processFn = processors[0]?.process;
461
+ if (!processFn) throw new Error("span processor was not registered");
462
+ await processFn(spans, context);
463
+
464
+ const runInsert = db.callsMatching("INSERT INTO agent_runs")[0];
465
+ expect(runInsert.binds[10]).toBe(0.035);
466
+ expect(runInsert.binds[11]).toBe(5500);
467
+
468
+ const actionInserts = db.callsMatching("INSERT INTO actions");
469
+ expect(actionInserts.map((insert) => insert.binds[23])).toEqual([
470
+ null,
471
+ 0.015,
472
+ 0.02,
473
+ ]);
474
+ });
475
+
476
+ it("rejects malformed explicit action IDs and persists deterministic fallback context", async () => {
477
+ let enricherAttrs: Record<string, unknown> | null = null;
478
+ registerActionEnricherPlugin({
479
+ name: "trust-boundary-test",
480
+ enrichActionRecord(_record, _span, attrs) {
481
+ enricherAttrs = attrs;
482
+ },
483
+ });
484
+
485
+ const span: StoredSpan = {
486
+ projectId: "proj-123",
487
+ spanId: "span-malformed",
488
+ parentSpanId: null,
489
+ traceId: "trace-malformed",
490
+ traceState: null,
491
+ serviceName: "test-service",
492
+ scopeName: null,
493
+ scopeVersion: null,
494
+ spanName: "malformed-tool",
495
+ spanKind: 1,
496
+ statusCode: 1,
497
+ statusMessage: null,
498
+ startTime: "2026-05-22T00:00:00.000Z",
499
+ endTime: "2026-05-22T00:00:01.000Z",
500
+ durationMs: 1000,
501
+ attributesJson: JSON.stringify({
502
+ [ACTION_ID_KEY]: "not-an-action-id",
503
+ [ACTION_ROOT_ID_KEY]: "also-not-an-action-id",
504
+ [ACTION_CAUSED_BY_ID_KEY]: "bad-parent-id",
505
+ "obs.agent_run.id": "bad-run-id",
506
+ "obs.action.kind": "tool.call",
507
+ "obs.tool_call.tool_name": "unsafe_tool",
508
+ }),
509
+ droppedAttributesCount: 0,
510
+ resourceAttributesJson: "{}",
511
+ eventsJson: "[]",
512
+ droppedEventsCount: 0,
513
+ linksJson: "[]",
514
+ droppedLinksCount: 0,
515
+ receivedAt: "2026-05-22T00:00:01.000Z",
516
+ expiresAt: "2026-05-23T00:00:01.000Z",
517
+ sessionId: null,
518
+ interactionId: null,
519
+ };
520
+
521
+ const processors: SpanProcessorPlugin[] = [];
522
+ actionGraphProcessorPlugin.register(
523
+ {} as Parameters<typeof actionGraphProcessorPlugin.register>[0],
524
+ {
525
+ addSpanProcessor(p: SpanProcessorPlugin) {
526
+ processors.push(p);
527
+ },
528
+ } as unknown as CollectorRuntime,
529
+ );
530
+ const processFn = processors[0]?.process;
531
+ if (!processFn) throw new Error("span processor was not registered");
532
+
533
+ const [transformed] = await processFn([span], context);
534
+ const expectedActionId = await deriveActionId(
535
+ span.projectId,
536
+ span.traceId,
537
+ span.spanId,
538
+ );
539
+ const expectedRootId = await deriveActionId(
540
+ span.projectId,
541
+ span.traceId,
542
+ span.traceId.substring(0, 16),
543
+ );
544
+
545
+ const actionInsert = db.callsMatching("INSERT INTO actions")[0];
546
+ expect(actionInsert.binds[0]).toBe(expectedActionId);
547
+ expect(actionInsert.binds[2]).toBe(expectedRootId);
548
+ expect(actionInsert.binds[3]).toBeNull();
549
+ expect(actionInsert.binds[17]).toBeNull();
550
+
551
+ const attrsJson = String(actionInsert.binds[24]);
552
+ expect(attrsJson).not.toContain("not-an-action-id");
553
+ expect(attrsJson).not.toContain("also-not-an-action-id");
554
+ expect(attrsJson).not.toContain("bad-parent-id");
555
+ expect(attrsJson).not.toContain("bad-run-id");
556
+ expect(JSON.parse(attrsJson)).toMatchObject({
557
+ [ACTION_ID_KEY]: expectedActionId,
558
+ [ACTION_ROOT_ID_KEY]: expectedRootId,
559
+ [ACTION_CAUSED_BY_ID_KEY]: null,
560
+ [ACTION_CONFIDENCE_KEY]: ActionConfidence.Fallback,
561
+ });
562
+
563
+ const transformedAttrs = JSON.parse(transformed?.attributesJson ?? "{}");
564
+ expect(transformedAttrs[ACTION_ID_KEY]).toBe(expectedActionId);
565
+ expect(transformedAttrs[ACTION_CONFIDENCE_KEY]).toBe(
566
+ ActionConfidence.Fallback,
567
+ );
568
+ expect(transformedAttrs["obs.agent_run.id"]).toBeUndefined();
569
+ expect(enricherAttrs).toMatchObject({
570
+ [ACTION_ID_KEY]: expectedActionId,
571
+ [ACTION_ROOT_ID_KEY]: expectedRootId,
572
+ [ACTION_CAUSED_BY_ID_KEY]: null,
573
+ [ACTION_CONFIDENCE_KEY]: ActionConfidence.Fallback,
574
+ });
575
+ expect(JSON.stringify(enricherAttrs)).not.toContain("not-an-action-id");
576
+ expect(JSON.stringify(enricherAttrs)).not.toContain(
577
+ "also-not-an-action-id",
578
+ );
579
+ expect(JSON.stringify(enricherAttrs)).not.toContain("bad-parent-id");
580
+ expect(JSON.stringify(enricherAttrs)).not.toContain("bad-run-id");
581
+ });
582
+
583
+ it("derives queue continuation parent links when explicit async IDs are absent", async () => {
584
+ const span: StoredSpan = {
585
+ projectId: "proj-123",
586
+ spanId: "queue-child-span",
587
+ parentSpanId: "queue-parent-span",
588
+ traceId: "trace-queue",
589
+ traceState: null,
590
+ serviceName: "worker-service",
591
+ scopeName: null,
592
+ scopeVersion: null,
593
+ spanName: "continue queued job",
594
+ spanKind: 1,
595
+ statusCode: 1,
596
+ statusMessage: null,
597
+ startTime: "2026-05-22T00:00:00.000Z",
598
+ endTime: "2026-05-22T00:00:01.000Z",
599
+ durationMs: 1000,
600
+ attributesJson: JSON.stringify({
601
+ "openinference.span.kind": "CHAIN",
602
+ }),
603
+ droppedAttributesCount: 0,
604
+ resourceAttributesJson: "{}",
605
+ eventsJson: "[]",
606
+ droppedEventsCount: 0,
607
+ linksJson: "[]",
608
+ droppedLinksCount: 0,
609
+ receivedAt: "2026-05-22T00:00:01.000Z",
610
+ expiresAt: "2026-05-23T00:00:01.000Z",
611
+ sessionId: null,
612
+ interactionId: null,
613
+ };
614
+
615
+ const processors: SpanProcessorPlugin[] = [];
616
+ actionGraphProcessorPlugin.register(
617
+ {} as Parameters<typeof actionGraphProcessorPlugin.register>[0],
618
+ {
619
+ addSpanProcessor(p: SpanProcessorPlugin) {
620
+ processors.push(p);
621
+ },
622
+ } as unknown as CollectorRuntime,
623
+ );
624
+ const processFn = processors[0]?.process;
625
+ if (!processFn) throw new Error("span processor was not registered");
626
+
627
+ await processFn([span], context);
628
+ const expectedActionId = await deriveActionId(
629
+ span.projectId,
630
+ span.traceId,
631
+ span.spanId,
632
+ );
633
+ const expectedParentActionId = await deriveActionId(
634
+ span.projectId,
635
+ span.traceId,
636
+ span.parentSpanId ?? "",
637
+ );
638
+
639
+ const actionInsert = db.callsMatching("INSERT INTO actions")[0];
640
+ expect(actionInsert.binds[0]).toBe(expectedActionId);
641
+ expect(actionInsert.binds[3]).toBe(expectedParentActionId);
642
+ const attrs = JSON.parse(String(actionInsert.binds[24]));
643
+ expect(attrs[ACTION_CONFIDENCE_KEY]).toBe(ActionConfidence.Fallback);
644
+ expect(attrs[ACTION_CAUSED_BY_ID_KEY]).toBe(expectedParentActionId);
645
+ });
646
+
647
+ it("preserves action identity confidence across the normalizer-to-processor pipeline", async () => {
648
+ const baseSpan = {
649
+ projectId: "proj-123",
650
+ parentSpanId: null,
651
+ traceId: "trace-pipeline",
652
+ traceState: null,
653
+ serviceName: "pipeline-service",
654
+ scopeName: null,
655
+ scopeVersion: null,
656
+ spanKind: 1,
657
+ statusCode: 1,
658
+ statusMessage: null,
659
+ startTime: "2026-05-22T00:00:00.000Z",
660
+ endTime: "2026-05-22T00:00:01.000Z",
661
+ durationMs: 1000,
662
+ droppedAttributesCount: 0,
663
+ resourceAttributesJson: "{}",
664
+ eventsJson: "[]",
665
+ droppedEventsCount: 0,
666
+ linksJson: "[]",
667
+ droppedLinksCount: 0,
668
+ receivedAt: "2026-05-22T00:00:01.000Z",
669
+ expiresAt: "2026-05-23T00:00:01.000Z",
670
+ sessionId: null,
671
+ interactionId: null,
672
+ } satisfies Partial<StoredSpan>;
673
+
674
+ const explicitId = "01J3Y4Z5A6B7C8D9E0F1G2H3J4";
675
+ const spans: StoredSpan[] = [
676
+ {
677
+ ...baseSpan,
678
+ spanId: "span-fallback",
679
+ spanName: "fallback llm",
680
+ attributesJson: JSON.stringify({
681
+ "gen_ai.operation.name": "chat",
682
+ }),
683
+ },
684
+ {
685
+ ...baseSpan,
686
+ spanId: "span-explicit",
687
+ spanName: "explicit llm",
688
+ attributesJson: JSON.stringify({
689
+ "gen_ai.operation.name": "chat",
690
+ [ACTION_ID_KEY]: explicitId,
691
+ }),
692
+ },
693
+ {
694
+ ...baseSpan,
695
+ spanId: "span-malformed-pipeline",
696
+ spanName: "malformed llm",
697
+ attributesJson: JSON.stringify({
698
+ "gen_ai.operation.name": "chat",
699
+ [ACTION_ID_KEY]: "not-an-action-id",
700
+ }),
701
+ },
702
+ {
703
+ ...baseSpan,
704
+ spanId: "span-mcp",
705
+ spanName: "write_file",
706
+ attributesJson: JSON.stringify({
707
+ "mcp.method.name": "tools/call",
708
+ "mcp.tool.name": "write_file",
709
+ }),
710
+ },
711
+ {
712
+ ...baseSpan,
713
+ spanId: "span-queue-child",
714
+ parentSpanId: "span-queue-parent",
715
+ spanName: "queued continuation",
716
+ attributesJson: JSON.stringify({
717
+ "openinference.span.kind": "CHAIN",
718
+ }),
719
+ },
720
+ ] as StoredSpan[];
721
+
722
+ const runPipeline = registerSpanProcessors(
723
+ genAiNormalizerPlugin,
724
+ actionGraphProcessorPlugin,
725
+ );
726
+ await runPipeline(spans, context);
727
+
728
+ const actionBySpan = new Map(
729
+ db
730
+ .callsMatching("INSERT INTO actions")
731
+ .map((insert) => [String(insert.binds[13]), insert]),
732
+ );
733
+ const attrsFor = (spanId: string) =>
734
+ JSON.parse(String(actionBySpan.get(spanId)?.binds[24] ?? "{}"));
735
+
736
+ expect(attrsFor("span-fallback")[ACTION_CONFIDENCE_KEY]).toBe(
737
+ ActionConfidence.Fallback,
738
+ );
739
+ expect(attrsFor("span-explicit")).toMatchObject({
740
+ [ACTION_ID_KEY]: explicitId,
741
+ [ACTION_CONFIDENCE_KEY]: ActionConfidence.Explicit,
742
+ });
743
+ expect(attrsFor("span-malformed-pipeline")).toMatchObject({
744
+ [ACTION_ID_KEY]: await deriveActionId(
745
+ "proj-123",
746
+ "trace-pipeline",
747
+ "span-malformed-pipeline",
748
+ ),
749
+ [ACTION_CONFIDENCE_KEY]: ActionConfidence.Fallback,
750
+ });
751
+ expect(attrsFor("span-mcp")[ACTION_CONFIDENCE_KEY]).toBe(
752
+ ActionConfidence.Fallback,
753
+ );
754
+ expect(db.callsMatching("INSERT INTO tool_calls")).toHaveLength(1);
755
+ expect(actionBySpan.get("span-queue-child")?.binds[3]).toBe(
756
+ await deriveActionId("proj-123", "trace-pipeline", "span-queue-parent"),
757
+ );
758
+ });
759
+
760
+ it("persists explicit MCP audit and mutation evidence without accepting raw MCP metadata", async () => {
761
+ const baseSpan = {
762
+ projectId: "proj-123",
763
+ parentSpanId: null,
764
+ traceId: "trace-evidence",
765
+ traceState: null,
766
+ serviceName: "tool-service",
767
+ scopeName: null,
768
+ scopeVersion: null,
769
+ spanKind: 1,
770
+ statusCode: 1,
771
+ statusMessage: null,
772
+ startTime: "2026-05-22T00:00:00.000Z",
773
+ endTime: "2026-05-22T00:00:01.000Z",
774
+ durationMs: 1000,
775
+ droppedAttributesCount: 0,
776
+ resourceAttributesJson: "{}",
777
+ eventsJson: "[]",
778
+ droppedEventsCount: 0,
779
+ linksJson: "[]",
780
+ droppedLinksCount: 0,
781
+ receivedAt: "2026-05-22T00:00:01.000Z",
782
+ expiresAt: "2026-05-23T00:00:01.000Z",
783
+ sessionId: null,
784
+ interactionId: null,
785
+ } satisfies Partial<StoredSpan>;
786
+
787
+ const runPipeline = registerSpanProcessors(actionGraphProcessorPlugin);
788
+ await runPipeline(
789
+ [
790
+ {
791
+ ...baseSpan,
792
+ spanId: "span-evidence",
793
+ spanName: "write_file",
794
+ attributesJson: JSON.stringify({
795
+ "openinference.span.kind": "tool.call",
796
+ "obs.tool_call.tool_name": "write_file",
797
+ "obs.mcp.audit": {
798
+ schemaVersion: 1,
799
+ presentFields: ["progressToken", "_meta"],
800
+ allowedFields: { progressToken: true, _meta: false },
801
+ redactedFields: ["_meta"],
802
+ hashedFields: { progressToken: "sha256:123" },
803
+ droppedFields: ["_meta.secret"],
804
+ hasRawMeta: true,
805
+ },
806
+ "obs.tool_call.mutation.before": { status: "draft" },
807
+ "obs.tool_call.mutation.after": { status: "published" },
808
+ "obs.tool_call.mutation.diff": [{ op: "replace", path: "/status" }],
809
+ "obs.tool_call.mutation.artifact_id": "artifact-123",
810
+ }),
811
+ },
812
+ {
813
+ ...baseSpan,
814
+ spanId: "span-raw-meta",
815
+ spanName: "raw_meta_tool",
816
+ attributesJson: JSON.stringify({
817
+ "openinference.span.kind": "tool.call",
818
+ "obs.tool_call.tool_name": "raw_meta_tool",
819
+ "mcp._meta": { secret: "do-not-store" },
820
+ }),
821
+ },
822
+ {
823
+ ...baseSpan,
824
+ spanId: "span-resource",
825
+ spanName: "read_resource",
826
+ attributesJson: JSON.stringify({
827
+ "openinference.span.kind": "retrieval",
828
+ "mcp.method.name": "resources/read",
829
+ "obs.mcp.audit": {
830
+ schemaVersion: 1,
831
+ presentFields: ["cursor", "_meta"],
832
+ allowedFields: { cursor: true, _meta: false },
833
+ redactedFields: ["_meta"],
834
+ hasRawMeta: true,
835
+ },
836
+ }),
837
+ },
838
+ ] as StoredSpan[],
839
+ context,
840
+ );
841
+
842
+ const toolInserts = db.callsMatching("INSERT INTO tool_calls");
843
+ expect(toolInserts).toHaveLength(2);
844
+ const evidenceInsert = toolInserts.find((call) =>
845
+ call.binds.includes("span-evidence"),
846
+ );
847
+ const rawMetaInsert = toolInserts.find((call) =>
848
+ call.binds.includes("span-raw-meta"),
849
+ );
850
+ expect(evidenceInsert?.binds[11]).toContain("presentFields");
851
+ expect(evidenceInsert?.binds[12]).toContain("draft");
852
+ expect(evidenceInsert?.binds[13]).toContain("published");
853
+ expect(evidenceInsert?.binds[14]).toContain("replace");
854
+ expect(evidenceInsert?.binds[15]).toBe("artifact-123");
855
+ expect(rawMetaInsert?.binds[11]).toBeNull();
856
+ expect(String(rawMetaInsert?.binds.join(" "))).not.toContain(
857
+ "do-not-store",
858
+ );
859
+
860
+ const actionBySpan = new Map(
861
+ db
862
+ .callsMatching("INSERT INTO actions")
863
+ .map((insert) => [String(insert.binds[13]), insert]),
864
+ );
865
+ const resourceAttrs = JSON.parse(
866
+ String(actionBySpan.get("span-resource")?.binds[24] ?? "{}"),
867
+ );
868
+ expect(resourceAttrs["obs.mcp.audit_envelope"]).toMatchObject({
869
+ presentFields: ["cursor", "_meta"],
870
+ hasRawMeta: true,
871
+ });
872
+ expect(resourceAttrs["obs.mcp.audit"]).toBeUndefined();
873
+ });
874
+
875
+ it("persists Scenario B heavy-spender proof chain from seeded span attributes", async () => {
876
+ const scenario = {
877
+ traceId: "0b000000000000000000000000000001",
878
+ runSpanId: "0b00000000000001",
879
+ llmSpanId: "0b00000000000002",
880
+ toolSpanId: "0b00000000000003",
881
+ evalSpanId: "0b00000000000004",
882
+ runActionId: "01K00000000000000000000001",
883
+ llmActionId: "01K00000000000000000000002",
884
+ toolActionId: "01K00000000000000000000003",
885
+ evalActionId: "01K00000000000000000000004",
886
+ };
887
+ const baseSpan = {
888
+ projectId: "default",
889
+ traceId: scenario.traceId,
890
+ traceState: null,
891
+ serviceName: "obs-demo",
892
+ scopeName: "seed-ai",
893
+ scopeVersion: null,
894
+ spanKind: 1,
895
+ statusCode: 1,
896
+ statusMessage: null,
897
+ startTime: "2026-05-22T00:00:00.000Z",
898
+ endTime: "2026-05-22T00:00:01.000Z",
899
+ durationMs: 1000,
900
+ droppedAttributesCount: 0,
901
+ resourceAttributesJson: "{}",
902
+ eventsJson: "[]",
903
+ droppedEventsCount: 0,
904
+ linksJson: "[]",
905
+ droppedLinksCount: 0,
906
+ receivedAt: "2026-05-22T00:00:01.000Z",
907
+ expiresAt: "2026-05-23T00:00:01.000Z",
908
+ sessionId: "sess-heavy",
909
+ interactionId: "ix-ai",
910
+ } satisfies Partial<StoredSpan>;
911
+
912
+ const runPipeline = registerSpanProcessors(actionGraphProcessorPlugin);
913
+ await runPipeline(
914
+ [
915
+ {
916
+ ...baseSpan,
917
+ spanId: scenario.runSpanId,
918
+ parentSpanId: null,
919
+ spanName: "agent.run.recommendation-cost-spike",
920
+ attributesJson: JSON.stringify({
921
+ "obs.action.id": scenario.runActionId,
922
+ "obs.action.kind": "agent.run",
923
+ "obs.action.name": "Seed recommendation agent run",
924
+ "obs.actor.type": "agent",
925
+ "obs.actor.id": "seed-recommendation-agent",
926
+ "obs.agent.id": "seed-recommendation-agent",
927
+ "obs.agent.name": "Seed Recommendation Agent",
928
+ "obs.agent.version": "scenario-b-v1",
929
+ "obs.agent.goal":
930
+ "Generate recommendations for a heavy-spender session",
931
+ "obs.agent.autonomy_level": "suggested_action",
932
+ }),
933
+ },
934
+ {
935
+ ...baseSpan,
936
+ spanId: scenario.llmSpanId,
937
+ parentSpanId: scenario.runSpanId,
938
+ spanName: "anthropic.chat",
939
+ attributesJson: JSON.stringify({
940
+ "openinference.span.kind": "LLM",
941
+ "llm.provider": "anthropic",
942
+ "llm.model_name": "claude-3-5-haiku",
943
+ "obs.action.id": scenario.llmActionId,
944
+ "obs.action.root_id": scenario.runActionId,
945
+ "obs.action.caused_by_id": scenario.runActionId,
946
+ "obs.action.kind": "llm.call",
947
+ "obs.agent.run_id": scenario.runActionId,
948
+ "obs.action.prompt_version": "seed-scenario-b-v1",
949
+ "obs.action.total_cost_usd": 0.25,
950
+ }),
951
+ },
952
+ {
953
+ ...baseSpan,
954
+ spanId: scenario.toolSpanId,
955
+ parentSpanId: scenario.llmSpanId,
956
+ spanName: "catalog.lookup_recommendations",
957
+ attributesJson: JSON.stringify({
958
+ "obs.action.id": scenario.toolActionId,
959
+ "obs.action.root_id": scenario.runActionId,
960
+ "obs.action.caused_by_id": scenario.llmActionId,
961
+ "obs.action.kind": "tool.call",
962
+ "obs.agent.run_id": scenario.runActionId,
963
+ "obs.tool.name": "catalog.lookup_recommendations",
964
+ "obs.tool.args": JSON.stringify({ category: "premium", limit: 50 }),
965
+ "obs.tool.result": JSON.stringify({ returned: 50, cache: "miss" }),
966
+ }),
967
+ },
968
+ {
969
+ ...baseSpan,
970
+ spanId: scenario.evalSpanId,
971
+ parentSpanId: scenario.llmSpanId,
972
+ spanName: "eval.recommendation_budget_guard",
973
+ statusCode: 2,
974
+ statusMessage: "budget_guard_failed",
975
+ attributesJson: JSON.stringify({
976
+ "obs.action.id": scenario.evalActionId,
977
+ "obs.action.root_id": scenario.runActionId,
978
+ "obs.action.caused_by_id": scenario.llmActionId,
979
+ "obs.action.kind": "eval",
980
+ "obs.agent.run_id": scenario.runActionId,
981
+ "obs.eval.evaluator_name": "recommendation_budget_guard",
982
+ "obs.eval.evaluator_version": "seed-v1",
983
+ "obs.eval.score": 0.35,
984
+ "obs.eval.passed": false,
985
+ "obs.eval.reasoning":
986
+ "Heavy-spender recommendation call exceeded the seed budget threshold.",
987
+ }),
988
+ },
989
+ ] as StoredSpan[],
990
+ context,
991
+ );
992
+
993
+ const actionInserts = db.callsMatching("INSERT INTO actions");
994
+ expect(actionInserts.map((call) => call.binds[0])).toEqual([
995
+ scenario.runActionId,
996
+ scenario.llmActionId,
997
+ scenario.toolActionId,
998
+ scenario.evalActionId,
999
+ ]);
1000
+ expect(actionInserts.map((call) => call.binds[2])).toEqual([
1001
+ scenario.runActionId,
1002
+ scenario.runActionId,
1003
+ scenario.runActionId,
1004
+ scenario.runActionId,
1005
+ ]);
1006
+ expect(actionInserts.map((call) => call.binds[3])).toEqual([
1007
+ null,
1008
+ scenario.runActionId,
1009
+ scenario.llmActionId,
1010
+ scenario.llmActionId,
1011
+ ]);
1012
+
1013
+ expect(db.callsMatching("INSERT INTO agent_runs")[0].binds).toContain(
1014
+ "seed-recommendation-agent",
1015
+ );
1016
+ expect(db.callsMatching("INSERT INTO tool_calls")[0].binds).toContain(
1017
+ "catalog.lookup_recommendations",
1018
+ );
1019
+ const evalInsert = db.callsMatching("INSERT INTO eval_results")[0];
1020
+ expect(evalInsert.binds).toContain("recommendation_budget_guard");
1021
+ expect(evalInsert.binds).toContain(0);
1022
+ });
1023
+ });