Skip to main content

systemprompt_cli/presentation/tables/
trace.rs

1//! Task-execution tables: task summary, steps, AI requests, MCP tool calls,
2//! artifacts, and the interleaved trace view.
3//!
4//! Copyright (c) systemprompt.io — Business Source License 1.1.
5//! See <https://systemprompt.io> for licensing details.
6
7use 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}