diff --git a/ghostscope-ui/src/components/ebpf_panel/renderer.rs b/ghostscope-ui/src/components/ebpf_panel/renderer.rs index 8912970..3f0da3a 100644 --- a/ghostscope-ui/src/components/ebpf_panel/renderer.rs +++ b/ghostscope-ui/src/components/ebpf_panel/renderer.rs @@ -435,6 +435,18 @@ impl EbpfPanelRenderer { ) -> Vec> { match item { TraceDisplayItem::Text { content } => Self::render_text_item(content, content_width), + TraceDisplayItem::FormattedText { content } => { + Self::render_text_item(content, content_width) + } + TraceDisplayItem::Variable(variable) => { + Self::render_text_item(&variable.to_formatted_output(), content_width) + } + TraceDisplayItem::ComplexVariable(variable) => { + Self::render_text_item(&variable.to_formatted_output(), content_width) + } + TraceDisplayItem::ExprError(error) => { + Self::render_text_item(&error.to_formatted_output(), content_width) + } TraceDisplayItem::Backtrace(backtrace) => Self::render_backtrace_item( backtrace, matches!(view_mode, EbpfViewMode::Expanded { .. }), diff --git a/ghostscope-ui/src/events.rs b/ghostscope-ui/src/events.rs index 09002af..35cedfb 100644 --- a/ghostscope-ui/src/events.rs +++ b/ghostscope-ui/src/events.rs @@ -1,16 +1,16 @@ use crossterm::event::{KeyEvent, MouseEvent}; use ghostscope_protocol::{ trace_event::{backtrace_error_label, BacktraceStatus}, - ParsedTraceEvent, + ParsedInstruction, ParsedTraceEvent, }; use tokio::sync::mpsc; use unicode_width::UnicodeWidthStr; -/// Runtime trace event after conversion into TUI display items. +/// Runtime trace event after conversion into display items. /// /// This keeps the UI transport structured without changing the eBPF/protocol -/// wire format. Non-backtrace instructions are grouped into text lines, while -/// backtraces keep frame/status fields for dedicated TUI rendering. +/// wire format. Backtraces, variables, and runtime expression errors keep their +/// fields for dedicated CLI/TUI rendering. #[derive(Debug, Clone)] pub struct UiTraceEvent { pub trace_id: u64, @@ -39,11 +39,7 @@ impl UiTraceEvent { timestamp: event.timestamp, pid: event.pid, tid: event.tid, - items: event - .to_formatted_output() - .into_iter() - .map(|content| TraceDisplayItem::Text { content }) - .collect(), + items: protocol_instructions_to_display_items(&event.instructions), execution_status, } } @@ -76,13 +72,13 @@ impl UiTraceEvent { pub fn is_error(&self) -> bool { self.execution_status .is_some_and(|status| status == 1 || status == 2) - || self.items.iter().any(|item| { - matches!( - item, - TraceDisplayItem::Backtrace(backtrace) - if backtrace.status != BacktraceStatus::Complete - && backtrace.status != BacktraceStatus::Truncated - ) + || self.items.iter().any(|item| match item { + TraceDisplayItem::ExprError(_) => true, + TraceDisplayItem::Backtrace(backtrace) => { + backtrace.status != BacktraceStatus::Complete + && backtrace.status != BacktraceStatus::Truncated + } + _ => false, }) } } @@ -90,6 +86,10 @@ impl UiTraceEvent { #[derive(Debug, Clone)] pub enum TraceDisplayItem { Text { content: String }, + FormattedText { content: String }, + Variable(VariableDisplay), + ComplexVariable(ComplexVariableDisplay), + ExprError(ExprErrorDisplay), Backtrace(BacktraceDisplay), } @@ -97,11 +97,247 @@ impl TraceDisplayItem { pub fn to_formatted_output(&self) -> Vec { match self { Self::Text { content } => vec![content.clone()], + Self::FormattedText { content } => vec![content.clone()], + Self::Variable(variable) => vec![variable.to_formatted_output()], + Self::ComplexVariable(variable) => vec![variable.to_formatted_output()], + Self::ExprError(error) => vec![error.to_formatted_output()], Self::Backtrace(backtrace) => backtrace.to_formatted_output(), } } } +#[derive(Debug, Clone)] +pub struct VariableDisplay { + pub name: String, + pub type_name: String, + pub formatted_value: String, +} + +impl VariableDisplay { + pub fn to_formatted_output(&self) -> String { + format!( + "{} ({}): {}", + self.name, self.type_name, self.formatted_value + ) + } +} + +#[derive(Debug, Clone)] +pub struct ComplexVariableDisplay { + pub name: String, + pub access_path: String, + pub type_index: u16, + pub formatted_value: String, +} + +impl ComplexVariableDisplay { + pub fn display_name(&self) -> &str { + if self.access_path.is_empty() { + &self.name + } else { + &self.access_path + } + } + + pub fn to_formatted_output(&self) -> String { + self.formatted_value.clone() + } +} + +#[derive(Debug, Clone)] +pub struct ExprErrorDisplay { + pub expr: String, + pub error_code: u8, + pub flags: u8, + pub failing_addr: u64, +} + +impl ExprErrorDisplay { + pub fn reason(&self) -> &'static str { + match self.error_code { + 1 => "null deref", + 2 => "read error", + 3 => "access error", + 4 => "truncated", + 5 => "offsets unavailable", + 6 => "zero length", + _ => "error", + } + } + + pub fn readable_flags(&self) -> Option { + if self.flags == 0 { + return None; + } + let mut tags: Vec<&'static str> = Vec::new(); + let is_memcmp = self.expr.contains("memcmp("); + let is_strncmp = self.expr.contains("strncmp(") || self.expr.contains("starts_with("); + if is_memcmp { + if (self.flags & 0x01) != 0 { + tags.push("first-arg read-fail"); + } + if (self.flags & 0x02) != 0 { + tags.push("second-arg read-fail"); + } + if (self.flags & 0x04) != 0 { + tags.push("len-clamped"); + } + if (self.flags & 0x08) != 0 { + tags.push("len=0"); + } + } else if is_strncmp { + if (self.flags & 0x01) != 0 { + tags.push("read-fail"); + } + if (self.flags & 0x04) != 0 { + tags.push("len-clamped"); + } + if (self.flags & 0x08) != 0 { + tags.push("len=0"); + } + } else { + return Some(format!("0x{:02x}", self.flags)); + } + + (!tags.is_empty()).then(|| tags.join(",")) + } + + pub fn addr_text(&self) -> String { + if self.failing_addr != 0 { + format!("at 0x{:016x}", self.failing_addr) + } else { + "at NULL".to_string() + } + } + + pub fn to_formatted_output(&self) -> String { + let base = format!( + "ExprError: {} ({} {}", + self.expr, + self.reason(), + self.addr_text() + ); + match self.readable_flags() { + Some(flags) => format!("{base}, flags: {flags})"), + None => format!("{base})"), + } + } +} + +fn protocol_instructions_to_display_items( + instructions: &[ParsedInstruction], +) -> Vec { + let mut items = Vec::new(); + let mut index = 0usize; + + while index < instructions.len() { + match &instructions[index] { + ParsedInstruction::PrintString { content } => { + if content.contains("{}") { + let (formatted, consumed) = + format_string_with_variable_items(content, instructions, index + 1); + items.push(TraceDisplayItem::FormattedText { content: formatted }); + index += consumed; + } else { + items.push(TraceDisplayItem::Text { + content: content.clone(), + }); + index += 1; + } + } + ParsedInstruction::PrintVariable { + name, + type_encoding, + formatted_value, + .. + } => { + items.push(TraceDisplayItem::Variable(VariableDisplay { + name: name.clone(), + type_name: format!("{type_encoding:?}"), + formatted_value: formatted_value.clone(), + })); + index += 1; + } + ParsedInstruction::ExprError { + expr, + error_code, + flags, + failing_addr, + } => { + items.push(TraceDisplayItem::ExprError(ExprErrorDisplay { + expr: expr.clone(), + error_code: *error_code, + flags: *flags, + failing_addr: *failing_addr, + })); + index += 1; + } + ParsedInstruction::PrintComplexFormat { formatted_output } => { + items.push(TraceDisplayItem::FormattedText { + content: formatted_output.clone(), + }); + index += 1; + } + ParsedInstruction::PrintComplexVariable { + name, + access_path, + type_index, + formatted_value, + .. + } => { + items.push(TraceDisplayItem::ComplexVariable(ComplexVariableDisplay { + name: name.clone(), + access_path: access_path.clone(), + type_index: *type_index, + formatted_value: formatted_value.clone(), + })); + index += 1; + } + ParsedInstruction::Backtrace { .. } => { + index += 1; + } + ParsedInstruction::EndInstruction { .. } => { + index += 1; + } + } + } + + items +} + +fn format_string_with_variable_items( + format_string: &str, + instructions: &[ParsedInstruction], + start_index: usize, +) -> (String, usize) { + let placeholder_count = format_string.matches("{}").count(); + let mut consumed = 1; + let mut result = String::with_capacity(format_string.len()); + let mut remaining = format_string; + + for instruction_index in start_index..(start_index + placeholder_count).min(instructions.len()) + { + let Some(pos) = remaining.find("{}") else { + break; + }; + + if let Some(ParsedInstruction::PrintVariable { + formatted_value, .. + }) = instructions.get(instruction_index) + { + result.push_str(&remaining[..pos]); + result.push_str(formatted_value); + consumed += 1; + remaining = &remaining[pos + 2..]; + } else { + break; + } + } + result.push_str(remaining); + + (result, consumed) +} + #[derive(Debug, Clone)] pub struct BacktraceDisplay { pub requested_depth: u8, @@ -1489,6 +1725,7 @@ impl RuntimeStatus { #[cfg(test)] mod tests { use super::*; + use ghostscope_protocol::{ParsedInstruction, ParsedTraceEvent, TypeKind}; fn sample_event(trace_id: u64) -> UiTraceEvent { UiTraceEvent { @@ -1509,6 +1746,86 @@ mod tests { channels.trace_sender.try_send(sample_event(1)).unwrap(); assert!(channels.trace_sender.try_send(sample_event(2)).is_err()); } + + #[test] + fn protocol_event_preserves_structured_print_items() { + let event = ParsedTraceEvent { + trace_id: 7, + timestamp: 11, + pid: 42, + tid: 43, + instructions: vec![ + ParsedInstruction::PrintString { + content: "value={}".to_string(), + }, + ParsedInstruction::PrintVariable { + name: "counter".to_string(), + type_encoding: TypeKind::U64, + formatted_value: "99".to_string(), + raw_data: Vec::new(), + }, + ParsedInstruction::PrintVariable { + name: "standalone".to_string(), + type_encoding: TypeKind::I32, + formatted_value: "-1".to_string(), + raw_data: Vec::new(), + }, + ParsedInstruction::PrintComplexVariable { + name: "req".to_string(), + access_path: "req.method".to_string(), + type_index: 12, + formatted_value: "req.method = GET".to_string(), + raw_data: Vec::new(), + }, + ParsedInstruction::ExprError { + expr: "memcmp(buf, hex(\"41\"), 1)".to_string(), + error_code: 2, + flags: 0x01, + failing_addr: 0x1234, + }, + ParsedInstruction::EndInstruction { + total_instructions: 5, + execution_status: 1, + }, + ], + }; + + let display = UiTraceEvent::from_protocol_event(&event); + assert_eq!(display.items.len(), 4); + assert!(matches!( + &display.items[0], + TraceDisplayItem::FormattedText { content } if content == "value=99" + )); + assert!(matches!( + &display.items[1], + TraceDisplayItem::Variable(variable) + if variable.name == "standalone" + && variable.type_name == "I32" + && variable.formatted_value == "-1" + )); + assert!(matches!( + &display.items[2], + TraceDisplayItem::ComplexVariable(variable) + if variable.display_name() == "req.method" + && variable.to_formatted_output() == "req.method = GET" + )); + assert!(matches!( + &display.items[3], + TraceDisplayItem::ExprError(error) + if error.reason() == "read error" + && error.readable_flags().as_deref() == Some("first-arg read-fail") + )); + assert!(display.is_error()); + assert_eq!( + display.to_formatted_output(), + vec![ + "value=99".to_string(), + "standalone (I32): -1".to_string(), + "req.method = GET".to_string(), + "ExprError: memcmp(buf, hex(\"41\"), 1) (read error at 0x0000000000001234, flags: first-arg read-fail)".to_string(), + ] + ); + } } /// Source path information for display (shared between UI and runtime) diff --git a/ghostscope-ui/src/lib.rs b/ghostscope-ui/src/lib.rs index 24548a3..268206a 100644 --- a/ghostscope-ui/src/lib.rs +++ b/ghostscope-ui/src/lib.rs @@ -11,8 +11,9 @@ pub mod utils; pub use action::{Action, PanelType}; pub use components::App; pub use events::{ - BacktraceDisplay, BacktraceDisplayFrame, EventRegistry, RuntimeChannels, RuntimeCommand, - RuntimeStatus, TraceDisplayItem, TuiEvent, UiTraceEvent, + BacktraceDisplay, BacktraceDisplayFrame, ComplexVariableDisplay, EventRegistry, + ExprErrorDisplay, RuntimeChannels, RuntimeCommand, RuntimeStatus, TraceDisplayItem, TuiEvent, + UiTraceEvent, VariableDisplay, }; pub use model::ui_state::{HistoryConfig, LayoutMode, UiConfig}; diff --git a/ghostscope/src/cli/script_output.rs b/ghostscope/src/cli/script_output.rs index 87c0d0f..9b9aa87 100644 --- a/ghostscope/src/cli/script_output.rs +++ b/ghostscope/src/cli/script_output.rs @@ -1,5 +1,10 @@ use crate::config::{ScriptOutputMode, ScriptTimestampFormat}; +#[cfg(test)] use ghostscope_protocol::ParsedTraceEvent; +use ghostscope_ui::{ + BacktraceDisplay, BacktraceDisplayFrame, ComplexVariableDisplay, ExprErrorDisplay, + TraceDisplayItem, UiTraceEvent, VariableDisplay, +}; use std::io::{self, Write}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -60,50 +65,84 @@ impl ScriptOutputRenderer { } } - pub fn write_event( + #[cfg(test)] + pub fn render_display_event_lines(&mut self, event: &UiTraceEvent) -> Vec { + match self.mode { + ScriptOutputMode::Plain => event.to_formatted_output(), + ScriptOutputMode::Pretty => { + if event.items.is_empty() { + return Vec::new(); + } + + let mut lines = Vec::new(); + lines.push(self.render_pretty_display_header(event)); + for item in &event.items { + self.render_pretty_display_item(item, &mut lines); + } + lines + } + } + } + + pub fn write_display_event( &mut self, - event: &ParsedTraceEvent, + event: &UiTraceEvent, writer: &mut W, ) -> io::Result { match self.mode { ScriptOutputMode::Plain => { let mut wrote = false; - event.try_for_each_formatted_output(|line| { + for line in event.to_formatted_output() { wrote = true; - writeln!(writer, "{line}") - })?; + writeln!(writer, "{line}")?; + } Ok(wrote) } ScriptOutputMode::Pretty => { - if !event.has_formatted_output() { + if event.items.is_empty() { return Ok(false); } - writeln!(writer, "{}", self.render_pretty_header(event))?; - event.try_for_each_formatted_output(|line| { - writeln!(writer, "{}", self.render_pretty_payload_line(line)) - })?; + writeln!(writer, "{}", self.render_pretty_display_header(event))?; + for item in &event.items { + self.write_pretty_display_item(item, writer)?; + } Ok(true) } } } + #[cfg(test)] fn render_pretty_header(&mut self, event: &ParsedTraceEvent) -> String { + self.render_pretty_header_fields(event.timestamp, event.trace_id, event.pid, event.tid) + } + + fn render_pretty_display_header(&mut self, event: &UiTraceEvent) -> String { + self.render_pretty_header_fields(event.timestamp, event.trace_id, event.pid, event.tid) + } + + fn render_pretty_header_fields( + &mut self, + timestamp: u64, + trace_id: u64, + pid: u32, + tid: u32, + ) -> String { let metadata = format!( "{}:{} {}:{} {}:{}", self.colors.cyan("TraceID"), - event.trace_id, + trace_id, self.colors.cyan("PID"), - event.pid, + pid, self.colors.cyan("TID"), - event.tid + tid ); match self .pretty_timestamp .as_mut() .expect("pretty timestamp formatter must exist for pretty mode") - .format(event.timestamp) + .format(timestamp) { Some(timestamp) => format!("{} {metadata}", self.colors.dim(format!("[{timestamp}]"))), None => metadata, @@ -126,12 +165,201 @@ impl ScriptOutputRenderer { return colored; } if line.starts_with("stopped: ") { - return self.colors.red(line); + return self.colorize_stopped_line(line); } line.to_string() } + #[cfg(test)] + fn render_pretty_display_item(&self, item: &TraceDisplayItem, lines: &mut Vec) { + match item { + TraceDisplayItem::Text { content } => { + lines.push(self.render_pretty_payload_line(content)); + } + TraceDisplayItem::FormattedText { content } => { + lines.push(self.render_pretty_payload_line(content)); + } + TraceDisplayItem::Variable(variable) => { + lines.push(self.render_pretty_variable(variable)); + } + TraceDisplayItem::ComplexVariable(variable) => { + lines.push(self.render_pretty_complex_variable(variable)); + } + TraceDisplayItem::ExprError(error) => { + lines.push(self.render_pretty_expr_error(error)); + } + TraceDisplayItem::Backtrace(backtrace) => { + lines.push(self.render_pretty_backtrace_header(backtrace)); + lines.extend( + backtrace + .frames + .iter() + .map(|frame| self.render_pretty_backtrace_frame(frame)), + ); + if let Some(stopped) = backtrace.stopped_text() { + lines.push(format!(" {}", self.colorize_stopped_line(&stopped))); + } + } + } + } + + fn write_pretty_display_item( + &self, + item: &TraceDisplayItem, + writer: &mut W, + ) -> io::Result<()> { + match item { + TraceDisplayItem::Text { content } => { + writeln!(writer, "{}", self.render_pretty_payload_line(content)) + } + TraceDisplayItem::FormattedText { content } => { + writeln!(writer, "{}", self.render_pretty_payload_line(content)) + } + TraceDisplayItem::Variable(variable) => { + writeln!(writer, "{}", self.render_pretty_variable(variable)) + } + TraceDisplayItem::ComplexVariable(variable) => { + writeln!(writer, "{}", self.render_pretty_complex_variable(variable)) + } + TraceDisplayItem::ExprError(error) => { + writeln!(writer, "{}", self.render_pretty_expr_error(error)) + } + TraceDisplayItem::Backtrace(backtrace) => { + writeln!(writer, "{}", self.render_pretty_backtrace_header(backtrace))?; + for frame in &backtrace.frames { + writeln!(writer, "{}", self.render_pretty_backtrace_frame(frame))?; + } + if let Some(stopped) = backtrace.stopped_text() { + writeln!(writer, " {}", self.colorize_stopped_line(&stopped))?; + } + Ok(()) + } + } + } + + fn render_pretty_variable(&self, variable: &VariableDisplay) -> String { + format!( + " {} ({}): {}", + self.colors.cyan(&variable.name), + self.colors.dim(&variable.type_name), + self.colors.green(&variable.formatted_value) + ) + } + + fn render_pretty_complex_variable(&self, variable: &ComplexVariableDisplay) -> String { + let line = variable.to_formatted_output(); + let display_name = variable.display_name(); + let Some(tail) = line.strip_prefix(display_name) else { + return self.render_pretty_payload_line(&line); + }; + + format!( + " {}{}", + self.colors.cyan(display_name), + self.colorize_complex_variable_tail(tail) + ) + } + + fn colorize_complex_variable_tail(&self, tail: &str) -> String { + if let Some(value) = tail.strip_prefix(" = ") { + format!(" = {}", self.colors.green(value)) + } else { + tail.to_string() + } + } + + fn render_pretty_expr_error(&self, error: &ExprErrorDisplay) -> String { + let mut line = format!( + " {} {} ({} {}", + self.colors.red("ExprError:"), + self.colors.yellow(&error.expr), + self.colors.red(error.reason()), + self.colors.dim(error.addr_text()) + ); + if let Some(flags) = error.readable_flags() { + line.push_str(", flags: "); + line.push_str(&self.colors.dim(flags)); + } + line.push(')'); + line + } + + fn render_pretty_backtrace_header(&self, backtrace: &BacktraceDisplay) -> String { + let frame_word = if backtrace.physical_frame_count == 1 { + "frame" + } else { + "frames" + }; + format!( + " {}: {}, {} {} (max {})", + self.colors.bold("backtrace"), + self.colorize_backtrace_status(backtrace.status.label()), + backtrace.physical_frame_count, + frame_word, + backtrace.requested_depth + ) + } + + fn render_pretty_backtrace_frame(&self, frame: &BacktraceDisplayFrame) -> String { + let mut line = String::from(" "); + let mut frame_label = format!("#{}", frame.index); + if frame.inline { + frame_label.push_str(".inline"); + } + line.push_str(&self.colors.blue(frame_label)); + line.push(' '); + + match (&frame.function, &frame.address) { + (Some(function), _) => { + line.push_str(&self.colors.bold(function)); + if !frame.parameters.is_empty() { + line.push_str( + &self + .colors + .magenta(format!("({})", frame.parameters.join(", "))), + ); + } + } + (None, Some(address)) => line.push_str(address), + (None, None) => line.push_str(&self.colors.bold("")), + } + + if let Some(location) = &frame.location { + line.push_str(" at "); + line.push_str(&self.colors.dim(location)); + } else if frame.function.is_some() { + line.push_str(" at "); + line.push_str(&self.colors.dim("??")); + } + + line.push(' '); + line.push_str(&self.colors.yellow(format!("[{}]", frame.module))); + + if let Some(raw_ip) = frame.raw_ip { + line.push(' '); + line.push_str(&self.colors.dim(format!("raw=0x{raw_ip:x}"))); + } + if let Some(cookie) = frame.cookie { + line.push(' '); + line.push_str(&self.colors.dim(format!("cookie=0x{cookie:016x}"))); + } + if let Some(flags) = frame.flags { + line.push(' '); + line.push_str(&self.colors.dim(format!("flags=0x{flags:x}"))); + } + + line + } + + fn colorize_stopped_line(&self, line: &str) -> String { + if self.colors.enabled() { + self.colors.red(line) + } else { + line.to_string() + } + } + fn colorize_backtrace_header(&self, line: &str) -> Option { let rest = line.strip_prefix("backtrace: ")?; let (status, tail) = rest.split_once(',')?; @@ -346,7 +574,11 @@ mod tests { ScriptOutputRenderer, }; use crate::config::{ScriptOutputMode, ScriptTimestampFormat}; - use ghostscope_protocol::{ParsedInstruction, ParsedTraceEvent}; + use ghostscope_protocol::{trace_event::BacktraceStatus, ParsedInstruction, ParsedTraceEvent}; + use ghostscope_ui::{ + BacktraceDisplay, BacktraceDisplayFrame, ComplexVariableDisplay, ExprErrorDisplay, + TraceDisplayItem, UiTraceEvent, VariableDisplay, + }; fn sample_event() -> ParsedTraceEvent { ParsedTraceEvent { @@ -411,11 +643,102 @@ mod tests { } } + fn sample_backtrace_display_event() -> UiTraceEvent { + UiTraceEvent { + trace_id: 10, + timestamp: 4_000_000_000, + pid: 7001, + tid: 7002, + items: vec![ + TraceDisplayItem::Text { + content: "nginx ngx_http_process_request".to_string(), + }, + TraceDisplayItem::Backtrace(BacktraceDisplay { + requested_depth: 128, + physical_frame_count: 1, + status: BacktraceStatus::Complete, + error_code: 0, + raw: false, + frames: vec![ + BacktraceDisplayFrame { + index: 0, + inline: false, + function: Some("ngx_http_process_request".to_string()), + parameters: vec!["ngx_http_request_s* r".to_string()], + address: None, + location: Some("/tmp/ngx_http_request.c:2054:1".to_string()), + module: "nginx+0x16e233".to_string(), + raw_ip: None, + cookie: None, + flags: None, + }, + BacktraceDisplayFrame { + index: 1, + inline: false, + function: None, + parameters: Vec::new(), + address: Some("0x7f001234".to_string()), + location: None, + module: "libc.so.6+0x2a1ca".to_string(), + raw_ip: Some(0x7f001234), + cookie: Some(0xfeed_beef), + flags: Some(0x8000), + }, + ], + }), + ], + execution_status: Some(0), + } + } + + fn sample_structured_print_display_event() -> UiTraceEvent { + UiTraceEvent { + trace_id: 11, + timestamp: 5_000_000_000, + pid: 8001, + tid: 8002, + items: vec![ + TraceDisplayItem::Text { + content: "plain text".to_string(), + }, + TraceDisplayItem::FormattedText { + content: "value=99".to_string(), + }, + TraceDisplayItem::Variable(VariableDisplay { + name: "counter".to_string(), + type_name: "U64".to_string(), + formatted_value: "99".to_string(), + }), + TraceDisplayItem::ComplexVariable(ComplexVariableDisplay { + name: "req".to_string(), + access_path: "req.method".to_string(), + type_index: 12, + formatted_value: "req.method = GET".to_string(), + }), + TraceDisplayItem::ExprError(ExprErrorDisplay { + expr: "memcmp(buf, hex(\"41\"), 1)".to_string(), + error_code: 2, + flags: 0x01, + failing_addr: 0x1234, + }), + ], + execution_status: Some(1), + } + } + fn render_with_renderer(event: &ParsedTraceEvent, options: ScriptOutputOptions) -> Vec { let mut renderer = ScriptOutputRenderer::new(options); renderer.render_event_lines(event) } + fn render_display_with_renderer( + event: &UiTraceEvent, + options: ScriptOutputOptions, + ) -> Vec { + let mut renderer = ScriptOutputRenderer::new(options); + renderer.render_display_event_lines(event) + } + #[test] fn pretty_output_includes_boot_timestamp_and_metadata() { let lines = render_with_renderer( @@ -568,6 +891,112 @@ mod tests { ); } + #[test] + fn pretty_output_renders_structured_backtrace_items() { + let colored = render_display_with_renderer( + &sample_backtrace_display_event(), + ScriptOutputOptions { + mode: ScriptOutputMode::Pretty, + timestamp: ScriptTimestampFormat::None, + color_enabled: true, + }, + ); + + assert!( + colored + .iter() + .any(|line| line.contains("\u{1b}[1mbacktrace\u{1b}[0m")), + "expected structured backtrace header color: {colored:?}" + ); + assert!( + colored.iter().any(|line| line.contains( + "\u{1b}[1mngx_http_process_request\u{1b}[0m\u{1b}[35m(ngx_http_request_s* r)\u{1b}[0m" + )), + "expected function and parameters to be colored separately: {colored:?}" + ); + assert!( + colored + .iter() + .any(|line| line.contains("\u{1b}[33m[libc.so.6+0x2a1ca]\u{1b}[0m")), + "expected module to be colored from structured field: {colored:?}" + ); + assert!( + colored + .iter() + .any(|line| line.contains("\u{1b}[2mraw=0x7f001234\u{1b}[0m")), + "expected raw metadata to be dimmed from structured field: {colored:?}" + ); + + let plain = render_display_with_renderer( + &sample_backtrace_display_event(), + ScriptOutputOptions { + mode: ScriptOutputMode::Plain, + timestamp: ScriptTimestampFormat::None, + color_enabled: true, + }, + ); + assert!( + plain.iter().all(|line| !line.contains("\u{1b}[")), + "plain structured output must not colorize payload: {plain:?}" + ); + assert_eq!( + plain[1], + "backtrace: complete, 1 frame (max 128)".to_string() + ); + } + + #[test] + fn pretty_output_renders_structured_print_items() { + let colored = render_display_with_renderer( + &sample_structured_print_display_event(), + ScriptOutputOptions { + mode: ScriptOutputMode::Pretty, + timestamp: ScriptTimestampFormat::None, + color_enabled: true, + }, + ); + + assert!( + colored + .iter() + .any(|line| line.contains("\u{1b}[36mcounter\u{1b}[0m")), + "expected variable name color: {colored:?}" + ); + assert!( + colored + .iter() + .any(|line| line.contains("\u{1b}[2mU64\u{1b}[0m")), + "expected variable type color: {colored:?}" + ); + assert!( + colored + .iter() + .any(|line| line.contains("\u{1b}[36mreq.method\u{1b}[0m")), + "expected complex variable label color: {colored:?}" + ); + assert!( + colored + .iter() + .any(|line| line.contains("\u{1b}[31mExprError:\u{1b}[0m")), + "expected expression error label color: {colored:?}" + ); + + let plain = render_display_with_renderer( + &sample_structured_print_display_event(), + ScriptOutputOptions { + mode: ScriptOutputMode::Plain, + timestamp: ScriptTimestampFormat::None, + color_enabled: true, + }, + ); + assert!( + plain.iter().all(|line| !line.contains("\u{1b}[")), + "plain structured print output must not colorize payload: {plain:?}" + ); + assert_eq!(plain[2], "counter (U64): 99"); + assert_eq!(plain[3], "req.method = GET"); + } + #[test] fn backtrace_signature_colorizer_splits_trailing_parameter_list() { assert_eq!( diff --git a/ghostscope/src/cli/script_runtime.rs b/ghostscope/src/cli/script_runtime.rs index 50d62a5..a181f3b 100644 --- a/ghostscope/src/cli/script_runtime.rs +++ b/ghostscope/src/cli/script_runtime.rs @@ -409,19 +409,19 @@ async fn run_cli_with_session( ) { ScriptOutputRateDecision::Silent => {} ScriptOutputRateDecision::Render => { - let rendered_event = { + let display_event = { let coordinator = session .coordinator .lock() .expect("coordinator mutex poisoned"); - backtrace_renderer.render_event_backtraces( + backtrace_renderer.render_event_for_tui( &event, session.process_analyzer.as_ref(), &coordinator, session.proc_pid(), ) }; - match output_renderer.write_event(&rendered_event, &mut stdout) { + match output_renderer.write_display_event(&display_event, &mut stdout) { Ok(wrote) => wrote_output |= wrote, Err(e) => warn!("Failed to write event output: {e}"), } diff --git a/ghostscope/src/trace/backtrace.rs b/ghostscope/src/trace/backtrace.rs index 893b28e..a16040f 100644 --- a/ghostscope/src/trace/backtrace.rs +++ b/ghostscope/src/trace/backtrace.rs @@ -1,7 +1,9 @@ use ghostscope_dwarf::{DwarfAnalyzer, FunctionParameter, ModuleAddress, PcContext}; use ghostscope_process::{PidOffsetsEntry, ProcessManager}; +#[cfg(test)] +use ghostscope_protocol::trace_event::backtrace_error_label; use ghostscope_protocol::trace_event::{ - backtrace_error_label, BacktraceStatus, BACKTRACE_FLAG_INLINE, BACKTRACE_FLAG_RAW, + BacktraceStatus, BACKTRACE_FLAG_INLINE, BACKTRACE_FLAG_RAW, }; use ghostscope_protocol::{ParsedBacktraceFrame, ParsedInstruction, ParsedTraceEvent}; use ghostscope_ui::{BacktraceDisplay, BacktraceDisplayFrame, TraceDisplayItem, UiTraceEvent}; @@ -20,6 +22,7 @@ struct ResolvedFrameModule<'a> { #[derive(Debug)] pub struct BacktraceRenderer { + #[cfg(test)] frame_cache: SimpleCache>, frame_display_cache: SimpleCache>, status_cache: SimpleCache, @@ -28,6 +31,7 @@ pub struct BacktraceRenderer { impl Default for BacktraceRenderer { fn default() -> Self { Self { + #[cfg(test)] frame_cache: SimpleCache::new(FRAME_RENDER_CACHE_MAX_ENTRIES), frame_display_cache: SimpleCache::new(FRAME_RENDER_CACHE_MAX_ENTRIES), status_cache: SimpleCache::new(STATUS_CACHE_MAX_ENTRIES), @@ -168,6 +172,7 @@ impl BacktraceRenderer { } } + #[cfg(test)] pub fn render_event_backtraces( &mut self, event: &ParsedTraceEvent, @@ -209,6 +214,7 @@ impl BacktraceRenderer { } } + #[cfg(test)] fn format_backtrace_instruction( &mut self, instruction: &ParsedInstruction, @@ -366,6 +372,7 @@ impl BacktraceRenderer { display_status } + #[cfg(test)] fn format_frame( &mut self, index: usize, @@ -570,12 +577,7 @@ fn flush_text_chunk( tid: event.tid, instructions: std::mem::take(text_chunk), }; - items.extend( - chunk_event - .to_formatted_output() - .into_iter() - .map(|content| TraceDisplayItem::Text { content }), - ); + items.extend(UiTraceEvent::from_protocol_event(&chunk_event).items); } fn is_process_entry_frame(analyzer: &DwarfAnalyzer, module_path: &str, pc: u64) -> bool { @@ -605,6 +607,7 @@ fn candidate_pids(event_pid: u32, proc_pid_hint: Option) -> Vec { seen.into_iter().collect() } +#[cfg(test)] fn format_backtrace_header(status: BacktraceStatus, frames: usize, requested_depth: u8) -> String { let frame_word = if frames == 1 { "frame" } else { "frames" }; format!( @@ -645,6 +648,7 @@ fn resolve_frame_module<'a>( None } +#[cfg(test)] fn format_raw_frame( index: usize, frame: &ParsedBacktraceFrame, @@ -708,6 +712,7 @@ fn format_line_info(line: &ghostscope_dwarf::PcLineInfo) -> String { } } +#[cfg(test)] fn format_function_signature(function: &str, analyzer: &DwarfAnalyzer, ctx: &PcContext) -> String { let parameters = format_function_parameters(analyzer, ctx); if parameters.is_empty() {