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 accounted_total: u64 = spans
87 .iter()
88 .filter(|s| {
89 (s.parent_id.is_none() && !is_profile_root_span(s))
90 || matches!(s.parent_id, Some(pid) if is_pipeline_root(spans, pid))
91 })
92 .map(|s| s.duration_ms)
93 .sum();
94 let residual_ms = total_wall_ms.saturating_sub(accounted_total);
95
96 let top_llm_calls = top_n_by_duration(spans, "llm_call");
97 let top_tool_calls = top_n_by_duration(spans, "tool_call");
98 let steps = build_step_summaries(spans);
99
100 RunProfile {
101 total_wall_ms,
102 by_kind,
103 residual_ms,
104 top_llm_calls,
105 top_tool_calls,
106 steps,
107 }
108}
109
110pub fn build_aggregate(span_groups: &[Vec<Span>]) -> RunProfile {
115 let mut merged = Vec::new();
116 let mut next_offset = 0u64;
117 for group in span_groups {
118 let offset = next_offset;
119 let max_id = group.iter().map(|span| span.span_id).max().unwrap_or(0);
120 for span in group {
121 let mut remapped = span.clone();
122 remapped.span_id += offset;
123 remapped.parent_id = remapped.parent_id.map(|id| id + offset);
124 merged.push(remapped);
125 }
126 next_offset += max_id + 1;
127 }
128 build(&merged)
129}
130
131fn is_pipeline_root(spans: &[Span], id: u64) -> bool {
132 spans
133 .iter()
134 .find(|s| s.span_id == id)
135 .map(is_profile_root_span)
136 .unwrap_or(false)
137}
138
139fn is_profile_root_span(span: &Span) -> bool {
140 span.parent_id.is_none() && span.kind == crate::tracing::SpanKind::Pipeline
141}
142
143fn bucket_by_kind(spans: &[Span], total_wall_ms: u64) -> Vec<KindBucket> {
144 let mut totals: BTreeMap<String, (u64, u64)> = BTreeMap::new();
149 for span in spans {
150 if is_profile_root_span(span) {
151 continue;
154 }
155 let entry = totals.entry(span.kind.as_str().to_string()).or_default();
156 entry.0 += span.duration_ms;
157 entry.1 += 1;
158 }
159 let mut buckets: Vec<KindBucket> = totals
160 .into_iter()
161 .map(|(kind, (total_ms, count))| KindBucket {
162 kind,
163 total_ms,
164 count,
165 pct_of_wall: pct(total_ms, total_wall_ms),
166 })
167 .collect();
168 buckets.sort_by_key(|bucket| std::cmp::Reverse(bucket.total_ms));
169 buckets
170}
171
172fn top_n_by_duration(spans: &[Span], kind: &str) -> Vec<SpanRef> {
173 let mut matches: Vec<&Span> = spans.iter().filter(|s| s.kind.as_str() == kind).collect();
174 matches.sort_by_key(|span| std::cmp::Reverse(span.duration_ms));
175 matches
176 .into_iter()
177 .take(TOP_N)
178 .map(|span| SpanRef {
179 span_id: span.span_id,
180 kind: span.kind.as_str().to_string(),
181 name: span.name.clone(),
182 duration_ms: span.duration_ms,
183 step: enclosing_step_name(spans, span.parent_id),
184 model: span
185 .metadata
186 .get("model")
187 .and_then(|v| v.as_str())
188 .map(str::to_string),
189 })
190 .collect()
191}
192
193fn enclosing_step_name(spans: &[Span], mut parent_id: Option<u64>) -> Option<String> {
194 while let Some(pid) = parent_id {
195 let parent = spans.iter().find(|s| s.span_id == pid)?;
196 if parent.kind.as_str() == "step" {
197 return Some(parent.name.clone());
198 }
199 parent_id = parent.parent_id;
200 }
201 None
202}
203
204fn build_step_summaries(spans: &[Span]) -> Vec<StepSummary> {
205 let mut steps: Vec<StepSummary> = Vec::new();
206 for step_span in spans.iter().filter(|s| s.kind.as_str() == "step") {
207 let mut summary = StepSummary {
208 name: step_span.name.clone(),
209 duration_ms: step_span.duration_ms,
210 ..StepSummary::default()
211 };
212 for descendant in descendants(spans, step_span.span_id) {
213 match descendant.kind.as_str() {
214 "llm_call" => {
215 summary.llm_ms += descendant.duration_ms;
216 summary.llm_calls += 1;
217 }
218 "tool_call" => {
219 summary.tool_ms += descendant.duration_ms;
220 summary.tool_calls += 1;
221 }
222 _ => {}
223 }
224 }
225 summary.other_ms = summary
228 .duration_ms
229 .saturating_sub(summary.llm_ms.saturating_add(summary.tool_ms));
230 steps.push(summary);
231 }
232 steps.sort_by_key(|summary| std::cmp::Reverse(summary.duration_ms));
233 steps
234}
235
236fn descendants(spans: &[Span], root: u64) -> Vec<&Span> {
237 let mut out = Vec::new();
238 let mut frontier = vec![root];
239 while let Some(parent) = frontier.pop() {
240 for span in spans {
241 if span.parent_id == Some(parent) {
242 out.push(span);
243 frontier.push(span.span_id);
244 }
245 }
246 }
247 out
248}
249
250fn pct(part: u64, whole: u64) -> f64 {
251 if whole == 0 {
252 0.0
253 } else {
254 (part as f64 / whole as f64) * 100.0
255 }
256}
257
258pub fn render(profile: &RunProfile) -> String {
262 use std::fmt::Write;
263 let mut out = String::new();
264 let _ = writeln!(out, "\n\x1b[2m─── Run profile ───\x1b[0m");
265 let _ = writeln!(
266 out,
267 " Total wall time: {}",
268 format_secs(profile.total_wall_ms)
269 );
270 let _ = writeln!(out, "\n By category:");
271 for bucket in &profile.by_kind {
272 let _ = writeln!(
273 out,
274 " {:<14} {:>10} {:>5.1}% ({} call{})",
275 bucket.kind,
276 format_secs(bucket.total_ms),
277 bucket.pct_of_wall,
278 bucket.count,
279 if bucket.count == 1 { "" } else { "s" },
280 );
281 }
282 let _ = writeln!(
283 out,
284 " {:<14} {:>10} {:>5.1}%",
285 "vm/residual",
286 format_secs(profile.residual_ms),
287 pct(profile.residual_ms, profile.total_wall_ms),
288 );
289 if !profile.top_llm_calls.is_empty() {
290 let _ = writeln!(out, "\n Top LLM calls:");
291 for span in &profile.top_llm_calls {
292 let model = span.model.as_deref().unwrap_or(&span.name);
293 let step = span
294 .step
295 .as_deref()
296 .map(|s| format!(" step={s}"))
297 .unwrap_or_default();
298 let _ = writeln!(
299 out,
300 " #{:<4} {:<24} {:>10}{}",
301 span.span_id,
302 model,
303 format_secs(span.duration_ms),
304 step,
305 );
306 }
307 }
308 if !profile.top_tool_calls.is_empty() {
309 let _ = writeln!(out, "\n Top tool calls:");
310 for span in &profile.top_tool_calls {
311 let step = span
312 .step
313 .as_deref()
314 .map(|s| format!(" step={s}"))
315 .unwrap_or_default();
316 let _ = writeln!(
317 out,
318 " #{:<4} {:<24} {:>10}{}",
319 span.span_id,
320 span.name,
321 format_secs(span.duration_ms),
322 step,
323 );
324 }
325 }
326 if !profile.steps.is_empty() {
327 let _ = writeln!(out, "\n Per-@step:");
328 for step in &profile.steps {
329 let _ = writeln!(
330 out,
331 " {:<20} {:>10} (LLM {} · tools {} · other {})",
332 step.name,
333 format_secs(step.duration_ms),
334 format_secs(step.llm_ms),
335 format_secs(step.tool_ms),
336 format_secs(step.other_ms),
337 );
338 }
339 }
340 out
341}
342
343fn format_secs(ms: u64) -> String {
344 if ms < 1000 {
345 format!("{} ms", ms)
346 } else {
347 format!("{:.3} s", ms as f64 / 1000.0)
348 }
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354 use crate::tracing::SpanKind;
355
356 fn span(span_id: u64, parent_id: Option<u64>, kind: SpanKind, name: &str, dur: u64) -> Span {
357 Span {
358 trace_id: "trace_test".to_string(),
359 span_id,
360 parent_id,
361 kind,
362 name: name.into(),
363 start_ms: 0,
364 start_unix_ms: 0,
365 duration_ms: dur,
366 metadata: BTreeMap::new(),
367 links: Vec::new(),
368 events: Vec::new(),
369 }
370 }
371
372 fn span_with_meta(
373 span_id: u64,
374 parent_id: Option<u64>,
375 kind: SpanKind,
376 name: &str,
377 dur: u64,
378 meta: &[(&str, serde_json::Value)],
379 ) -> Span {
380 let mut s = span(span_id, parent_id, kind, name, dur);
381 for (k, v) in meta {
382 s.metadata.insert((*k).to_string(), v.clone());
383 }
384 s
385 }
386
387 #[test]
388 fn empty_spans_yield_default_profile() {
389 let profile = build(&[]);
390 assert_eq!(profile.total_wall_ms, 0);
391 assert!(profile.by_kind.is_empty());
392 assert_eq!(profile.residual_ms, 0);
393 }
394
395 #[test]
396 fn buckets_are_sorted_descending_by_total() {
397 let spans = vec![
398 span(1, None, SpanKind::Pipeline, "main", 1000),
399 span(2, Some(1), SpanKind::LlmCall, "llm_call", 600),
400 span(3, Some(1), SpanKind::ToolCall, "mcp_call", 250),
401 span(4, Some(1), SpanKind::ToolCall, "mcp_call", 50),
402 ];
403 let profile = build(&spans);
404 assert_eq!(profile.total_wall_ms, 1000);
405 assert_eq!(profile.by_kind[0].kind, "llm_call");
406 assert_eq!(profile.by_kind[0].total_ms, 600);
407 assert_eq!(profile.by_kind[1].kind, "tool_call");
408 assert_eq!(profile.by_kind[1].total_ms, 300);
409 assert_eq!(profile.by_kind[1].count, 2);
410 assert_eq!(profile.residual_ms, 100);
412 }
413
414 #[test]
415 fn top_level_vm_setup_span_gets_its_own_bucket() {
416 let spans = vec![
417 span(1, None, SpanKind::VmSetup, "acp_vm_setup", 20),
418 span(2, None, SpanKind::Pipeline, "main", 80),
419 span(3, Some(2), SpanKind::LlmCall, "llm_call", 50),
420 ];
421 let profile = build(&spans);
422 assert_eq!(profile.total_wall_ms, 100);
423 assert_eq!(profile.residual_ms, 30);
424 assert!(profile
425 .by_kind
426 .iter()
427 .any(|bucket| bucket.kind == "vm_setup" && bucket.total_ms == 20));
428 }
429
430 #[test]
431 fn nested_spans_do_not_double_count_residual() {
432 let spans = vec![
435 span(1, None, SpanKind::Pipeline, "main", 1000),
436 span(2, Some(1), SpanKind::Step, "research", 800),
437 span(3, Some(2), SpanKind::LlmCall, "llm_call", 700),
438 ];
439 let profile = build(&spans);
440 assert_eq!(profile.total_wall_ms, 1000);
441 assert_eq!(profile.residual_ms, 200);
442 }
443
444 #[test]
445 fn step_summaries_split_llm_tool_other() {
446 let spans = vec![
447 span(1, None, SpanKind::Pipeline, "main", 2000),
448 span(2, Some(1), SpanKind::Step, "research", 1500),
449 span(3, Some(2), SpanKind::LlmCall, "llm_call", 900),
450 span(4, Some(2), SpanKind::ToolCall, "mcp_call", 400),
451 ];
452 let profile = build(&spans);
453 assert_eq!(profile.steps.len(), 1);
454 let step = &profile.steps[0];
455 assert_eq!(step.name, "research");
456 assert_eq!(step.duration_ms, 1500);
457 assert_eq!(step.llm_ms, 900);
458 assert_eq!(step.tool_ms, 400);
459 assert_eq!(step.other_ms, 200);
460 assert_eq!(step.llm_calls, 1);
461 assert_eq!(step.tool_calls, 1);
462 }
463
464 #[test]
465 fn top_llm_calls_attribute_enclosing_step_and_model() {
466 let spans = vec![
467 span(1, None, SpanKind::Pipeline, "main", 2000),
468 span(2, Some(1), SpanKind::Step, "research", 1500),
469 span_with_meta(
470 3,
471 Some(2),
472 SpanKind::LlmCall,
473 "llm_call",
474 900,
475 &[("model", serde_json::json!("claude-sonnet-4-6"))],
476 ),
477 span(4, Some(1), SpanKind::LlmCall, "llm_call", 100),
478 ];
479 let profile = build(&spans);
480 assert_eq!(profile.top_llm_calls.len(), 2);
481 assert_eq!(profile.top_llm_calls[0].duration_ms, 900);
482 assert_eq!(profile.top_llm_calls[0].step.as_deref(), Some("research"));
483 assert_eq!(
484 profile.top_llm_calls[0].model.as_deref(),
485 Some("claude-sonnet-4-6")
486 );
487 assert!(profile.top_llm_calls[1].step.is_none());
488 }
489
490 #[test]
491 fn render_produces_nonempty_output_for_real_run() {
492 let spans = vec![
493 span(1, None, SpanKind::Pipeline, "main", 1000),
494 span(2, Some(1), SpanKind::LlmCall, "llm_call", 700),
495 ];
496 let rendered = render(&build(&spans));
497 assert!(rendered.contains("Run profile"));
498 assert!(rendered.contains("llm_call"));
499 assert!(rendered.contains("vm/residual"));
500 }
501
502 #[test]
503 fn render_for_empty_profile_still_produces_header() {
504 let rendered = render(&RunProfile::default());
509 assert!(rendered.contains("Run profile"));
510 assert!(rendered.contains("vm/residual"));
511 }
512
513 #[test]
514 fn aggregate_remaps_duplicate_span_ids_across_runs() {
515 let first = vec![
516 span(1, None, SpanKind::Pipeline, "main", 100),
517 span(2, Some(1), SpanKind::LlmCall, "llm_call", 40),
518 ];
519 let second = vec![
520 span(1, None, SpanKind::Pipeline, "main", 200),
521 span(2, Some(1), SpanKind::ToolCall, "tool", 50),
522 ];
523
524 let profile = build_aggregate(&[first, second]);
525
526 assert_eq!(profile.total_wall_ms, 300);
527 assert_eq!(profile.by_kind.len(), 2);
528 assert!(profile.by_kind.iter().any(|bucket| {
529 bucket.kind == "llm_call" && bucket.total_ms == 40 && bucket.count == 1
530 }));
531 assert!(profile.by_kind.iter().any(|bucket| {
532 bucket.kind == "tool_call" && bucket.total_ms == 50 && bucket.count == 1
533 }));
534 }
535}