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

Configure Feed

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

Implement output formats for span display (exs-out)

- Added serde serialization for span representation with SpanDisplay struct
- Created formatter module with support for json, json-lines, yaml, and table formats
- Added Otlp output format variant as default for backward compatibility
- Updated export command to output to stdout when format is specified
- Added serde_yaml and comfy-table dependencies
- Dry run mode now displays spans instead of just counting

rektide 960ba554 7707bfa0

+286 -13
+129
Cargo.lock
··· 246 246 ] 247 247 248 248 [[package]] 249 + name = "comfy-table" 250 + version = "7.2.2" 251 + source = "registry+https://github.com/rust-lang/crates.io-index" 252 + checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47" 253 + dependencies = [ 254 + "crossterm", 255 + "unicode-segmentation", 256 + "unicode-width", 257 + ] 258 + 259 + [[package]] 260 + name = "crossterm" 261 + version = "0.29.0" 262 + source = "registry+https://github.com/rust-lang/crates.io-index" 263 + checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" 264 + dependencies = [ 265 + "bitflags", 266 + "crossterm_winapi", 267 + "document-features", 268 + "parking_lot", 269 + "rustix", 270 + "winapi", 271 + ] 272 + 273 + [[package]] 274 + name = "crossterm_winapi" 275 + version = "0.9.1" 276 + source = "registry+https://github.com/rust-lang/crates.io-index" 277 + checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" 278 + dependencies = [ 279 + "winapi", 280 + ] 281 + 282 + [[package]] 283 + name = "document-features" 284 + version = "0.2.12" 285 + source = "registry+https://github.com/rust-lang/crates.io-index" 286 + checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" 287 + dependencies = [ 288 + "litrs", 289 + ] 290 + 291 + [[package]] 249 292 name = "either" 250 293 version = "1.15.0" 251 294 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 274 317 "anyhow", 275 318 "clap", 276 319 "colored", 320 + "comfy-table", 277 321 "jiff", 278 322 "opentelemetry", 279 323 "opentelemetry-otlp", ··· 281 325 "regex", 282 326 "serde", 283 327 "serde_json", 328 + "serde_yaml", 284 329 "thiserror", 285 330 "tokio", 286 331 "tonic", ··· 620 665 checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" 621 666 622 667 [[package]] 668 + name = "linux-raw-sys" 669 + version = "0.11.0" 670 + source = "registry+https://github.com/rust-lang/crates.io-index" 671 + checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" 672 + 673 + [[package]] 674 + name = "litrs" 675 + version = "1.0.0" 676 + source = "registry+https://github.com/rust-lang/crates.io-index" 677 + checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" 678 + 679 + [[package]] 623 680 name = "lock_api" 624 681 version = "0.4.14" 625 682 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 971 1028 checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" 972 1029 973 1030 [[package]] 1031 + name = "rustix" 1032 + version = "1.1.3" 1033 + source = "registry+https://github.com/rust-lang/crates.io-index" 1034 + checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" 1035 + dependencies = [ 1036 + "bitflags", 1037 + "errno", 1038 + "libc", 1039 + "linux-raw-sys", 1040 + "windows-sys 0.61.2", 1041 + ] 1042 + 1043 + [[package]] 974 1044 name = "rustversion" 975 1045 version = "1.0.22" 976 1046 source = "registry+https://github.com/rust-lang/crates.io-index" 977 1047 checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" 1048 + 1049 + [[package]] 1050 + name = "ryu" 1051 + version = "1.0.22" 1052 + source = "registry+https://github.com/rust-lang/crates.io-index" 1053 + checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" 978 1054 979 1055 [[package]] 980 1056 name = "scopeguard" ··· 1023 1099 "serde", 1024 1100 "serde_core", 1025 1101 "zmij", 1102 + ] 1103 + 1104 + [[package]] 1105 + name = "serde_yaml" 1106 + version = "0.9.34+deprecated" 1107 + source = "registry+https://github.com/rust-lang/crates.io-index" 1108 + checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" 1109 + dependencies = [ 1110 + "indexmap 2.13.0", 1111 + "itoa", 1112 + "ryu", 1113 + "serde", 1114 + "unsafe-libyaml", 1026 1115 ] 1027 1116 1028 1117 [[package]] ··· 1361 1450 checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" 1362 1451 1363 1452 [[package]] 1453 + name = "unicode-segmentation" 1454 + version = "1.12.0" 1455 + source = "registry+https://github.com/rust-lang/crates.io-index" 1456 + checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" 1457 + 1458 + [[package]] 1459 + name = "unicode-width" 1460 + version = "0.2.2" 1461 + source = "registry+https://github.com/rust-lang/crates.io-index" 1462 + checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" 1463 + 1464 + [[package]] 1465 + name = "unsafe-libyaml" 1466 + version = "0.2.11" 1467 + source = "registry+https://github.com/rust-lang/crates.io-index" 1468 + checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" 1469 + 1470 + [[package]] 1364 1471 name = "utf8parse" 1365 1472 version = "0.2.2" 1366 1473 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1441 1548 "js-sys", 1442 1549 "wasm-bindgen", 1443 1550 ] 1551 + 1552 + [[package]] 1553 + name = "winapi" 1554 + version = "0.3.9" 1555 + source = "registry+https://github.com/rust-lang/crates.io-index" 1556 + checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 1557 + dependencies = [ 1558 + "winapi-i686-pc-windows-gnu", 1559 + "winapi-x86_64-pc-windows-gnu", 1560 + ] 1561 + 1562 + [[package]] 1563 + name = "winapi-i686-pc-windows-gnu" 1564 + version = "0.4.0" 1565 + source = "registry+https://github.com/rust-lang/crates.io-index" 1566 + checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 1567 + 1568 + [[package]] 1569 + name = "winapi-x86_64-pc-windows-gnu" 1570 + version = "0.4.0" 1571 + source = "registry+https://github.com/rust-lang/crates.io-index" 1572 + checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 1444 1573 1445 1574 [[package]] 1446 1575 name = "windows-link"
+2
Cargo.toml
··· 14 14 tonic = { version = "0.12" } 15 15 serde = { version = "1.0", features = ["derive"] } 16 16 serde_json = "1.0" 17 + serde_yaml = "0.9" 17 18 regex = "1.11" 18 19 jiff = { version = "0.2", features = ["serde"] } 19 20 anyhow = "1.0" 20 21 clap = { version = "4", features = ["derive", "env"] } 21 22 colored = "2" 22 23 thiserror = "1.0" 24 + comfy-table = "7"
+3 -1
src/cli.rs
··· 21 21 long, 22 22 global = true, 23 23 value_enum, 24 - default_value_t = OutputFormat::Json, 24 + default_value_t = OutputFormat::Otlp, 25 25 env = "EXP2SPAN_OUTPUT_FORMAT" 26 26 )] 27 27 pub output: OutputFormat, ··· 40 40 41 41 #[derive(ValueEnum, Clone, Copy, Debug, PartialEq)] 42 42 pub enum OutputFormat { 43 + /// Export to OTLP (default behavior) 44 + Otlp, 43 45 /// Pretty-printed JSON output 44 46 Json, 45 47 /// JSON Lines (NDJSON) for streaming
+22 -9
src/commands/export.rs
··· 1 1 use anyhow::{Context, Result}; 2 2 3 - use crate::cli::{ExportArgs, OtlpProtocol}; 3 + use crate::cli::{ExportArgs, OtlpProtocol, OutputFormat}; 4 4 use crate::config::{OtlpConfig, OtlpProtocol as ConfigOtlpProtocol}; 5 5 use crate::parser::LogParser; 6 6 use crate::exporter::OtelExporter; 7 + use crate::formatter; 7 8 8 - pub async fn execute(args: ExportArgs) -> Result<()> { 9 + pub async fn execute(args: ExportArgs, output_format: OutputFormat) -> Result<()> { 10 + let use_stdout = matches!(output_format, OutputFormat::Json | OutputFormat::JsonLines | OutputFormat::Yaml | OutputFormat::Table); 11 + 9 12 let parser = LogParser::new(); 10 13 11 14 let otlp_config = OtlpConfig { ··· 38 41 entries 39 42 }; 40 43 41 - tracing::info!("Dry run mode - would export {} spans", filtered_entries.len()); 44 + tracing::info!("Dry run mode - {} spans would be exported", filtered_entries.len()); 45 + 46 + let output = formatter::format_spans(&filtered_entries, output_format)?; 47 + println!("{}", output); 42 48 return Ok(()); 43 49 } 44 50 45 - let exporter = OtelExporter::new(otlp_config.clone()) 46 - .await 47 - .context("Failed to create OTLP exporter")?; 48 - 49 - tracing::info!("Created OTLP exporter: {}", exporter.get_endpoint()); 50 - 51 51 let log = parser.parse_file(args.file.to_str().unwrap())?; 52 52 let entries = parser.parse_entries(&log); 53 53 ··· 56 56 } else { 57 57 entries 58 58 }; 59 + 60 + if use_stdout { 61 + tracing::info!("Outputting {} spans to stdout in {:?} format", filtered_entries.len(), output_format); 62 + let output = formatter::format_spans(&filtered_entries, output_format)?; 63 + println!("{}", output); 64 + return Ok(()); 65 + } 66 + 67 + let exporter = OtelExporter::new(otlp_config.clone()) 68 + .await 69 + .context("Failed to create OTLP exporter")?; 70 + 71 + tracing::info!("Created OTLP exporter: {}", exporter.get_endpoint()); 59 72 60 73 tracing::info!("Exporting {} spans...", filtered_entries.len()); 61 74
+2 -2
src/commands/mod.rs
··· 9 9 use crate::cli::{ExportArgs, ValidateArgs, InfoArgs, Commands}; 10 10 use anyhow::Result; 11 11 12 - pub async fn execute(cmd: Commands) -> Result<()> { 12 + pub async fn execute(cmd: Commands, output: crate::cli::OutputFormat) -> Result<()> { 13 13 match cmd { 14 - Commands::Export(args) => export_execute(args).await, 14 + Commands::Export(args) => export_execute(args, output).await, 15 15 Commands::Validate(args) => validate_execute(args), 16 16 Commands::Info(args) => info_execute(args), 17 17 }
+126
src/formatter.rs
··· 1 + use crate::cli::OutputFormat; 2 + use crate::parser::ParsedLogEntry; 3 + use anyhow::Result; 4 + 5 + #[derive(Debug, serde::Serialize)] 6 + pub struct SpanDisplay { 7 + pub name: String, 8 + pub kind: String, 9 + pub session_id: String, 10 + pub role: String, 11 + pub model: Option<String>, 12 + pub tool_name: Option<String>, 13 + pub mcp_method: Option<String>, 14 + pub operation_name: Option<String>, 15 + pub timestamp: String, 16 + pub duration_ms: Option<u64>, 17 + pub content_preview: String, 18 + pub attributes: Vec<(String, String)>, 19 + } 20 + 21 + impl SpanDisplay { 22 + pub fn from_entry(entry: &ParsedLogEntry) -> Self { 23 + let content_preview = if entry.content.len() > 100 { 24 + format!("{}...", &entry.content[..100]) 25 + } else { 26 + entry.content.clone() 27 + }; 28 + 29 + let mut attributes = vec![ 30 + ("mcp.session.id".to_string(), entry.session_id.clone()), 31 + ("network.transport".to_string(), "tcp".to_string()), 32 + ("jsonrpc.protocol.version".to_string(), "2.0".to_string()), 33 + ]; 34 + 35 + if let Some(model) = &entry.model { 36 + attributes.push(("gen_ai.model.name".to_string(), model.clone())); 37 + } 38 + 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())); 43 + } 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 + )); 51 + } 52 + 53 + let span_name = if let Some(tool_name) = &tool_name_str { 54 + format!("tools/call {}", tool_name) 55 + } else { 56 + format!("{} session", entry.role) 57 + }; 58 + 59 + SpanDisplay { 60 + name: span_name, 61 + kind: "client".to_string(), 62 + session_id: entry.session_id.clone(), 63 + role: entry.role.clone(), 64 + model: entry.model.clone(), 65 + tool_name: entry.tool_name.clone(), 66 + mcp_method: entry.mcp_method.clone(), 67 + operation_name: entry 68 + .mcp_method 69 + .as_ref() 70 + .map(|_| "execute_tool".to_string()), 71 + timestamp: entry.timestamp.to_string(), 72 + duration_ms: entry.duration_ms, 73 + content_preview, 74 + attributes, 75 + } 76 + } 77 + } 78 + 79 + pub fn format_spans(entries: &[ParsedLogEntry], format: OutputFormat) -> Result<String> { 80 + let spans: Vec<SpanDisplay> = entries.iter().map(SpanDisplay::from_entry).collect(); 81 + 82 + match format { 83 + OutputFormat::Otlp => Err(anyhow::anyhow!("Otlp format should not use formatter")), 84 + OutputFormat::Json => format_json(&spans), 85 + OutputFormat::JsonLines => format_json_lines(&spans), 86 + OutputFormat::Yaml => format_yaml(&spans), 87 + OutputFormat::Table => format_table(&spans), 88 + } 89 + } 90 + 91 + fn format_json(spans: &[SpanDisplay]) -> Result<String> { 92 + serde_json::to_string_pretty(spans).map_err(Into::into) 93 + } 94 + 95 + fn format_json_lines(spans: &[SpanDisplay]) -> Result<String> { 96 + Ok(spans 97 + .iter() 98 + .map(|span| serde_json::to_string(span).map_err(Into::into)) 99 + .collect::<Result<Vec<String>>>()? 100 + .join("\n")) 101 + } 102 + 103 + fn format_yaml(spans: &[SpanDisplay]) -> Result<String> { 104 + serde_yaml::to_string(spans).map_err(Into::into) 105 + } 106 + 107 + fn format_table(spans: &[SpanDisplay]) -> Result<String> { 108 + use comfy_table::{Cell, Color, Table}; 109 + 110 + let mut table = Table::new(); 111 + table 112 + .set_header(vec!["Span Name", "Role", "Tool", "Method", "Model"]) 113 + .load_preset(comfy_table::presets::UTF8_FULL); 114 + 115 + for span in spans { 116 + table.add_row(vec![ 117 + Cell::new(&span.name).fg(Color::Blue), 118 + Cell::new(&span.role).fg(Color::Green), 119 + Cell::new(span.tool_name.as_deref().unwrap_or("-")), 120 + Cell::new(span.mcp_method.as_deref().unwrap_or("-")), 121 + Cell::new(span.model.as_deref().unwrap_or("-")), 122 + ]); 123 + } 124 + 125 + Ok(table.to_string()) 126 + }
+1
src/lib.rs
··· 3 3 pub mod config; 4 4 pub mod parser; 5 5 pub mod exporter; 6 + pub mod formatter; 6 7 7 8 pub use cli::{Cli, Commands, OutputFormat};
+1 -1
src/main.rs
··· 13 13 setup_logging(&cli); 14 14 15 15 // Execute command 16 - exp2span::commands::execute(cli.command).await 16 + exp2span::commands::execute(cli.command, cli.output).await 17 17 } 18 18 19 19 fn setup_logging(cli: &Cli) {