systemprompt_cli/presentation/tables/
trace.rs1use systemprompt_logging::{
8 AiRequestInfo, ExecutionStep, McpToolExecution, TaskArtifact, TaskInfo, TraceEvent,
9};
10use tabled::settings::Style;
11use tabled::{Table, Tabled};
12
13use super::{dash, millis, truncate_cell};
14
15#[derive(Tabled)]
16struct TaskInfoRow {
17 #[tabled(rename = "Task ID")]
18 task: String,
19 #[tabled(rename = "Agent")]
20 agent_name: String,
21 #[tabled(rename = "Status")]
22 status: String,
23 #[tabled(rename = "Started")]
24 started_at: String,
25 #[tabled(rename = "Duration")]
26 duration: String,
27}
28
29#[must_use]
30pub fn task_info_table(task_info: &TaskInfo) -> String {
31 let rows = vec![TaskInfoRow {
32 task: task_info.task_id.as_str().chars().take(8).collect(),
33 agent_name: task_info.agent_name.clone().unwrap_or_else(dash),
34 status: task_info.status.clone(),
35 started_at: task_info
36 .started_at
37 .map_or_else(dash, |t| t.format("%H:%M:%S").to_string()),
38 duration: millis(task_info.execution_time_ms),
39 }];
40 Table::new(rows).with(Style::rounded()).to_string()
41}
42
43#[derive(Tabled)]
44struct StepRow {
45 #[tabled(rename = "#")]
46 step_number: usize,
47 #[tabled(rename = "Type")]
48 step_type: String,
49 #[tabled(rename = "Title")]
50 title: String,
51 #[tabled(rename = "Status")]
52 status: String,
53 #[tabled(rename = "Duration")]
54 duration: String,
55}
56
57#[must_use]
58pub fn execution_steps_table(steps: &[ExecutionStep]) -> String {
59 let rows: Vec<StepRow> = steps
60 .iter()
61 .enumerate()
62 .map(|(i, s)| StepRow {
63 step_number: i + 1,
64 step_type: s.step_type.clone().unwrap_or_else(|| "unknown".to_owned()),
65 title: truncate_cell(s.title.as_deref().unwrap_or_default(), 40),
66 status: s.status.clone(),
67 duration: millis(s.duration_ms),
68 })
69 .collect();
70 Table::new(rows).with(Style::rounded()).to_string()
71}
72
73#[derive(Tabled)]
74struct AiRequestRow {
75 #[tabled(rename = "Model")]
76 model: String,
77 #[tabled(rename = "Max")]
78 max_tokens: String,
79 #[tabled(rename = "Tokens")]
80 tokens: String,
81 #[tabled(rename = "Cost")]
82 cost: String,
83 #[tabled(rename = "Latency")]
84 latency: String,
85}
86
87#[must_use]
88pub fn ai_requests_table(requests: &[AiRequestInfo]) -> String {
89 let rows: Vec<AiRequestRow> = requests
90 .iter()
91 .map(|r| AiRequestRow {
92 model: format!(
93 "{}/{}",
94 r.provider.as_deref().unwrap_or("-"),
95 r.model.as_deref().unwrap_or("-")
96 ),
97 max_tokens: r.max_tokens.map_or_else(dash, |t| t.to_string()),
98 tokens: format!(
99 "{} (in:{}, out:{})",
100 r.input_tokens.unwrap_or(0) + r.output_tokens.unwrap_or(0),
101 r.input_tokens.unwrap_or(0),
102 r.output_tokens.unwrap_or(0)
103 ),
104 #[expect(
105 clippy::cast_precision_loss,
106 reason = "display-only dollar conversion of microdollar totals"
107 )]
108 cost: format!("${:.4}", r.cost_microdollars as f64 / 1_000_000.0),
109 latency: millis(r.latency_ms),
110 })
111 .collect();
112 Table::new(rows).with(Style::rounded()).to_string()
113}
114
115#[derive(Tabled)]
116struct ToolCallRow {
117 #[tabled(rename = "Tool")]
118 tool_name: String,
119 #[tabled(rename = "Server")]
120 server: String,
121 #[tabled(rename = "Status")]
122 status: String,
123 #[tabled(rename = "Duration")]
124 duration: String,
125}
126
127#[must_use]
128pub fn mcp_tool_calls_table(executions: &[McpToolExecution]) -> String {
129 let rows: Vec<ToolCallRow> = executions
130 .iter()
131 .map(|e| ToolCallRow {
132 tool_name: e.tool_name.clone(),
133 server: e.server_name.clone(),
134 status: e.status.clone(),
135 duration: millis(e.execution_time_ms),
136 })
137 .collect();
138 Table::new(rows).with(Style::rounded()).to_string()
139}
140
141#[derive(Tabled)]
142struct TaskArtifactRow {
143 #[tabled(rename = "ID")]
144 artifact: String,
145 #[tabled(rename = "Type")]
146 artifact_type: String,
147 #[tabled(rename = "Name")]
148 name: String,
149 #[tabled(rename = "Source")]
150 source: String,
151 #[tabled(rename = "Tool")]
152 tool_name: String,
153}
154
155#[must_use]
156pub fn task_artifacts_table(artifacts: &[TaskArtifact]) -> String {
157 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
158 let rows: Vec<TaskArtifactRow> = artifacts
159 .iter()
160 .filter(|a| seen.insert(a.artifact_id.to_string()))
161 .map(|a| TaskArtifactRow {
162 artifact: truncate_cell(a.artifact_id.as_str(), 12),
163 artifact_type: a.artifact_type.clone(),
164 name: a.name.as_ref().map_or_else(dash, |s| truncate_cell(s, 30)),
165 source: a.source.clone().unwrap_or_else(dash),
166 tool_name: a.tool_name.clone().unwrap_or_else(dash),
167 })
168 .collect();
169 Table::new(&rows).with(Style::rounded()).to_string()
170}
171
172#[derive(Tabled)]
173struct TraceRow {
174 #[tabled(rename = "Time")]
175 time: String,
176 #[tabled(rename = "Delta")]
177 delta: String,
178 #[tabled(rename = "Type")]
179 event_type: String,
180 #[tabled(rename = "Details")]
181 details: String,
182 #[tabled(rename = "Latency")]
183 latency: String,
184}
185
186#[must_use]
187pub fn format_metadata_value(key: &str, value: &serde_json::Value) -> String {
188 let raw = || format!("{value}").trim_matches('"').to_owned();
189 match key {
190 "cost_microdollars" => value.as_i64().map_or_else(raw, |microdollars| {
191 #[expect(
192 clippy::cast_precision_loss,
193 reason = "display-only dollar conversion of microdollar totals"
194 )]
195 let dollars = microdollars as f64 / 1_000_000.0;
196 format!("${dollars:.6}")
197 }),
198 "latency_ms" | "execution_time_ms" => {
199 value.as_i64().map_or_else(raw, |ms| format!("{ms}ms"))
200 },
201 "tokens_used" => value.as_i64().map_or_else(raw, |tokens| tokens.to_string()),
202 _ => raw(),
203 }
204}
205
206#[must_use]
207pub fn extract_latency_from_metadata(metadata: Option<&str>, event_type: &str) -> String {
208 if let Some(meta) = metadata
209 && let Ok(parsed) = serde_json::from_str::<serde_json::Value>(meta)
210 {
211 let key = match event_type {
212 "AI" => Some("latency_ms"),
213 "MCP" => Some("execution_time_ms"),
214 _ => None,
215 };
216 if let Some(key) = key
217 && let Some(ms) = parsed.get(key).and_then(serde_json::Value::as_i64)
218 {
219 return format!("{ms}ms");
220 }
221 }
222 dash()
223}
224
225#[must_use]
226pub fn trace_events_table(events: &[TraceEvent]) -> String {
227 let mut prev_timestamp: Option<chrono::DateTime<chrono::Utc>> = None;
228 let rows: Vec<TraceRow> = events
229 .iter()
230 .map(|e| {
231 let delta = prev_timestamp.map_or_else(
232 || "+0ms".to_owned(),
233 |prev| {
234 let delta_ms = e.timestamp.signed_duration_since(prev).num_milliseconds();
235 format!("+{delta_ms}ms")
236 },
237 );
238 prev_timestamp = Some(e.timestamp);
239 TraceRow {
240 time: e.timestamp.format("%H:%M:%S%.3f").to_string(),
241 delta,
242 event_type: e.event_type.clone(),
243 details: truncate_cell(&e.details, 100),
244 latency: extract_latency_from_metadata(e.metadata.as_deref(), &e.event_type),
245 }
246 })
247 .collect();
248
249 if rows.is_empty() {
250 String::new()
251 } else {
252 Table::new(rows).with(Style::modern()).to_string()
253 }
254}