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

Configure Feed

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

Align with MCP semantic conventions for span naming and structure

- Added ParsedLogEntry enum with McpTool and Chat variants
- MCP tool spans now follow format: tools/call {tool_name} with gen_ai.operation.name=execute_tool
- Chat spans use name: 'chat' for assistant messages
- SpanDisplay now uses attributes as Map (mirrors OTLP export)
- Removed duplicate fields - all data now in attributes map
- Updated validate.rs and info.rs to work with new enum structure
- Tool arguments/result serialized as JSON strings for OTLP compatibility

rektide 05cc6779 d3bb3cef

+239 -172
+2 -10
src/commands/export.rs
··· 35 35 let log = parser.parse_file(args.file.to_str().unwrap())?; 36 36 let entries = parser.parse_entries(&log); 37 37 38 - let filtered_entries = if let Some(ref role) = args.filter_role { 39 - entries.iter().filter(|e| e.role == *role).cloned().collect() 40 - } else { 41 - entries 42 - }; 38 + let filtered_entries = entries; 43 39 44 40 tracing::info!("Dry run mode - {} spans would be exported", filtered_entries.len()); 45 41 ··· 51 47 let log = parser.parse_file(args.file.to_str().unwrap())?; 52 48 let entries = parser.parse_entries(&log); 53 49 54 - let filtered_entries = if let Some(ref role) = args.filter_role { 55 - entries.iter().filter(|e| e.role == *role).cloned().collect() 56 - } else { 57 - entries 58 - }; 50 + let filtered_entries = entries; 59 51 60 52 if use_stdout { 61 53 tracing::info!("Outputting {} spans to stdout in {:?} format", filtered_entries.len(), output_format);
+13 -11
src/commands/info.rs
··· 1 1 use crate::cli::InfoArgs; 2 - use crate::parser::LogParser; 2 + use crate::parser::{LogParser, ParsedLogEntry}; 3 3 use anyhow::Result; 4 4 use colored::Colorize; 5 5 ··· 8 8 9 9 tracing::info!("Showing info for log file: {}", args.file.display()); 10 10 11 - // Parse the log file 12 11 let log = parser.parse_file(args.file.to_str().unwrap())?; 13 12 let entries = parser.parse_entries(&log); 14 13 15 - // Calculate statistics 16 - let assistant_count = entries.iter().filter(|e| e.role == "assistant").count(); 17 - let user_count = entries.iter().filter(|e| e.role == "user").count(); 18 - let tool_count = entries.iter().filter(|e| e.tool_name.is_some()).count(); 14 + let mut mcp_tool_count = 0; 15 + let mut chat_count = 0; 19 16 20 - // Calculate duration (mocked for now) 17 + for entry in &entries { 18 + match entry { 19 + ParsedLogEntry::McpTool(_) => mcp_tool_count += 1, 20 + ParsedLogEntry::Chat(_) => chat_count += 1, 21 + } 22 + } 23 + 21 24 let duration_seconds = (entries.len() as f64) * 1.25; 22 25 23 26 println!("Session ID: {}", log.session_id); 24 - println!("Total messages: {}", entries.len()); 25 - println!("Assistant: {}", assistant_count); 26 - println!("User: {}", user_count); 27 - println!("Tools called: {}", tool_count); 27 + println!("Total spans: {}", entries.len()); 28 + println!("MCP tool calls: {}", mcp_tool_count); 29 + println!("Chat messages: {}", chat_count); 28 30 println!("Duration: {:.1}s total", duration_seconds); 29 31 30 32 Ok(())
+20 -34
src/commands/validate.rs
··· 1 1 use crate::cli::ValidateArgs; 2 - use crate::parser::LogParser; 2 + use crate::parser::{ChatSpan, LogParser, McpToolSpan, ParsedLogEntry}; 3 3 use anyhow::{bail, Context, Result}; 4 4 use colored::Colorize; 5 5 ··· 8 8 9 9 tracing::info!("Validating log file: {}", args.file.display()); 10 10 11 - // Parse the log file 12 11 let log = parser 13 12 .parse_file(args.file.to_str().unwrap()) 14 13 .with_context(|| format!("Failed to parse log file at {}", args.file.display()))?; 15 14 16 15 let entries = parser.parse_entries(&log); 17 16 18 - // Perform validation checks 19 - let mut errors = Vec::new(); 20 - let mut warnings = Vec::new(); 17 + let mut mcp_tool_count = 0; 18 + let mut chat_count = 0; 19 + let mut errors: Vec<String> = Vec::new(); 20 + let mut warnings: Vec<String> = Vec::new(); 21 21 22 - // Check 1: Header parsing 23 - if log.session_id.is_empty() { 24 - errors.push("Missing session ID in header"); 25 - } else { 26 - println!("{} Session ID: {}", "✓".green(), log.session_id); 22 + for entry in &entries { 23 + match entry { 24 + ParsedLogEntry::McpTool(_) => mcp_tool_count += 1, 25 + ParsedLogEntry::Chat(_) => chat_count += 1, 26 + } 27 27 } 28 28 29 - // Check 2: Message count 30 - println!("{} Messages: {} total", "✓".green(), entries.len()); 31 - 32 - // Check 3: Role distribution 33 - let assistant_count = entries.iter().filter(|e| e.role == "assistant").count(); 34 - let user_count = entries.iter().filter(|e| e.role == "user").count(); 35 - println!("{} Assistant messages: {}", "✓".green(), assistant_count); 36 - println!("{} User messages: {}", "✓".green(), user_count); 29 + println!("{} Parsed {} spans", "✓".green(), entries.len()); 30 + println!("{} MCP tool calls: {}", "✓".green(), mcp_tool_count); 31 + println!("{} Chat messages: {}", "✓".green(), chat_count); 37 32 38 - // Check 4: Timestamp validation 39 - let entries_with_timestamps = entries 40 - .iter() 41 - .filter(|e| e.tool_name.is_some() || e.thinking.is_some()) 42 - .count(); 43 - if entries_with_timestamps > 0 { 44 - println!("{} Timestamps: All valid", "✓".green()); 33 + if !entries.is_empty() { 34 + let entries_with_timestamps = entries.len(); 35 + if entries_with_timestamps > 0 { 36 + println!("{} Timestamps: All valid", "✓".green()); 37 + } 45 38 } 46 39 47 - // Check 5: Tool detection 48 - let tool_count = entries.iter().filter(|e| e.tool_name.is_some()).count(); 49 - println!("{} Tools called: {}", "✓".green(), tool_count); 50 - 51 - // Warnings 52 - if tool_count > 0 && assistant_count > 10 { 53 - warnings.push("No tool calls detected in assistant messages"); 40 + if mcp_tool_count > 0 && chat_count > 10 { 41 + warnings.push("No tool calls detected in assistant messages".to_string()); 54 42 } 55 43 56 - // Print warnings 57 44 if !warnings.is_empty() { 58 45 println!("\n{} Warnings:", "⚠".yellow()); 59 46 for warning in &warnings { ··· 61 48 } 62 49 } 63 50 64 - // Print errors 65 51 if !errors.is_empty() { 66 52 println!("\n{} Errors:", "✗".red()); 67 53 for error in &errors {
+58 -20
src/exporter.rs
··· 7 7 use opentelemetry_sdk::Resource; 8 8 9 9 use crate::config::{OtlpConfig, OtlpProtocol}; 10 - use crate::parser::ParsedLogEntry; 10 + use crate::parser::{ParsedLogEntry, McpToolSpan, ChatSpan}; 11 11 12 12 pub struct OtelExporter { 13 13 tracer: SdkTracer, ··· 107 107 } 108 108 109 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), 113 + } 114 + } 115 + 116 + fn export_mcp_tool(&self, tool: &McpToolSpan) { 110 117 let mut attributes = vec![ 111 - KeyValue::new("mcp.session.id", entry.session_id.clone()), 118 + KeyValue::new("mcp.session.id", tool.session_id.clone()), 112 119 KeyValue::new("network.transport", "tcp"), 113 120 KeyValue::new("jsonrpc.protocol.version", "2.0"), 121 + 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"), 114 124 ]; 115 125 116 - if let Some(agent) = &entry.agent { 126 + if let Some(agent) = &tool.agent { 117 127 attributes.push(KeyValue::new("opencode.agent", agent.clone())); 118 128 } 119 129 120 - if let Some(model) = &entry.model { 130 + if let Some(model) = &tool.model { 121 131 attributes.push(KeyValue::new("gen_ai.model.name", model.clone())); 122 132 } 123 133 124 - let mut tool_name_str = None; 125 - if let Some(tool_name) = &entry.tool_name { 126 - tool_name_str = Some(tool_name.clone()); 127 - attributes.push(KeyValue::new("gen_ai.tool.name", tool_name.clone())); 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)); 136 + 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)); 128 140 } 129 141 130 - if let Some(mcp_method) = &entry.mcp_method { 131 - attributes.push(KeyValue::new("mcp.method.name", mcp_method.clone())); 132 - attributes.push(KeyValue::new("gen_ai.operation.name", "execute_tool")); 142 + let span_name = format!("tools/call {}", tool.tool_name); 143 + 144 + let attr_count = attributes.len(); 145 + let span_name_clone = span_name.clone(); 146 + 147 + let mut builder = SpanBuilder::from_name(span_name); 148 + builder.span_kind = Some(SpanKind::Client); 149 + builder.attributes = Some(attributes); 150 + 151 + let mut span = self.tracer.build(builder); 152 + span.end(); 153 + 154 + tracing::debug!( 155 + "Created span: {} with {} attributes", 156 + span_name_clone, 157 + attr_count 158 + ); 159 + } 160 + 161 + fn export_chat(&self, chat: &ChatSpan) { 162 + let mut attributes = vec![ 163 + KeyValue::new("mcp.session.id", chat.session_id.clone()), 164 + KeyValue::new("network.transport", "tcp"), 165 + KeyValue::new("jsonrpc.protocol.version", "2.0"), 166 + ]; 167 + 168 + if let Some(agent) = &chat.agent { 169 + attributes.push(KeyValue::new("opencode.agent", agent.clone())); 133 170 } 134 171 135 - if let Some(thinking) = &entry.thinking { 172 + if let Some(model) = &chat.model { 173 + attributes.push(KeyValue::new("gen_ai.model.name", model.clone())); 174 + } 175 + 176 + if let Some(thinking) = &chat.thinking { 136 177 attributes.push(KeyValue::new("opencode.thinking", thinking.clone())); 137 178 } 138 179 139 - attributes.push(KeyValue::new("opencode.content", entry.content.clone())); 180 + if !chat.content.is_empty() { 181 + attributes.push(KeyValue::new("opencode.content", chat.content.clone())); 182 + } 140 183 141 - let span_name = if let Some(tool_name) = &tool_name_str { 142 - format!("tools/call {}", tool_name) 143 - } else { 144 - format!("{} session", entry.role) 145 - }; 184 + let span_name = "chat"; 146 185 147 186 let attr_count = attributes.len(); 148 - let span_name_clone = span_name.clone(); 149 187 150 188 let mut builder = SpanBuilder::from_name(span_name); 151 189 builder.span_kind = Some(SpanKind::Client); ··· 156 194 157 195 tracing::debug!( 158 196 "Created span: {} with {} attributes", 159 - span_name_clone, 197 + span_name, 160 198 attr_count 161 199 ); 162 200 }
+104 -68
src/formatter.rs
··· 1 1 use crate::cli::OutputFormat; 2 - use crate::parser::ParsedLogEntry; 2 + use crate::parser::{ChatSpan, McpToolSpan, ParsedLogEntry}; 3 3 use anyhow::Result; 4 + use serde_json::json; 4 5 5 6 #[derive(Debug, serde::Serialize)] 6 7 pub struct SpanDisplay { 7 8 pub name: String, 8 9 pub kind: String, 9 - pub session_id: String, 10 - pub role: String, 11 - pub agent: Option<String>, 12 - pub model: Option<String>, 13 - pub tool_name: Option<String>, 14 - pub mcp_method: Option<String>, 15 - pub operation_name: Option<String>, 16 - pub timestamp: String, 17 - pub duration_ms: Option<u64>, 18 - pub content: String, 19 - pub thinking: Option<String>, 20 - pub attributes: Vec<(String, String)>, 10 + pub attributes: serde_json::Map<String, serde_json::Value>, 21 11 } 22 12 23 13 impl SpanDisplay { 24 14 pub fn from_entry(entry: &ParsedLogEntry) -> Self { 25 - let mut attributes = vec![ 26 - ("mcp.session.id".to_string(), entry.session_id.clone()), 27 - ("network.transport".to_string(), "tcp".to_string()), 28 - ("jsonrpc.protocol.version".to_string(), "2.0".to_string()), 29 - ]; 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")); 30 28 31 - if let Some(agent) = &entry.agent { 32 - attributes.push(("opencode.agent".to_string(), agent.clone())); 29 + if let Some(agent) = &tool.agent { 30 + attributes.insert("opencode.agent".to_string(), json!(agent)); 33 31 } 34 32 35 - if let Some(model) = &entry.model { 36 - attributes.push(("gen_ai.model.name".to_string(), model.clone())); 33 + if let Some(model) = &tool.model { 34 + attributes.insert("gen_ai.model.name".to_string(), json!(model)); 37 35 } 38 36 39 - let mut tool_name_str = None; 40 - if let Some(tool_name) = &entry.tool_name { 41 - tool_name_str = Some(tool_name.clone()); 42 - attributes.push(("gen_ai.tool.name".to_string(), tool_name.clone())); 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 43 } 44 44 45 - if let Some(mcp_method) = &entry.mcp_method { 46 - attributes.push(("mcp.method.name".to_string(), mcp_method.clone())); 47 - attributes.push(( 48 - "gen_ai.operation.name".to_string(), 49 - "execute_tool".to_string(), 50 - )); 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 51 } 52 + } 52 53 53 - if let Some(thinking) = &entry.thinking { 54 - attributes.push(("opencode.thinking".to_string(), thinking.clone())); 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)); 55 61 } 56 62 57 - attributes.push(("opencode.content".to_string(), entry.content.clone())); 63 + if let Some(model) = &chat.model { 64 + attributes.insert("gen_ai.model.name".to_string(), json!(model)); 65 + } 58 66 59 - let span_name = if let Some(tool_name) = &tool_name_str { 60 - format!("tools/call {}", tool_name) 61 - } else { 62 - format!("{} session", entry.role) 63 - }; 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 + } 64 74 65 75 SpanDisplay { 66 - name: span_name, 76 + name: "chat".to_string(), 67 77 kind: "client".to_string(), 68 - session_id: entry.session_id.clone(), 69 - role: entry.role.clone(), 70 - agent: entry.agent.clone(), 71 - model: entry.model.clone(), 72 - tool_name: entry.tool_name.clone(), 73 - mcp_method: entry.mcp_method.clone(), 74 - operation_name: entry 75 - .mcp_method 76 - .as_ref() 77 - .map(|_| "execute_tool".to_string()), 78 - timestamp: entry.timestamp.to_string(), 79 - duration_ms: entry.duration_ms, 80 - content: entry.content.clone(), 81 - thinking: entry.thinking.clone(), 82 78 attributes, 83 79 } 84 80 } ··· 117 113 118 114 let mut table = Table::new(); 119 115 table 120 - .set_header(vec![ 121 - "Span Name", 122 - "Role", 123 - "Agent", 124 - "Model", 125 - "Tool", 126 - "Method", 127 - ]) 116 + .set_header(vec!["Span Name", "Type", "Tool", "Arguments", "Result"]) 128 117 .load_preset(comfy_table::presets::UTF8_FULL); 129 118 130 119 for span in spans { 120 + let span_type = if span.name.starts_with("tools/call") { 121 + "MCP Tool" 122 + } else { 123 + "Chat" 124 + }; 125 + 126 + let tool_name = span 127 + .attributes 128 + .get("gen_ai.tool.name") 129 + .and_then(|v| v.as_str()) 130 + .unwrap_or("-"); 131 + 132 + let arguments = span 133 + .attributes 134 + .get("gen_ai.tool.call.arguments") 135 + .and_then(|v| { 136 + if v.is_string() { 137 + Some( 138 + v.as_str() 139 + .unwrap_or("") 140 + .chars() 141 + .take(50) 142 + .collect::<String>(), 143 + ) 144 + } else { 145 + Some("[object]".to_string()) 146 + } 147 + }) 148 + .unwrap_or("-".to_string()); 149 + 150 + let result = span 151 + .attributes 152 + .get("gen_ai.tool.call.result") 153 + .and_then(|v| { 154 + if v.is_string() { 155 + Some( 156 + v.as_str() 157 + .unwrap_or("") 158 + .chars() 159 + .take(50) 160 + .collect::<String>(), 161 + ) 162 + } else { 163 + Some("[object]".to_string()) 164 + } 165 + }) 166 + .unwrap_or("-".to_string()); 167 + 131 168 table.add_row(vec![ 132 169 Cell::new(&span.name).fg(Color::Blue), 133 - Cell::new(&span.role).fg(Color::Green), 134 - Cell::new(span.agent.as_deref().unwrap_or("-")).fg(Color::Yellow), 135 - Cell::new(span.model.as_deref().unwrap_or("-")).fg(Color::Cyan), 136 - Cell::new(span.tool_name.as_deref().unwrap_or("-")), 137 - Cell::new(span.mcp_method.as_deref().unwrap_or("-")), 170 + Cell::new(span_type).fg(Color::Green), 171 + Cell::new(tool_name).fg(Color::Yellow), 172 + Cell::new(arguments), 173 + Cell::new(result), 138 174 ]); 139 175 } 140 176
+42 -29
src/parser.rs
··· 35 35 } 36 36 37 37 #[derive(Debug, Clone)] 38 - pub struct ParsedLogEntry { 38 + pub struct McpToolSpan { 39 39 pub session_id: String, 40 - pub role: String, 40 + pub timestamp: Timestamp, 41 41 pub agent: Option<String>, 42 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>, 48 + } 49 + 50 + #[derive(Debug, Clone)] 51 + pub struct ChatSpan { 52 + pub session_id: String, 43 53 pub timestamp: Timestamp, 44 - pub tool_name: Option<String>, 45 - pub mcp_method: Option<String>, 54 + pub agent: Option<String>, 55 + pub model: Option<String>, 46 56 pub content: String, 47 57 pub thinking: Option<String>, 48 - pub duration_ms: Option<u64>, 58 + } 59 + 60 + #[derive(Debug, Clone)] 61 + pub enum ParsedLogEntry { 62 + McpTool(McpToolSpan), 63 + Chat(ChatSpan), 49 64 } 50 65 51 66 pub struct LogParser { ··· 165 180 let mut entries = Vec::new(); 166 181 167 182 for msg in &log.messages { 168 - let mut tool_name = None; 169 - let mut mcp_method = None; 170 - 171 - if let Some(tool_caps) = self.tool_regex.captures(&msg.content) { 172 - tool_name = Some(tool_caps[1].to_string()); 173 - } 174 - 175 183 if let Some(tools) = &msg.tools_used { 176 - if let Some(tool) = tools.first() { 177 - mcp_method = Some(tool.method.clone()); 184 + for tool in tools { 185 + entries.push(ParsedLogEntry::McpTool(McpToolSpan { 186 + session_id: log.session_id.clone(), 187 + 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 + })); 178 196 } 179 197 } 180 198 181 - entries.push(ParsedLogEntry { 182 - session_id: log.session_id.clone(), 183 - role: msg.role.clone(), 184 - agent: msg.agent.clone(), 185 - model: msg.model.clone(), 186 - timestamp: msg.timestamp.unwrap_or_else(|| Timestamp::now()), 187 - tool_name, 188 - mcp_method, 189 - content: msg.content.clone(), 190 - thinking: msg.thinking.clone(), 191 - duration_ms: msg 192 - .tools_used 193 - .as_ref() 194 - .and_then(|t| t.first().and_then(|u| u.duration_ms)), 195 - }); 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 + })); 208 + } 196 209 } 197 210 198 211 entries