From 35faed01de84238a8abc0c45752a378b763246c6 Mon Sep 17 00:00:00 2001 From: swananan Date: Sun, 7 Jun 2026 11:58:23 +0800 Subject: [PATCH] refactor: structure script output display items Keep script output as structured UI display items through the runtime path so backtraces, variables, complex variables, and expression errors retain their fields for CLI and TUI rendering. Use the structured fields for CLI pretty colorization while preserving the existing plain text output format. --- .../src/components/ebpf_panel/renderer.rs | 12 + ghostscope-ui/src/events.rs | 349 ++++++++++++- ghostscope-ui/src/lib.rs | 5 +- ghostscope/src/cli/script_output.rs | 461 +++++++++++++++++- ghostscope/src/cli/script_runtime.rs | 6 +- ghostscope/src/trace/backtrace.rs | 19 +- 6 files changed, 808 insertions(+), 44 deletions(-) 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() {