Convert opencode transcripts to otel (or agent) traces
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add nested span structure plan and begin refactoring

- Add doc/PLAN-nested.md with comprehensive plan for parent-child span hierarchy
- Add parent_span_id field to GenAiSpan struct for span linking
- Update parser to prepare for paired user/assistant message handling
- Update exporter, formatter, and commands for new span structure
- Fix compilation errors with serde_json value handling

rektide 4873575f 05cc6779

+667 -145
+324
doc/PLAN-nested.md
··· 1 + # Plan: Implement Nested Span Structure for exp2span 2 + 3 + ## Problem Statement 4 + 5 + The current implementation creates a **flat span structure** where all spans are independent and have no parent-child relationships. This loses critical trace context about how AI thinking, tool calls, and completions relate to each other within a conversation turn. 6 + 7 + ### Current Issues 8 + 9 + 1. **No parent-child links**: All spans are created independently with no `parent_span_id` field 10 + 2. **Lost hierarchy**: It's impossible to trace which thinking/tool calls belong to which assistant response 11 + 3. **Misaligned with OpenTelemetry**: OpenTelemetry tracing relies on span hierarchies to show execution flow 12 + 4. **Ambiguous ownership**: Multiple spans can exist for the same timestamp without clear relationships 13 + 14 + ### Current Span Structure (FLAT) 15 + 16 + ``` 17 + GenAiSpan { 18 + span_type: Chat|Thinking|ToolCall, 19 + session_id: String, 20 + timestamp: Timestamp, 21 + agent: Option<String>, 22 + model: Option<String>, 23 + attributes: serde_json::Map, 24 + duration_ms: Option<u64>, 25 + } 26 + ``` 27 + 28 + All spans are pushed to a flat vector with no linking: 29 + ```rust 30 + pub fn parse_entries(&self, log: &OpenCodeLog) -> Vec<GenAiSpan> { 31 + let mut spans = Vec::new(); 32 + 33 + for msg in &log.messages { 34 + match msg.role.as_str() { 35 + "user" => { 36 + // Creates a Chat span - independent 37 + spans.push(GenAiSpan { ... }); 38 + } 39 + "assistant" => { 40 + // Creates Thinking span - independent 41 + spans.push(GenAiSpan { ... }); 42 + 43 + // Creates ToolCall spans - independent 44 + for tool in tools { 45 + spans.push(GenAiSpan { ... }); 46 + } 47 + 48 + // Creates completion Chat span - independent 49 + spans.push(GenAiSpan { ... }); 50 + } 51 + } 52 + } 53 + 54 + spans // Flat vector, no hierarchy 55 + } 56 + ``` 57 + 58 + ## Desired Span Structure (NESTED) 59 + 60 + ### Conceptual Model 61 + 62 + For each conversation turn (user input → assistant response), we want: 63 + 64 + ``` 65 + User Message → Chat Span (PARENT) 66 + ├─ Thinking Span (CHILD) 67 + ├─ Tool Call Spans (CHILDREN) 68 + └─ Completion content stored in parent Chat span 69 + ``` 70 + 71 + ### Key Design Decisions 72 + 73 + 1. **User messages are context, not spans**: User messages don't create independent spans. Instead, they're stored as `gen_ai.input` attribute in the Chat span for that conversation turn. 74 + 75 + 2. **One Chat span per turn**: Each user message + assistant response pair creates exactly ONE Chat span that serves as the parent. 76 + 77 + 3. **Child spans linked via parent_span_id**: All thinking and tool call spans are children of the Chat span, linked through the `parent_span_id` field. 78 + 79 + 4. **Completion content in parent**: The assistant's final completion (cleaned of tool output) is stored as `gen_ai.completion` attribute in the parent Chat span. 80 + 81 + ### Revised GenAiSpan Structure 82 + 83 + ```rust 84 + #[derive(Debug, Clone)] 85 + pub struct GenAiSpan { 86 + pub span_type: SpanType, 87 + pub session_id: String, 88 + pub timestamp: Timestamp, 89 + pub agent: Option<String>, 90 + pub model: Option<String>, 91 + pub attributes: serde_json::Map<String, serde_json::Value>, 92 + pub duration_ms: Option<u64>, 93 + pub parent_span_id: Option<String>, // NEW: Link to parent span 94 + } 95 + ``` 96 + 97 + ### Revised Parsing Algorithm 98 + 99 + Instead of parsing each message independently, we need to **pair user and assistant messages** and build span hierarchies: 100 + 101 + ```rust 102 + pub fn parse_entries(&self, log: &OpenCodeLog) -> Vec<GenAiSpan> { 103 + let mut spans = Vec::new(); 104 + let mut current_user_msg: Option<&LogMessage> = None; 105 + 106 + for msg in &log.messages { 107 + match msg.role.as_str() { 108 + "user" => { 109 + // Store user message as context for next assistant response 110 + current_user_msg = Some(msg); 111 + } 112 + 113 + "assistant" => { 114 + // Create Chat span as parent 115 + let parent_span = create_chat_span(current_user_msg, msg, log); 116 + let parent_span_id = parent_span.span_id(); 117 + spans.push(parent_span); 118 + 119 + // Create Thinking span as child 120 + if let Some(thinking) = &msg.thinking { 121 + spans.push(create_thinking_span(msg, log, &parent_span_id)); 122 + } 123 + 124 + // Create Tool Call spans as children 125 + if let Some(tools) = &msg.tools_used { 126 + for tool in tools { 127 + spans.push(create_tool_span(msg, tool, log, &parent_span_id)); 128 + } 129 + } 130 + 131 + // Reset for next turn 132 + current_user_msg = None; 133 + } 134 + } 135 + } 136 + 137 + spans // Nested hierarchy via parent_span_id 138 + } 139 + 140 + fn create_chat_span( 141 + user_msg: Option<&LogMessage>, 142 + assistant_msg: &LogMessage, 143 + log: &OpenCodeLog, 144 + ) -> GenAiSpan { 145 + let mut attributes = serde_json::Map::new(); 146 + 147 + // User input as context 148 + if let Some(user) = user_msg { 149 + attributes.insert("gen_ai.input".to_string(), serde_json::json!(user.content)); 150 + } 151 + 152 + // Assistant metadata 153 + if let Some(agent) = &assistant_msg.agent { 154 + attributes.insert("opencode.agent".to_string(), serde_json::json!(agent)); 155 + } 156 + 157 + if let Some(model) = &assistant_msg.model { 158 + attributes.insert("gen_ai.model.name".to_string(), serde_json::json!(model)); 159 + } 160 + 161 + // Clean completion content 162 + let content_cleaned = assistant_msg.content 163 + .lines() 164 + .filter(|l| !tool_output_regex.is_match(l)) 165 + .join("\n") 166 + .trim() 167 + .to_string(); 168 + 169 + if !content_cleaned.is_empty() { 170 + attributes.insert("gen_ai.completion".to_string(), serde_json::json!(content_cleaned)); 171 + } 172 + 173 + GenAiSpan { 174 + span_type: SpanType::Chat, 175 + session_id: log.session_id.clone(), 176 + timestamp: assistant_msg.timestamp.unwrap_or_else(|| Timestamp::now()), 177 + agent: assistant_msg.agent.clone(), 178 + model: assistant_msg.model.clone(), 179 + attributes, 180 + duration_ms: None, 181 + parent_span_id: None, // Root span, no parent 182 + } 183 + } 184 + 185 + fn create_thinking_span( 186 + assistant_msg: &LogMessage, 187 + log: &OpenCodeLog, 188 + parent_span_id: &str, 189 + ) -> GenAiSpan { 190 + let mut attributes = serde_json::Map::new(); 191 + attributes.insert("mcp.session.id".to_string(), serde_json::json!(log.session_id)); 192 + attributes.insert( 193 + "opencode.assistant.thinking".to_string(), 194 + serde_json::json!(assistant_msg.thinking), 195 + ); 196 + 197 + GenAiSpan { 198 + span_type: SpanType::Thinking, 199 + session_id: log.session_id.clone(), 200 + timestamp: assistant_msg.timestamp.unwrap_or_else(|| Timestamp::now()), 201 + agent: assistant_msg.agent.clone(), 202 + model: assistant_msg.model.clone(), 203 + attributes, 204 + duration_ms: None, 205 + parent_span_id: Some(parent_span_id.to_string()), // Linked to parent 206 + } 207 + } 208 + 209 + fn create_tool_span( 210 + assistant_msg: &LogMessage, 211 + tool: &ToolUsage, 212 + log: &OpenCodeLog, 213 + parent_span_id: &str, 214 + ) -> GenAiSpan { 215 + let mut attributes = serde_json::Map::new(); 216 + attributes.insert("mcp.session.id".to_string(), serde_json::json!(log.session_id)); 217 + attributes.insert("mcp.method.name".to_string(), serde_json::json!("tools/call")); 218 + attributes.insert("gen_ai.tool.name".to_string(), serde_json::json!(tool.name)); 219 + attributes.insert("gen_ai.operation.name".to_string(), serde_json::json!("execute_tool")); 220 + attributes.insert("gen_ai.tool.call.arguments".to_string(), serde_json::json!(tool.arguments)); 221 + 222 + if let Some(result) = &tool.result { 223 + attributes.insert("gen_ai.tool.call.result".to_string(), serde_json::json!(result)); 224 + } 225 + 226 + GenAiSpan { 227 + span_type: SpanType::ToolCall, 228 + session_id: log.session_id.clone(), 229 + timestamp: assistant_msg.timestamp.unwrap_or_else(|| Timestamp::now()), 230 + agent: assistant_msg.agent.clone(), 231 + model: assistant_msg.model.clone(), 232 + attributes, 233 + duration_ms: tool.duration_ms, 234 + parent_span_id: Some(parent_span_id.to_string()), // Linked to parent 235 + } 236 + } 237 + ``` 238 + 239 + ## Span Attributes Reference 240 + 241 + ### Parent Chat Span 242 + 243 + | Attribute | Type | Description | 244 + |-----------|------|-------------| 245 + | `gen_ai.input` | string | User's message content | 246 + | `gen_ai.completion` | string | Assistant's final response (cleaned) | 247 + | `gen_ai.model.name` | string | Model used (e.g., claude-3-5-sonnet) | 248 + | `opencode.agent` | string | Agent name (if applicable) | 249 + | `mcp.session.id` | string | Session identifier | 250 + 251 + ### Child Thinking Span 252 + 253 + | Attribute | Type | Description | 254 + |-----------|------|-------------| 255 + | `opencode.assistant.thinking` | string | Full thinking content | 256 + | `mcp.session.id` | string | Session identifier | 257 + | `parent_span_id` | string | ID of parent Chat span | 258 + 259 + ### Child Tool Call Span 260 + 261 + | Attribute | Type | Description | 262 + |-----------|------|-------------| 263 + | `mcp.method.name` | string | Always "tools/call" | 264 + | `gen_ai.tool.name` | string | Tool name (e.g., "read") | 265 + | `gen_ai.operation.name` | string | Always "execute_tool" | 266 + | `gen_ai.tool.call.arguments` | JSON | Tool input arguments | 267 + | `gen_ai.tool.call.result` | JSON | Tool output/result | 268 + | `gen_ai.model.name` | string | Model used | 269 + | `opencode.agent` | string | Agent name (if applicable) | 270 + | `mcp.session.id` | string | Session identifier | 271 + | `parent_span_id` | string | ID of parent Chat span | 272 + 273 + ## Implementation Checklist 274 + 275 + ### Core Changes 276 + 277 + - [ ] Add `parent_span_id: Option<String>` field to `GenAiSpan` struct 278 + - [ ] Refactor `parse_entries()` to pair user/assistant messages 279 + - [ ] Create helper functions for span creation: 280 + - [ ] `create_chat_span()` - Creates parent Chat span with user input + completion 281 + - [ ] `create_thinking_span()` - Creates child Thinking span linked to parent 282 + - [ ] `create_tool_span()` - Creates child ToolCall span linked to parent 283 + 284 + ### Exporter Updates 285 + 286 + - [ ] Update `export_tool_call()` to use `parent_span_id` from `GenAiSpan` 287 + - [ ] Update `export_chat()` to handle parent spans (no `parent_span_id`) 288 + - [ ] Update `export_thinking()` to use `parent_span_id` from `GenAiSpan` 289 + - [ ] Ensure OpenTelemetry span links use the parent ID correctly 290 + 291 + ### Formatter Updates 292 + 293 + - [ ] Update `SpanDisplay` to include `parent_span_id` field 294 + - [ ] Update table formatter to show parent-child relationships 295 + - [ ] Update JSON formatter to include parent_span_id in output 296 + 297 + ### Command Updates 298 + 299 + - [ ] Update `validate.rs` to check for valid parent-child links 300 + - [ ] Update `info.rs` to show span hierarchy statistics 301 + 302 + ### Testing 303 + 304 + - [ ] Test single turn (user → assistant with thinking + tools) 305 + - [ ] Test multiple turns (consecutive user → assistant pairs) 306 + - [ ] Test edge cases (no tools, no thinking, orphaned messages) 307 + - [ ] Verify OTLP export shows correct span hierarchy 308 + 309 + ## Open Questions 310 + 311 + 1. **Session-level trace ID**: Should we also add a `trace_id` field to link all spans in a session together? Or is the session_id sufficient? 312 + 313 + 2. **Duration tracking**: For the parent Chat span, should we calculate total duration (from first child to last child), or leave it as `None`? 314 + 315 + 3. **Multiple tools in parallel**: If tools are called in parallel, should they have the same timestamp? The current structure uses the assistant message timestamp for all tool spans. 316 + 317 + ## Next Steps 318 + 319 + 1. Update `GenAiSpan` struct to include `parent_span_id` 320 + 2. Refactor `parse_entries()` to implement the pairing algorithm 321 + 3. Create helper functions for span creation 322 + 4. Update exporter, formatter, and command files to handle new field 323 + 5. Test with actual opencode logs to verify hierarchy 324 + 6. Run linters and type checkers (`cargo build --release`)
+1 -1
src/commands/export.rs
··· 3 3 use crate::cli::{ExportArgs, OtlpProtocol, OutputFormat}; 4 4 use crate::config::{OtlpConfig, OtlpProtocol as ConfigOtlpProtocol}; 5 5 use crate::parser::LogParser; 6 + use crate::parser::GenAiSpan; 6 7 use crate::exporter::OtelExporter; 7 8 use crate::formatter; 8 - 9 9 pub async fn execute(args: ExportArgs, output_format: OutputFormat) -> Result<()> { 10 10 let use_stdout = matches!(output_format, OutputFormat::Json | OutputFormat::JsonLines | OutputFormat::Yaml | OutputFormat::Table); 11 11
+6 -6
src/commands/info.rs
··· 1 1 use crate::cli::InfoArgs; 2 - use crate::parser::{LogParser, ParsedLogEntry}; 2 + use crate::parser::{GenAiSpan, LogParser, SpanType}; 3 3 use anyhow::Result; 4 - use colored::Colorize; 5 4 6 5 pub fn execute(args: InfoArgs) -> Result<()> { 7 6 let parser = LogParser::new(); ··· 15 14 let mut chat_count = 0; 16 15 17 16 for entry in &entries { 18 - match entry { 19 - ParsedLogEntry::McpTool(_) => mcp_tool_count += 1, 20 - ParsedLogEntry::Chat(_) => chat_count += 1, 17 + match entry.span_type { 18 + SpanType::ToolCall => mcp_tool_count += 1, 19 + SpanType::Chat => chat_count += 1, 20 + SpanType::Thinking => chat_count += 1, 21 21 } 22 22 } 23 23 ··· 26 26 println!("Session ID: {}", log.session_id); 27 27 println!("Total spans: {}", entries.len()); 28 28 println!("MCP tool calls: {}", mcp_tool_count); 29 - println!("Chat messages: {}", chat_count); 29 + println!("Chat messages (including Thinking): {}", chat_count); 30 30 println!("Duration: {:.1}s total", duration_seconds); 31 31 32 32 Ok(())
+10 -5
src/commands/validate.rs
··· 1 1 use crate::cli::ValidateArgs; 2 - use crate::parser::{ChatSpan, LogParser, McpToolSpan, ParsedLogEntry}; 2 + use crate::parser::{GenAiSpan, LogParser, SpanType}; 3 3 use anyhow::{bail, Context, Result}; 4 4 use colored::Colorize; 5 5 ··· 20 20 let mut warnings: Vec<String> = Vec::new(); 21 21 22 22 for entry in &entries { 23 - match entry { 24 - ParsedLogEntry::McpTool(_) => mcp_tool_count += 1, 25 - ParsedLogEntry::Chat(_) => chat_count += 1, 23 + match entry.span_type { 24 + SpanType::ToolCall => mcp_tool_count += 1, 25 + SpanType::Chat => chat_count += 1, 26 + SpanType::Thinking => chat_count += 1, 26 27 } 27 28 } 28 29 29 30 println!("{} Parsed {} spans", "✓".green(), entries.len()); 30 31 println!("{} MCP tool calls: {}", "✓".green(), mcp_tool_count); 31 - println!("{} Chat messages: {}", "✓".green(), chat_count); 32 + println!( 33 + "{} Chat messages (including Thinking): {}", 34 + "✓".green(), 35 + chat_count 36 + ); 32 37 33 38 if !entries.is_empty() { 34 39 let entries_with_timestamps = entries.len();
+87 -25
src/exporter.rs
··· 7 7 use opentelemetry_sdk::Resource; 8 8 9 9 use crate::config::{OtlpConfig, OtlpProtocol}; 10 - use crate::parser::{ParsedLogEntry, McpToolSpan, ChatSpan}; 10 + use crate::parser::{GenAiSpan, SpanType}; 11 11 12 12 pub struct OtelExporter { 13 13 tracer: SdkTracer, ··· 106 106 Ok((tracer, provider)) 107 107 } 108 108 109 - pub fn export_log(&self, entry: &ParsedLogEntry) { 110 - match entry { 111 - ParsedLogEntry::McpTool(tool) => self.export_mcp_tool(tool), 112 - ParsedLogEntry::Chat(chat) => self.export_chat(chat), 109 + pub fn export_log(&self, span: &GenAiSpan) { 110 + match span.span_type { 111 + SpanType::ToolCall => self.export_tool_call(span), 112 + SpanType::Chat => self.export_chat(span), 113 + SpanType::Thinking => self.export_thinking(span), 113 114 } 114 115 } 115 116 116 - fn export_mcp_tool(&self, tool: &McpToolSpan) { 117 + fn export_tool_call(&self, span: &GenAiSpan) { 117 118 let mut attributes = vec![ 118 - KeyValue::new("mcp.session.id", tool.session_id.clone()), 119 + KeyValue::new("mcp.session.id", span.session_id.clone()), 119 120 KeyValue::new("network.transport", "tcp"), 120 121 KeyValue::new("jsonrpc.protocol.version", "2.0"), 121 122 KeyValue::new("mcp.method.name", "tools/call"), 122 - KeyValue::new("gen_ai.tool.name", tool.tool_name.clone()), 123 - KeyValue::new("gen_ai.operation.name", "execute_tool"), 123 + KeyValue::new("gen_ai.tool.name", span.attributes.get("gen_ai.tool.name").unwrap().to_string()), 124 + KeyValue::new("gen_ai.operation.name", span.attributes.get("gen_ai.operation.name").unwrap().to_string()), 124 125 ]; 125 126 126 - if let Some(agent) = &tool.agent { 127 + if let Some(agent) = &span.agent { 127 128 attributes.push(KeyValue::new("opencode.agent", agent.clone())); 128 129 } 129 130 130 - if let Some(model) = &tool.model { 131 + if let Some(model) = &span.model { 131 132 attributes.push(KeyValue::new("gen_ai.model.name", model.clone())); 132 133 } 133 134 134 - let arguments_json = serde_json::to_string(&tool.arguments).unwrap_or_else(|_| "{}".to_string()); 135 - attributes.push(KeyValue::new("gen_ai.tool.call.arguments", arguments_json)); 135 + if let Some(args) = span.attributes.get("gen_ai.tool.call.arguments") { 136 + let args_value = if let serde_json::Value::String(s) = args { 137 + s.to_string() 138 + } else { 139 + "{}".to_string() 140 + }; 141 + attributes.push(KeyValue::new("gen_ai.tool.call.arguments", args_value)); 142 + } 136 143 137 - if let Some(result) = &tool.result { 138 - let result_json = serde_json::to_string(result).unwrap_or_else(|_| "null".to_string()); 139 - attributes.push(KeyValue::new("gen_ai.tool.call.result", result_json)); 144 + if let Some(result) = span.attributes.get("gen_ai.tool.call.result") { 145 + if let serde_json::Value::String(s) = result { 146 + attributes.push(KeyValue::new("gen_ai.tool.call.result", s.clone())); 147 + } else { 148 + attributes.push(KeyValue::new("gen_ai.tool.call.result", "null".to_string())); 149 + } 140 150 } 141 151 142 - let span_name = format!("tools/call {}", tool.tool_name); 152 + let span_name = format!("tools/call {}", span.attributes.get("gen_ai.tool.name").unwrap().as_str().unwrap()); 143 153 144 154 let attr_count = attributes.len(); 145 155 let span_name_clone = span_name.clone(); ··· 158 168 ); 159 169 } 160 170 161 - fn export_chat(&self, chat: &ChatSpan) { 171 + fn export_chat(&self, span: &GenAiSpan) { 162 172 let mut attributes = vec![ 163 - KeyValue::new("mcp.session.id", chat.session_id.clone()), 173 + KeyValue::new("mcp.session.id", span.session_id.clone()), 164 174 KeyValue::new("network.transport", "tcp"), 165 175 KeyValue::new("jsonrpc.protocol.version", "2.0"), 166 176 ]; 167 177 168 - if let Some(agent) = &chat.agent { 178 + if let Some(agent) = &span.agent { 169 179 attributes.push(KeyValue::new("opencode.agent", agent.clone())); 170 180 } 171 181 172 - if let Some(model) = &chat.model { 182 + if let Some(model) = &span.model { 173 183 attributes.push(KeyValue::new("gen_ai.model.name", model.clone())); 174 184 } 175 185 176 - if let Some(thinking) = &chat.thinking { 177 - attributes.push(KeyValue::new("opencode.thinking", thinking.clone())); 186 + if let Some(thinking) = span.attributes.get("opencode.assistant.thinking") { 187 + let thinking_value = if let serde_json::Value::String(s) = thinking { 188 + s.to_string() 189 + } else { 190 + "{}".to_string() 191 + }; 192 + attributes.push(KeyValue::new("opencode.assistant.thinking", thinking_value)); 178 193 } 179 194 180 - if !chat.content.is_empty() { 181 - attributes.push(KeyValue::new("opencode.content", chat.content.clone())); 195 + if let Some(content) = span.attributes.get("gen_ai.completion") { 196 + let content_value = if let serde_json::Value::String(s) = content { 197 + s.to_string() 198 + } else { 199 + "{}".to_string() 200 + }; 201 + attributes.push(KeyValue::new("gen_ai.completion", content_value)); 182 202 } 183 203 184 204 let span_name = "chat"; ··· 187 207 188 208 let mut builder = SpanBuilder::from_name(span_name); 189 209 builder.span_kind = Some(SpanKind::Client); 210 + builder.attributes = Some(attributes); 211 + 212 + let mut span = self.tracer.build(builder); 213 + span.end(); 214 + 215 + tracing::debug!( 216 + "Created span: {} with {} attributes", 217 + span_name, 218 + attr_count 219 + ); 220 + } 221 + 222 + fn export_thinking(&self, span: &GenAiSpan) { 223 + let mut attributes = vec![ 224 + KeyValue::new("mcp.session.id", span.session_id.clone()), 225 + KeyValue::new("network.transport", "tcp"), 226 + KeyValue::new("jsonrpc.protocol.version", "2.0"), 227 + ]; 228 + 229 + if let Some(agent) = &span.agent { 230 + attributes.push(KeyValue::new("opencode.agent", agent.clone())); 231 + } 232 + 233 + if let Some(model) = &span.model { 234 + attributes.push(KeyValue::new("gen_ai.model.name", model.clone())); 235 + } 236 + 237 + if let Some(thinking) = span.attributes.get("opencode.assistant.thinking") { 238 + let thinking_value = if let serde_json::Value::String(s) = thinking { 239 + s.to_string() 240 + } else { 241 + "{}".to_string() 242 + }; 243 + attributes.push(KeyValue::new("opencode.assistant.thinking", thinking_value)); 244 + } 245 + 246 + let span_name = "thinking"; 247 + 248 + let attr_count = attributes.len(); 249 + 250 + let mut builder = SpanBuilder::from_name(span_name); 251 + builder.span_kind = Some(SpanKind::Internal); 190 252 builder.attributes = Some(attributes); 191 253 192 254 let mut span = self.tracer.build(builder);
+20 -66
src/formatter.rs
··· 1 1 use crate::cli::OutputFormat; 2 - use crate::parser::{ChatSpan, McpToolSpan, ParsedLogEntry}; 2 + use crate::parser::{GenAiSpan, SpanType}; 3 3 use anyhow::Result; 4 4 use serde_json::json; 5 5 ··· 7 7 pub struct SpanDisplay { 8 8 pub name: String, 9 9 pub kind: String, 10 + pub span_type: String, 10 11 pub attributes: serde_json::Map<String, serde_json::Value>, 11 12 } 12 13 13 14 impl SpanDisplay { 14 - pub fn from_entry(entry: &ParsedLogEntry) -> Self { 15 - match entry { 16 - ParsedLogEntry::McpTool(tool) => Self::from_mcp_tool(tool), 17 - ParsedLogEntry::Chat(chat) => Self::from_chat(chat), 18 - } 19 - } 20 - 21 - fn from_mcp_tool(tool: &McpToolSpan) -> Self { 22 - let mut attributes = serde_json::Map::new(); 23 - 24 - attributes.insert("mcp.session.id".to_string(), json!(tool.session_id)); 25 - attributes.insert("mcp.method.name".to_string(), json!("tools/call")); 26 - attributes.insert("gen_ai.tool.name".to_string(), json!(tool.tool_name)); 27 - attributes.insert("gen_ai.operation.name".to_string(), json!("execute_tool")); 28 - 29 - if let Some(agent) = &tool.agent { 30 - attributes.insert("opencode.agent".to_string(), json!(agent)); 31 - } 32 - 33 - if let Some(model) = &tool.model { 34 - attributes.insert("gen_ai.model.name".to_string(), json!(model)); 35 - } 36 - 37 - attributes.insert( 38 - "gen_ai.tool.call.arguments".to_string(), 39 - json!(tool.arguments), 40 - ); 41 - if let Some(result) = &tool.result { 42 - attributes.insert("gen_ai.tool.call.result".to_string(), json!(result)); 43 - } 44 - 45 - let span_name = format!("tools/call {}", tool.tool_name); 46 - 47 - SpanDisplay { 48 - name: span_name, 49 - kind: "client".to_string(), 50 - attributes, 51 - } 52 - } 53 - 54 - fn from_chat(chat: &ChatSpan) -> Self { 55 - let mut attributes = serde_json::Map::new(); 56 - 57 - attributes.insert("mcp.session.id".to_string(), json!(chat.session_id)); 58 - 59 - if let Some(agent) = &chat.agent { 60 - attributes.insert("opencode.agent".to_string(), json!(agent)); 61 - } 15 + pub fn from_entry(entry: &GenAiSpan) -> Self { 16 + let span_type_str = match entry.span_type { 17 + SpanType::Chat => "chat", 18 + SpanType::Thinking => "thinking", 19 + SpanType::ToolCall => "tool_call", 20 + }; 62 21 63 - if let Some(model) = &chat.model { 64 - attributes.insert("gen_ai.model.name".to_string(), json!(model)); 65 - } 66 - 67 - if let Some(thinking) = &chat.thinking { 68 - attributes.insert("opencode.thinking".to_string(), json!(thinking)); 69 - } 70 - 71 - if !chat.content.is_empty() { 72 - attributes.insert("opencode.content".to_string(), json!(chat.content)); 73 - } 22 + let span_kind_str = match entry.span_type { 23 + SpanType::Chat => "client", 24 + SpanType::Thinking => "internal", 25 + SpanType::ToolCall => "client", 26 + }; 74 27 75 28 SpanDisplay { 76 - name: "chat".to_string(), 77 - kind: "client".to_string(), 78 - attributes, 29 + name: entry.span_id(), 30 + kind: span_kind_str.to_string(), 31 + span_type: span_type_str.to_string(), 32 + attributes: entry.attributes.clone(), 79 33 } 80 34 } 81 35 } 82 36 83 - pub fn format_spans(entries: &[ParsedLogEntry], format: OutputFormat) -> Result<String> { 37 + pub fn format_spans(entries: &[GenAiSpan], format: OutputFormat) -> Result<String> { 84 38 let spans: Vec<SpanDisplay> = entries.iter().map(SpanDisplay::from_entry).collect(); 85 39 86 40 match format { ··· 120 74 let span_type = if span.name.starts_with("tools/call") { 121 75 "MCP Tool" 122 76 } else { 123 - "Chat" 77 + span.span_type.as_str() 124 78 }; 125 79 126 80 let tool_name = span ··· 168 122 table.add_row(vec![ 169 123 Cell::new(&span.name).fg(Color::Blue), 170 124 Cell::new(span_type).fg(Color::Green), 171 - Cell::new(tool_name).fg(Color::Yellow), 125 + Cell::new(tool_name), 172 126 Cell::new(arguments), 173 127 Cell::new(result), 174 128 ]);
+219 -42
src/parser.rs
··· 34 34 pub duration_ms: Option<u64>, 35 35 } 36 36 37 - #[derive(Debug, Clone)] 38 - pub struct McpToolSpan { 39 - pub session_id: String, 40 - pub timestamp: Timestamp, 41 - pub agent: Option<String>, 42 - pub model: Option<String>, 43 - pub tool_name: String, 44 - pub method: String, 45 - pub arguments: serde_json::Value, 46 - pub result: Option<serde_json::Value>, 47 - pub duration_ms: Option<u64>, 37 + #[derive(Debug, Clone, PartialEq)] 38 + pub enum SpanType { 39 + Chat, 40 + Thinking, 41 + ToolCall, 48 42 } 49 43 50 44 #[derive(Debug, Clone)] 51 - pub struct ChatSpan { 45 + pub struct GenAiSpan { 46 + pub span_type: SpanType, 52 47 pub session_id: String, 53 48 pub timestamp: Timestamp, 54 49 pub agent: Option<String>, 55 50 pub model: Option<String>, 56 - pub content: String, 57 - pub thinking: Option<String>, 51 + pub attributes: serde_json::Map<String, serde_json::Value>, 52 + pub duration_ms: Option<u64>, 53 + pub parent_span_id: Option<String>, 58 54 } 59 55 60 - #[derive(Debug, Clone)] 61 - pub enum ParsedLogEntry { 62 - McpTool(McpToolSpan), 63 - Chat(ChatSpan), 56 + impl GenAiSpan { 57 + pub fn span_id(&self) -> String { 58 + let span_type_i32 = match self.span_type { 59 + SpanType::Chat => 0, 60 + SpanType::Thinking => 1, 61 + SpanType::ToolCall => 2, 62 + }; 63 + 64 + format!( 65 + "{}-{}-{}", 66 + self.session_id, 67 + span_type_i32, 68 + self.timestamp.as_second() 69 + ) 70 + } 64 71 } 65 72 66 73 pub struct LogParser { ··· 68 75 assistant_regex: Regex, 69 76 user_regex: Regex, 70 77 tool_regex: Regex, 78 + tool_output_regex: Regex, 71 79 } 72 80 73 81 impl LogParser { ··· 80 88 assistant_regex: Regex::new(r"## \s*Assistant \s*\(([^)]+)\)\s*").unwrap(), 81 89 user_regex: Regex::new(r"## \s*User").unwrap(), 82 90 tool_regex: Regex::new(r"Tool:\s+(\w+)").unwrap(), 91 + tool_output_regex: Regex::new(r"Tool:\s+\w+").unwrap(), 83 92 } 84 93 } 85 94 ··· 106 115 while i < lines.len() { 107 116 let line = lines[i]; 108 117 118 + if let Some(_caps) = self.user_regex.captures(line) { 119 + let mut message = LogMessage { 120 + msg_type: "user".to_string(), 121 + role: "user".to_string(), 122 + agent: None, 123 + model: None, 124 + timestamp: None, 125 + content: String::new(), 126 + thinking: None, 127 + tools_used: None, 128 + }; 129 + 130 + i += 1; 131 + while i < lines.len() { 132 + let line = lines[i]; 133 + 134 + if line.starts_with("## ") { 135 + break; 136 + } 137 + 138 + message.content.push_str(line); 139 + message.content.push('\n'); 140 + i += 1; 141 + } 142 + 143 + if !message.content.is_empty() { 144 + messages.push(message); 145 + } 146 + 147 + continue; 148 + } 149 + 109 150 if let Some(caps) = self.assistant_regex.captures(line) { 110 151 let parts: Vec<&str> = caps[1].split("·").map(|s| s.trim()).collect(); 111 152 let agent = parts.get(0).map(|s| s.to_string()); ··· 176 217 }) 177 218 } 178 219 179 - pub fn parse_entries(&self, log: &OpenCodeLog) -> Vec<ParsedLogEntry> { 180 - let mut entries = Vec::new(); 220 + pub fn parse_entries(&self, log: &OpenCodeLog) -> Vec<GenAiSpan> { 221 + let mut spans = Vec::new(); 181 222 182 223 for msg in &log.messages { 183 - if let Some(tools) = &msg.tools_used { 184 - for tool in tools { 185 - entries.push(ParsedLogEntry::McpTool(McpToolSpan { 224 + match msg.role.as_str() { 225 + "user" => { 226 + let mut attributes = serde_json::Map::new(); 227 + attributes.insert( 228 + "mcp.session.id".to_string(), 229 + serde_json::json!(log.session_id), 230 + ); 231 + attributes.insert("gen_ai.input".to_string(), serde_json::json!(msg.content)); 232 + 233 + spans.push(GenAiSpan { 234 + span_type: SpanType::Chat, 186 235 session_id: log.session_id.clone(), 187 236 timestamp: msg.timestamp.unwrap_or_else(|| Timestamp::now()), 188 - agent: msg.agent.clone(), 189 - model: msg.model.clone(), 190 - tool_name: tool.name.clone(), 191 - method: tool.method.clone(), 192 - arguments: tool.arguments.clone(), 193 - result: tool.result.clone(), 194 - duration_ms: tool.duration_ms, 195 - })); 237 + agent: None, 238 + model: None, 239 + attributes, 240 + duration_ms: None, 241 + }); 242 + } 243 + 244 + "assistant" => { 245 + if let Some(thinking) = &msg.thinking { 246 + let mut attributes = serde_json::Map::new(); 247 + attributes.insert( 248 + "mcp.session.id".to_string(), 249 + serde_json::json!(log.session_id), 250 + ); 251 + 252 + if let Some(agent) = &msg.agent { 253 + attributes 254 + .insert("opencode.agent".to_string(), serde_json::json!(agent)); 255 + } 256 + 257 + if let Some(model) = &msg.model { 258 + attributes 259 + .insert("gen_ai.model.name".to_string(), serde_json::json!(model)); 260 + } 261 + 262 + attributes.insert( 263 + "opencode.assistant.thinking".to_string(), 264 + serde_json::json!(thinking), 265 + ); 266 + 267 + spans.push(GenAiSpan { 268 + span_type: SpanType::Thinking, 269 + session_id: log.session_id.clone(), 270 + timestamp: msg.timestamp.unwrap_or_else(|| Timestamp::now()), 271 + agent: msg.agent.clone(), 272 + model: msg.model.clone(), 273 + attributes, 274 + duration_ms: None, 275 + }); 276 + } 277 + 278 + if let Some(tools) = &msg.tools_used { 279 + for tool in tools { 280 + let mut tool_attributes = serde_json::Map::new(); 281 + tool_attributes.insert( 282 + "mcp.session.id".to_string(), 283 + serde_json::json!(log.session_id), 284 + ); 285 + tool_attributes.insert( 286 + "mcp.method.name".to_string(), 287 + serde_json::json!("tools/call"), 288 + ); 289 + tool_attributes.insert( 290 + "gen_ai.tool.name".to_string(), 291 + serde_json::json!(tool.name), 292 + ); 293 + tool_attributes.insert( 294 + "gen_ai.operation.name".to_string(), 295 + serde_json::json!("execute_tool"), 296 + ); 297 + tool_attributes.insert( 298 + "gen_ai.tool.call.arguments".to_string(), 299 + serde_json::json!(tool.arguments), 300 + ); 301 + 302 + if let Some(agent) = &msg.agent { 303 + tool_attributes 304 + .insert("opencode.agent".to_string(), serde_json::json!(agent)); 305 + } 306 + 307 + if let Some(model) = &msg.model { 308 + tool_attributes.insert( 309 + "gen_ai.model.name".to_string(), 310 + serde_json::json!(model), 311 + ); 312 + } 313 + 314 + if let Some(result) = &tool.result { 315 + tool_attributes.insert( 316 + "gen_ai.tool.call.result".to_string(), 317 + serde_json::json!(result), 318 + ); 319 + } 320 + 321 + spans.push(GenAiSpan { 322 + span_type: SpanType::ToolCall, 323 + session_id: log.session_id.clone(), 324 + timestamp: msg.timestamp.unwrap_or_else(|| Timestamp::now()), 325 + agent: msg.agent.clone(), 326 + model: msg.model.clone(), 327 + attributes: tool_attributes, 328 + duration_ms: tool.duration_ms, 329 + }); 330 + } 331 + } 332 + 333 + let content_cleaned = msg 334 + .content 335 + .lines() 336 + .filter(|l| !self.tool_output_regex.is_match(l)) 337 + .collect::<Vec<_>>() 338 + .join("\n") 339 + .trim() 340 + .to_string(); 341 + 342 + if !content_cleaned.is_empty() { 343 + let mut completion_attributes = serde_json::Map::new(); 344 + completion_attributes.insert( 345 + "mcp.session.id".to_string(), 346 + serde_json::json!(log.session_id), 347 + ); 348 + completion_attributes.insert( 349 + "gen_ai.operation.name".to_string(), 350 + serde_json::json!("completion"), 351 + ); 352 + 353 + if let Some(agent) = &msg.agent { 354 + completion_attributes 355 + .insert("opencode.agent".to_string(), serde_json::json!(agent)); 356 + } 357 + 358 + if let Some(model) = &msg.model { 359 + completion_attributes 360 + .insert("gen_ai.model.name".to_string(), serde_json::json!(model)); 361 + } 362 + 363 + completion_attributes.insert( 364 + "opencode.assistant.thinking".to_string(), 365 + serde_json::json!(msg.thinking), 366 + ); 367 + completion_attributes.insert( 368 + "gen_ai.completion".to_string(), 369 + serde_json::json!(content_cleaned), 370 + ); 371 + 372 + spans.push(GenAiSpan { 373 + span_type: SpanType::Chat, 374 + session_id: log.session_id.clone(), 375 + timestamp: msg.timestamp.unwrap_or_else(|| Timestamp::now()), 376 + agent: msg.agent.clone(), 377 + model: msg.model.clone(), 378 + attributes: completion_attributes, 379 + duration_ms: None, 380 + }); 381 + } 196 382 } 197 - } 198 383 199 - if !msg.content.is_empty() || msg.thinking.is_some() { 200 - entries.push(ParsedLogEntry::Chat(ChatSpan { 201 - session_id: log.session_id.clone(), 202 - timestamp: msg.timestamp.unwrap_or_else(|| Timestamp::now()), 203 - agent: msg.agent.clone(), 204 - model: msg.model.clone(), 205 - content: msg.content.clone(), 206 - thinking: msg.thinking.clone(), 207 - })); 384 + _ => {} 208 385 } 209 386 } 210 387 211 - entries 388 + spans 212 389 } 213 390 }