1use std::collections::BTreeMap;
15
16use serde::{Deserialize, Serialize};
17
18use crate::tracing::Span;
19
20const TOP_N: usize = 5;
23
24#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
26pub struct RunProfile {
27 pub total_wall_ms: u64,
28 pub by_kind: Vec<KindBucket>,
29 pub residual_ms: u64,
30 pub top_llm_calls: Vec<SpanRef>,
31 pub top_tool_calls: Vec<SpanRef>,
32 pub steps: Vec<StepSummary>,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
36pub struct KindBucket {
37 pub kind: String,
38 pub total_ms: u64,
39 pub count: u64,
40 pub pct_of_wall: f64,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
44pub struct SpanRef {
45 pub span_id: u64,
46 pub kind: String,
47 pub name: String,
48 pub duration_ms: u64,
49 pub step: Option<String>,
50 pub model: Option<String>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
54pub struct StepSummary {
55 pub name: String,
56 pub duration_ms: u64,
57 pub llm_ms: u64,
58 pub tool_ms: u64,
59 pub other_ms: u64,
60 pub llm_calls: u64,
61 pub tool_calls: u64,
62}
63
64pub fn build(spans: &[Span]) -> RunProfile {
68 if spans.is_empty() {
69 return RunProfile::default();
70 }
71
72 let total_wall_ms: u64 = spans
76 .iter()
77 .filter(|s| s.parent_id.is_none())
78 .map(|s| s.duration_ms)
79 .sum();
80
81 let by_kind = bucket_by_kind(spans, total_wall_ms);
82
83 let depth1_total: u64 = spans
88 .iter()
89 .filter(|s| matches!(s.parent_id, Some(pid) if is_pipeline_root(spans, pid)))
90 .map(|s| s.duration_ms)
91 .sum();
92 let residual_ms = total_wall_ms.saturating_sub(depth1_total);
93
94 let top_llm_calls = top_n_by_duration(spans, "llm_call");
95 let top_tool_calls = top_n_by_duration(spans, "tool_call");
96 let steps = build_step_summaries(spans);
97
98 RunProfile {
99 total_wall_ms,
100 by_kind,
101 residual_ms,
102 top_llm_calls,
103 top_tool_calls,
104 steps,
105 }
106}
107
108fn is_pipeline_root(spans: &[Span], id: u64) -> bool {
109 spans
110 .iter()
111 .find(|s| s.span_id == id)
112 .map(|s| s.parent_id.is_none())
113 .unwrap_or(false)
114}
115
116fn bucket_by_kind(spans: &[Span], total_wall_ms: u64) -> Vec<KindBucket> {
117 let mut totals: BTreeMap<String, (u64, u64)> = BTreeMap::new();
122 for span in spans {
123 if span.parent_id.is_none() {
124 continue;
127 }
128 let entry = totals.entry(span.kind.as_str().to_string()).or_default();
129 entry.0 += span.duration_ms;
130 entry.1 += 1;
131 }
132 let mut buckets: Vec<KindBucket> = totals
133 .into_iter()
134 .map(|(kind, (total_ms, count))| KindBucket {
135 kind,
136 total_ms,
137 count,
138 pct_of_wall: pct(total_ms, total_wall_ms),
139 })
140 .collect();
141 buckets.sort_by_key(|bucket| std::cmp::Reverse(bucket.total_ms));
142 buckets
143}
144
145fn top_n_by_duration(spans: &[Span], kind: &str) -> Vec<SpanRef> {
146 let mut matches: Vec<&Span> = spans.iter().filter(|s| s.kind.as_str() == kind).collect();
147 matches.sort_by_key(|span| std::cmp::Reverse(span.duration_ms));
148 matches
149 .into_iter()
150 .take(TOP_N)
151 .map(|span| SpanRef {
152 span_id: span.span_id,
153 kind: span.kind.as_str().to_string(),
154 name: span.name.clone(),
155 duration_ms: span.duration_ms,
156 step: enclosing_step_name(spans, span.parent_id),
157 model: span
158 .metadata
159 .get("model")
160 .and_then(|v| v.as_str())
161 .map(str::to_string),
162 })
163 .collect()
164}
165
166fn enclosing_step_name(spans: &[Span], mut parent_id: Option<u64>) -> Option<String> {
167 while let Some(pid) = parent_id {
168 let parent = spans.iter().find(|s| s.span_id == pid)?;
169 if parent.kind.as_str() == "step" {
170 return Some(parent.name.clone());
171 }
172 parent_id = parent.parent_id;
173 }
174 None
175}
176
177fn build_step_summaries(spans: &[Span]) -> Vec<StepSummary> {
178 let mut steps: Vec<StepSummary> = Vec::new();
179 for step_span in spans.iter().filter(|s| s.kind.as_str() == "step") {
180 let mut summary = StepSummary {
181 name: step_span.name.clone(),
182 duration_ms: step_span.duration_ms,
183 ..StepSummary::default()
184 };
185 for descendant in descendants(spans, step_span.span_id) {
186 match descendant.kind.as_str() {
187 "llm_call" => {
188 summary.llm_ms += descendant.duration_ms;
189 summary.llm_calls += 1;
190 }
191 "tool_call" => {
192 summary.tool_ms += descendant.duration_ms;
193 summary.tool_calls += 1;
194 }
195 _ => {}
196 }
197 }
198 summary.other_ms = summary
201 .duration_ms
202 .saturating_sub(summary.llm_ms.saturating_add(summary.tool_ms));
203 steps.push(summary);
204 }
205 steps.sort_by_key(|summary| std::cmp::Reverse(summary.duration_ms));
206 steps
207}
208
209fn descendants(spans: &[Span], root: u64) -> Vec<&Span> {
210 let mut out = Vec::new();
211 let mut frontier = vec![root];
212 while let Some(parent) = frontier.pop() {
213 for span in spans {
214 if span.parent_id == Some(parent) {
215 out.push(span);
216 frontier.push(span.span_id);
217 }
218 }
219 }
220 out
221}
222
223fn pct(part: u64, whole: u64) -> f64 {
224 if whole == 0 {
225 0.0
226 } else {
227 (part as f64 / whole as f64) * 100.0
228 }
229}
230
231pub fn render(profile: &RunProfile) -> String {
235 use std::fmt::Write;
236 let mut out = String::new();
237 let _ = writeln!(out, "\n\x1b[2m─── Run profile ───\x1b[0m");
238 let _ = writeln!(
239 out,
240 " Total wall time: {}",
241 format_secs(profile.total_wall_ms)
242 );
243 let _ = writeln!(out, "\n By category:");
244 for bucket in &profile.by_kind {
245 let _ = writeln!(
246 out,
247 " {:<14} {:>10} {:>5.1}% ({} call{})",
248 bucket.kind,
249 format_secs(bucket.total_ms),
250 bucket.pct_of_wall,
251 bucket.count,
252 if bucket.count == 1 { "" } else { "s" },
253 );
254 }
255 let _ = writeln!(
256 out,
257 " {:<14} {:>10} {:>5.1}%",
258 "vm/residual",
259 format_secs(profile.residual_ms),
260 pct(profile.residual_ms, profile.total_wall_ms),
261 );
262 if !profile.top_llm_calls.is_empty() {
263 let _ = writeln!(out, "\n Top LLM calls:");
264 for span in &profile.top_llm_calls {
265 let model = span.model.as_deref().unwrap_or(&span.name);
266 let step = span
267 .step
268 .as_deref()
269 .map(|s| format!(" step={s}"))
270 .unwrap_or_default();
271 let _ = writeln!(
272 out,
273 " #{:<4} {:<24} {:>10}{}",
274 span.span_id,
275 model,
276 format_secs(span.duration_ms),
277 step,
278 );
279 }
280 }
281 if !profile.top_tool_calls.is_empty() {
282 let _ = writeln!(out, "\n Top tool calls:");
283 for span in &profile.top_tool_calls {
284 let step = span
285 .step
286 .as_deref()
287 .map(|s| format!(" step={s}"))
288 .unwrap_or_default();
289 let _ = writeln!(
290 out,
291 " #{:<4} {:<24} {:>10}{}",
292 span.span_id,
293 span.name,
294 format_secs(span.duration_ms),
295 step,
296 );
297 }
298 }
299 if !profile.steps.is_empty() {
300 let _ = writeln!(out, "\n Per-@step:");
301 for step in &profile.steps {
302 let _ = writeln!(
303 out,
304 " {:<20} {:>10} (LLM {} · tools {} · other {})",
305 step.name,
306 format_secs(step.duration_ms),
307 format_secs(step.llm_ms),
308 format_secs(step.tool_ms),
309 format_secs(step.other_ms),
310 );
311 }
312 }
313 out
314}
315
316fn format_secs(ms: u64) -> String {
317 if ms < 1000 {
318 format!("{} ms", ms)
319 } else {
320 format!("{:.3} s", ms as f64 / 1000.0)
321 }
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327 use crate::tracing::SpanKind;
328
329 fn span(span_id: u64, parent_id: Option<u64>, kind: SpanKind, name: &str, dur: u64) -> Span {
330 Span {
331 span_id,
332 parent_id,
333 kind,
334 name: name.into(),
335 start_ms: 0,
336 duration_ms: dur,
337 metadata: BTreeMap::new(),
338 }
339 }
340
341 fn span_with_meta(
342 span_id: u64,
343 parent_id: Option<u64>,
344 kind: SpanKind,
345 name: &str,
346 dur: u64,
347 meta: &[(&str, serde_json::Value)],
348 ) -> Span {
349 let mut s = span(span_id, parent_id, kind, name, dur);
350 for (k, v) in meta {
351 s.metadata.insert((*k).to_string(), v.clone());
352 }
353 s
354 }
355
356 #[test]
357 fn empty_spans_yield_default_profile() {
358 let profile = build(&[]);
359 assert_eq!(profile.total_wall_ms, 0);
360 assert!(profile.by_kind.is_empty());
361 assert_eq!(profile.residual_ms, 0);
362 }
363
364 #[test]
365 fn buckets_are_sorted_descending_by_total() {
366 let spans = vec![
367 span(1, None, SpanKind::Pipeline, "main", 1000),
368 span(2, Some(1), SpanKind::LlmCall, "llm_call", 600),
369 span(3, Some(1), SpanKind::ToolCall, "mcp_call", 250),
370 span(4, Some(1), SpanKind::ToolCall, "mcp_call", 50),
371 ];
372 let profile = build(&spans);
373 assert_eq!(profile.total_wall_ms, 1000);
374 assert_eq!(profile.by_kind[0].kind, "llm_call");
375 assert_eq!(profile.by_kind[0].total_ms, 600);
376 assert_eq!(profile.by_kind[1].kind, "tool_call");
377 assert_eq!(profile.by_kind[1].total_ms, 300);
378 assert_eq!(profile.by_kind[1].count, 2);
379 assert_eq!(profile.residual_ms, 100);
381 }
382
383 #[test]
384 fn nested_spans_do_not_double_count_residual() {
385 let spans = vec![
388 span(1, None, SpanKind::Pipeline, "main", 1000),
389 span(2, Some(1), SpanKind::Step, "research", 800),
390 span(3, Some(2), SpanKind::LlmCall, "llm_call", 700),
391 ];
392 let profile = build(&spans);
393 assert_eq!(profile.total_wall_ms, 1000);
394 assert_eq!(profile.residual_ms, 200);
395 }
396
397 #[test]
398 fn step_summaries_split_llm_tool_other() {
399 let spans = vec![
400 span(1, None, SpanKind::Pipeline, "main", 2000),
401 span(2, Some(1), SpanKind::Step, "research", 1500),
402 span(3, Some(2), SpanKind::LlmCall, "llm_call", 900),
403 span(4, Some(2), SpanKind::ToolCall, "mcp_call", 400),
404 ];
405 let profile = build(&spans);
406 assert_eq!(profile.steps.len(), 1);
407 let step = &profile.steps[0];
408 assert_eq!(step.name, "research");
409 assert_eq!(step.duration_ms, 1500);
410 assert_eq!(step.llm_ms, 900);
411 assert_eq!(step.tool_ms, 400);
412 assert_eq!(step.other_ms, 200);
413 assert_eq!(step.llm_calls, 1);
414 assert_eq!(step.tool_calls, 1);
415 }
416
417 #[test]
418 fn top_llm_calls_attribute_enclosing_step_and_model() {
419 let spans = vec![
420 span(1, None, SpanKind::Pipeline, "main", 2000),
421 span(2, Some(1), SpanKind::Step, "research", 1500),
422 span_with_meta(
423 3,
424 Some(2),
425 SpanKind::LlmCall,
426 "llm_call",
427 900,
428 &[("model", serde_json::json!("claude-sonnet-4-6"))],
429 ),
430 span(4, Some(1), SpanKind::LlmCall, "llm_call", 100),
431 ];
432 let profile = build(&spans);
433 assert_eq!(profile.top_llm_calls.len(), 2);
434 assert_eq!(profile.top_llm_calls[0].duration_ms, 900);
435 assert_eq!(profile.top_llm_calls[0].step.as_deref(), Some("research"));
436 assert_eq!(
437 profile.top_llm_calls[0].model.as_deref(),
438 Some("claude-sonnet-4-6")
439 );
440 assert!(profile.top_llm_calls[1].step.is_none());
441 }
442
443 #[test]
444 fn render_produces_nonempty_output_for_real_run() {
445 let spans = vec![
446 span(1, None, SpanKind::Pipeline, "main", 1000),
447 span(2, Some(1), SpanKind::LlmCall, "llm_call", 700),
448 ];
449 let rendered = render(&build(&spans));
450 assert!(rendered.contains("Run profile"));
451 assert!(rendered.contains("llm_call"));
452 assert!(rendered.contains("vm/residual"));
453 }
454
455 #[test]
456 fn render_for_empty_profile_still_produces_header() {
457 let rendered = render(&RunProfile::default());
462 assert!(rendered.contains("Run profile"));
463 assert!(rendered.contains("vm/residual"));
464 }
465}