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 #[serde(skip_serializing_if = "Option::is_none")]
29 pub first_token_ms: Option<u64>,
30 pub by_kind: Vec<KindBucket>,
31 pub residual_ms: u64,
32 pub top_llm_calls: Vec<SpanRef>,
33 pub top_tool_calls: Vec<SpanRef>,
34 pub steps: Vec<StepSummary>,
35 #[serde(default, skip_serializing_if = "Vec::is_empty")]
41 pub top_builtins: Vec<crate::builtin_profile::BuiltinBucket>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
45pub struct KindBucket {
46 pub kind: String,
47 pub total_ms: u64,
48 pub count: u64,
49 pub pct_of_wall: f64,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
53pub struct SpanRef {
54 pub span_id: u64,
55 pub kind: String,
56 pub name: String,
57 pub duration_ms: u64,
58 pub step: Option<String>,
59 pub model: Option<String>,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
63pub struct StepSummary {
64 pub name: String,
65 pub duration_ms: u64,
66 pub llm_ms: u64,
67 pub tool_ms: u64,
68 pub other_ms: u64,
69 pub llm_calls: u64,
70 pub tool_calls: u64,
71}
72
73pub fn build(spans: &[Span]) -> RunProfile {
77 if spans.is_empty() {
78 return RunProfile::default();
79 }
80
81 let total_wall_ms: u64 = spans
85 .iter()
86 .filter(|s| s.parent_id.is_none())
87 .map(|s| s.duration_ms)
88 .sum();
89
90 let by_kind = bucket_by_kind(spans, total_wall_ms);
91
92 let accounted_total: u64 = spans
96 .iter()
97 .filter(|s| {
98 (s.parent_id.is_none() && !is_profile_root_span(s))
99 || matches!(s.parent_id, Some(pid) if is_pipeline_root(spans, pid))
100 })
101 .map(|s| s.duration_ms)
102 .sum();
103 let residual_ms = total_wall_ms.saturating_sub(accounted_total);
104
105 let top_llm_calls = top_n_by_duration(spans, "llm_call");
106 let top_tool_calls = top_n_by_duration(spans, "tool_call");
107 let steps = build_step_summaries(spans);
108 let first_token_ms = first_token_ms(spans);
109
110 RunProfile {
111 total_wall_ms,
112 first_token_ms,
113 by_kind,
114 residual_ms,
115 top_llm_calls,
116 top_tool_calls,
117 steps,
118 top_builtins: top_builtins(),
119 }
120}
121
122fn top_builtins() -> Vec<crate::builtin_profile::BuiltinBucket> {
125 let mut buckets = crate::builtin_profile::snapshot();
126 buckets.truncate(TOP_N);
127 buckets
128}
129
130pub fn build_aggregate(span_groups: &[Vec<Span>]) -> RunProfile {
135 let mut merged = Vec::new();
136 let mut next_offset = 0u64;
137 for group in span_groups {
138 let offset = next_offset;
139 let max_id = group.iter().map(|span| span.span_id).max().unwrap_or(0);
140 for span in group {
141 let mut remapped = span.clone();
142 remapped.span_id += offset;
143 remapped.parent_id = remapped.parent_id.map(|id| id + offset);
144 merged.push(remapped);
145 }
146 next_offset += max_id + 1;
147 }
148 build(&merged)
149}
150
151fn first_token_ms(spans: &[Span]) -> Option<u64> {
152 spans
153 .iter()
154 .filter(|span| span.kind.as_str() == "llm_call")
155 .filter_map(|span| {
156 span.metadata
157 .get(crate::llm::first_token::FIRST_TOKEN_METADATA_KEY)
158 .and_then(serde_json::Value::as_u64)
159 })
160 .min()
161}
162
163fn is_pipeline_root(spans: &[Span], id: u64) -> bool {
164 spans
165 .iter()
166 .find(|s| s.span_id == id)
167 .map(is_profile_root_span)
168 .unwrap_or(false)
169}
170
171fn is_profile_root_span(span: &Span) -> bool {
172 span.parent_id.is_none() && span.kind == crate::tracing::SpanKind::Pipeline
173}
174
175fn bucket_by_kind(spans: &[Span], total_wall_ms: u64) -> Vec<KindBucket> {
176 let mut totals: BTreeMap<String, (u64, u64)> = BTreeMap::new();
181 for span in spans {
182 if is_profile_root_span(span) {
183 continue;
186 }
187 let entry = totals.entry(span.kind.as_str().to_string()).or_default();
188 entry.0 += span.duration_ms;
189 entry.1 += 1;
190 }
191 let mut buckets: Vec<KindBucket> = totals
192 .into_iter()
193 .map(|(kind, (total_ms, count))| KindBucket {
194 kind,
195 total_ms,
196 count,
197 pct_of_wall: pct(total_ms, total_wall_ms),
198 })
199 .collect();
200 buckets.sort_by_key(|bucket| std::cmp::Reverse(bucket.total_ms));
201 buckets
202}
203
204fn top_n_by_duration(spans: &[Span], kind: &str) -> Vec<SpanRef> {
205 let mut matches: Vec<&Span> = spans.iter().filter(|s| s.kind.as_str() == kind).collect();
206 matches.sort_by_key(|span| std::cmp::Reverse(span.duration_ms));
207 matches
208 .into_iter()
209 .take(TOP_N)
210 .map(|span| SpanRef {
211 span_id: span.span_id,
212 kind: span.kind.as_str().to_string(),
213 name: span.name.clone(),
214 duration_ms: span.duration_ms,
215 step: enclosing_step_name(spans, span.parent_id),
216 model: span
217 .metadata
218 .get("model")
219 .and_then(|v| v.as_str())
220 .map(str::to_string),
221 })
222 .collect()
223}
224
225fn enclosing_step_name(spans: &[Span], mut parent_id: Option<u64>) -> Option<String> {
226 while let Some(pid) = parent_id {
227 let parent = spans.iter().find(|s| s.span_id == pid)?;
228 if parent.kind.as_str() == "step" {
229 return Some(parent.name.clone());
230 }
231 parent_id = parent.parent_id;
232 }
233 None
234}
235
236fn build_step_summaries(spans: &[Span]) -> Vec<StepSummary> {
237 let mut steps: Vec<StepSummary> = Vec::new();
238 for step_span in spans.iter().filter(|s| s.kind.as_str() == "step") {
239 let mut summary = StepSummary {
240 name: step_span.name.clone(),
241 duration_ms: step_span.duration_ms,
242 ..StepSummary::default()
243 };
244 for descendant in descendants(spans, step_span.span_id) {
245 match descendant.kind.as_str() {
246 "llm_call" => {
247 summary.llm_ms += descendant.duration_ms;
248 summary.llm_calls += 1;
249 }
250 "tool_call" => {
251 summary.tool_ms += descendant.duration_ms;
252 summary.tool_calls += 1;
253 }
254 _ => {}
255 }
256 }
257 summary.other_ms = summary
260 .duration_ms
261 .saturating_sub(summary.llm_ms.saturating_add(summary.tool_ms));
262 steps.push(summary);
263 }
264 steps.sort_by_key(|summary| std::cmp::Reverse(summary.duration_ms));
265 steps
266}
267
268fn descendants(spans: &[Span], root: u64) -> Vec<&Span> {
269 let mut out = Vec::new();
270 let mut frontier = vec![root];
271 while let Some(parent) = frontier.pop() {
272 for span in spans {
273 if span.parent_id == Some(parent) {
274 out.push(span);
275 frontier.push(span.span_id);
276 }
277 }
278 }
279 out
280}
281
282fn pct(part: u64, whole: u64) -> f64 {
283 if whole == 0 {
284 0.0
285 } else {
286 (part as f64 / whole as f64) * 100.0
287 }
288}
289
290pub fn render(profile: &RunProfile) -> String {
294 use std::fmt::Write;
295 let mut out = String::new();
296 let _ = writeln!(out, "\n\x1b[2m─── Run profile ───\x1b[0m");
297 let _ = writeln!(
298 out,
299 " Total wall time: {}",
300 format_secs(profile.total_wall_ms)
301 );
302 if let Some(first_token_ms) = profile.first_token_ms {
303 let _ = writeln!(out, " First token: {}", format_secs(first_token_ms));
304 }
305 let _ = writeln!(out, "\n By category:");
306 for bucket in &profile.by_kind {
307 let _ = writeln!(
308 out,
309 " {:<14} {:>10} {:>5.1}% ({} call{})",
310 bucket.kind,
311 format_secs(bucket.total_ms),
312 bucket.pct_of_wall,
313 bucket.count,
314 if bucket.count == 1 { "" } else { "s" },
315 );
316 }
317 let _ = writeln!(
318 out,
319 " {:<14} {:>10} {:>5.1}%",
320 "vm/residual",
321 format_secs(profile.residual_ms),
322 pct(profile.residual_ms, profile.total_wall_ms),
323 );
324 if !profile.top_builtins.is_empty() {
325 let _ = writeln!(out, "\n Top builtins (inclusive of nested calls):");
326 for bucket in &profile.top_builtins {
327 let _ = writeln!(
328 out,
329 " {:<24} {:>10} {:>5.1}% ({} call{}, avg {:.1} ms)",
330 bucket.name,
331 format_secs(bucket.total_ms),
332 pct(bucket.total_ms, profile.total_wall_ms),
333 bucket.calls,
334 if bucket.calls == 1 { "" } else { "s" },
335 bucket.avg_ms,
336 );
337 }
338 }
339 if !profile.top_llm_calls.is_empty() {
340 let _ = writeln!(out, "\n Top LLM calls:");
341 for span in &profile.top_llm_calls {
342 let model = span.model.as_deref().unwrap_or(&span.name);
343 let step = span
344 .step
345 .as_deref()
346 .map(|s| format!(" step={s}"))
347 .unwrap_or_default();
348 let _ = writeln!(
349 out,
350 " #{:<4} {:<24} {:>10}{}",
351 span.span_id,
352 model,
353 format_secs(span.duration_ms),
354 step,
355 );
356 }
357 }
358 if !profile.top_tool_calls.is_empty() {
359 let _ = writeln!(out, "\n Top tool calls:");
360 for span in &profile.top_tool_calls {
361 let step = span
362 .step
363 .as_deref()
364 .map(|s| format!(" step={s}"))
365 .unwrap_or_default();
366 let _ = writeln!(
367 out,
368 " #{:<4} {:<24} {:>10}{}",
369 span.span_id,
370 span.name,
371 format_secs(span.duration_ms),
372 step,
373 );
374 }
375 }
376 if !profile.steps.is_empty() {
377 let _ = writeln!(out, "\n Per-@step:");
378 for step in &profile.steps {
379 let _ = writeln!(
380 out,
381 " {:<20} {:>10} (LLM {} · tools {} · other {})",
382 step.name,
383 format_secs(step.duration_ms),
384 format_secs(step.llm_ms),
385 format_secs(step.tool_ms),
386 format_secs(step.other_ms),
387 );
388 }
389 }
390 out
391}
392
393fn format_secs(ms: u64) -> String {
394 if ms < 1000 {
395 format!("{ms} ms")
396 } else {
397 format!("{:.3} s", ms as f64 / 1000.0)
398 }
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404 use crate::tracing::SpanKind;
405
406 fn span(span_id: u64, parent_id: Option<u64>, kind: SpanKind, name: &str, dur: u64) -> Span {
407 Span {
408 trace_id: "trace_test".to_string(),
409 span_id,
410 parent_id,
411 kind,
412 name: name.into(),
413 start_ms: 0,
414 start_unix_ms: 0,
415 duration_ms: dur,
416 metadata: BTreeMap::new(),
417 links: Vec::new(),
418 events: Vec::new(),
419 }
420 }
421
422 fn span_with_meta(
423 span_id: u64,
424 parent_id: Option<u64>,
425 kind: SpanKind,
426 name: &str,
427 dur: u64,
428 meta: &[(&str, serde_json::Value)],
429 ) -> Span {
430 let mut s = span(span_id, parent_id, kind, name, dur);
431 for (k, v) in meta {
432 s.metadata.insert((*k).to_string(), v.clone());
433 }
434 s
435 }
436
437 #[test]
438 fn empty_spans_yield_default_profile() {
439 let profile = build(&[]);
440 assert_eq!(profile.total_wall_ms, 0);
441 assert_eq!(profile.first_token_ms, None);
442 assert!(profile.by_kind.is_empty());
443 assert_eq!(profile.residual_ms, 0);
444 }
445
446 #[test]
447 fn buckets_are_sorted_descending_by_total() {
448 let spans = vec![
449 span(1, None, SpanKind::Pipeline, "main", 1000),
450 span(2, Some(1), SpanKind::LlmCall, "llm_call", 600),
451 span(3, Some(1), SpanKind::ToolCall, "mcp_call", 250),
452 span(4, Some(1), SpanKind::ToolCall, "mcp_call", 50),
453 ];
454 let profile = build(&spans);
455 assert_eq!(profile.total_wall_ms, 1000);
456 assert_eq!(profile.by_kind[0].kind, "llm_call");
457 assert_eq!(profile.by_kind[0].total_ms, 600);
458 assert_eq!(profile.by_kind[1].kind, "tool_call");
459 assert_eq!(profile.by_kind[1].total_ms, 300);
460 assert_eq!(profile.by_kind[1].count, 2);
461 assert_eq!(profile.residual_ms, 100);
463 }
464
465 #[test]
466 fn first_token_ms_uses_earliest_llm_span_metadata() {
467 let spans = vec![
468 span(1, None, SpanKind::Pipeline, "main", 1000),
469 span_with_meta(
470 2,
471 Some(1),
472 SpanKind::LlmCall,
473 "llm_call",
474 600,
475 &[("first_token_ms", serde_json::json!(350))],
476 ),
477 span_with_meta(
478 3,
479 Some(1),
480 SpanKind::LlmCall,
481 "llm_call",
482 300,
483 &[("first_token_ms", serde_json::json!(125))],
484 ),
485 ];
486
487 let profile = build(&spans);
488
489 assert_eq!(profile.first_token_ms, Some(125));
490 }
491
492 #[test]
493 fn render_includes_first_token_when_present() {
494 let mut profile = RunProfile {
495 total_wall_ms: 1000,
496 first_token_ms: Some(120),
497 ..RunProfile::default()
498 };
499 profile.by_kind.push(KindBucket {
500 kind: "llm_call".to_string(),
501 total_ms: 900,
502 count: 1,
503 pct_of_wall: 90.0,
504 });
505
506 let rendered = render(&profile);
507
508 assert!(rendered.contains("First token:"));
509 assert!(rendered.contains("120 ms"));
510 }
511
512 #[test]
513 fn top_level_vm_setup_span_gets_its_own_bucket() {
514 let spans = vec![
515 span(1, None, SpanKind::VmSetup, "acp_vm_setup", 20),
516 span(2, None, SpanKind::Pipeline, "main", 80),
517 span(3, Some(2), SpanKind::LlmCall, "llm_call", 50),
518 ];
519 let profile = build(&spans);
520 assert_eq!(profile.total_wall_ms, 100);
521 assert_eq!(profile.residual_ms, 30);
522 assert!(profile
523 .by_kind
524 .iter()
525 .any(|bucket| bucket.kind == "vm_setup" && bucket.total_ms == 20));
526 }
527
528 #[test]
529 fn nested_spans_do_not_double_count_residual() {
530 let spans = vec![
533 span(1, None, SpanKind::Pipeline, "main", 1000),
534 span(2, Some(1), SpanKind::Step, "research", 800),
535 span(3, Some(2), SpanKind::LlmCall, "llm_call", 700),
536 ];
537 let profile = build(&spans);
538 assert_eq!(profile.total_wall_ms, 1000);
539 assert_eq!(profile.residual_ms, 200);
540 }
541
542 #[test]
543 fn step_summaries_split_llm_tool_other() {
544 let spans = vec![
545 span(1, None, SpanKind::Pipeline, "main", 2000),
546 span(2, Some(1), SpanKind::Step, "research", 1500),
547 span(3, Some(2), SpanKind::LlmCall, "llm_call", 900),
548 span(4, Some(2), SpanKind::ToolCall, "mcp_call", 400),
549 ];
550 let profile = build(&spans);
551 assert_eq!(profile.steps.len(), 1);
552 let step = &profile.steps[0];
553 assert_eq!(step.name, "research");
554 assert_eq!(step.duration_ms, 1500);
555 assert_eq!(step.llm_ms, 900);
556 assert_eq!(step.tool_ms, 400);
557 assert_eq!(step.other_ms, 200);
558 assert_eq!(step.llm_calls, 1);
559 assert_eq!(step.tool_calls, 1);
560 }
561
562 #[test]
563 fn top_llm_calls_attribute_enclosing_step_and_model() {
564 let spans = vec![
565 span(1, None, SpanKind::Pipeline, "main", 2000),
566 span(2, Some(1), SpanKind::Step, "research", 1500),
567 span_with_meta(
568 3,
569 Some(2),
570 SpanKind::LlmCall,
571 "llm_call",
572 900,
573 &[("model", serde_json::json!("claude-sonnet-4-6"))],
574 ),
575 span(4, Some(1), SpanKind::LlmCall, "llm_call", 100),
576 ];
577 let profile = build(&spans);
578 assert_eq!(profile.top_llm_calls.len(), 2);
579 assert_eq!(profile.top_llm_calls[0].duration_ms, 900);
580 assert_eq!(profile.top_llm_calls[0].step.as_deref(), Some("research"));
581 assert_eq!(
582 profile.top_llm_calls[0].model.as_deref(),
583 Some("claude-sonnet-4-6")
584 );
585 assert!(profile.top_llm_calls[1].step.is_none());
586 }
587
588 #[test]
589 fn render_produces_nonempty_output_for_real_run() {
590 let spans = vec![
591 span(1, None, SpanKind::Pipeline, "main", 1000),
592 span(2, Some(1), SpanKind::LlmCall, "llm_call", 700),
593 ];
594 let rendered = render(&build(&spans));
595 assert!(rendered.contains("Run profile"));
596 assert!(rendered.contains("llm_call"));
597 assert!(rendered.contains("vm/residual"));
598 }
599
600 #[test]
601 fn render_for_empty_profile_still_produces_header() {
602 let rendered = render(&RunProfile::default());
607 assert!(rendered.contains("Run profile"));
608 assert!(rendered.contains("vm/residual"));
609 }
610
611 #[test]
612 fn aggregate_remaps_duplicate_span_ids_across_runs() {
613 let first = vec![
614 span(1, None, SpanKind::Pipeline, "main", 100),
615 span(2, Some(1), SpanKind::LlmCall, "llm_call", 40),
616 ];
617 let second = vec![
618 span(1, None, SpanKind::Pipeline, "main", 200),
619 span(2, Some(1), SpanKind::ToolCall, "tool", 50),
620 ];
621
622 let profile = build_aggregate(&[first, second]);
623
624 assert_eq!(profile.total_wall_ms, 300);
625 assert_eq!(profile.by_kind.len(), 2);
626 assert!(profile.by_kind.iter().any(|bucket| {
627 bucket.kind == "llm_call" && bucket.total_ms == 40 && bucket.count == 1
628 }));
629 assert!(profile.by_kind.iter().any(|bucket| {
630 bucket.kind == "tool_call" && bucket.total_ms == 50 && bucket.count == 1
631 }));
632 }
633}