Skip to main content

harn_vm/
profile.rs

1//! Categorical profile rollup over completed [`crate::tracing::Span`]s.
2//!
3//! Turns the raw span tree (parent/child, kinds, durations) into a digestible
4//! "where did the time go?" answer for harness writers. This module owns no
5//! state of its own — it consumes the snapshot returned by
6//! [`crate::tracing::peek_spans`] and folds it into a [`RunProfile`] that
7//! callers can render to text or serialize to JSON.
8//!
9//! Top-level wall time and residual ("VM / script overhead") are computed
10//! by summing only spans whose parent is the pipeline root (or `None`),
11//! so nested LLM/tool work isn't double-counted under both its category
12//! and its containing step.
13
14use std::collections::BTreeMap;
15
16use serde::{Deserialize, Serialize};
17
18use crate::tracing::Span;
19
20/// Top-N to surface in the rendered profile. Kept small so the stderr
21/// summary stays scannable.
22const TOP_N: usize = 5;
23
24/// Aggregate breakdown of one run's spans.
25#[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    /// Costliest builtins, when per-builtin recording was on. The categorical
36    /// buckets above fold every builtin that is not an LLM or tool call into
37    /// `residual`, so a run whose time went into one project scan or one
38    /// subprocess reports `vm/residual 100%` and names nothing. Empty when the
39    /// operator did not ask for a profile.
40    #[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
73/// Build a profile from a slice of completed spans. Designed to be called
74/// post-run with `crate::tracing::peek_spans()`. Pure function — does not
75/// touch globals.
76pub fn build(spans: &[Span]) -> RunProfile {
77    if spans.is_empty() {
78        return RunProfile::default();
79    }
80
81    // The pipeline root is conventionally the only top-level VM span. Host
82    // adapters may add sibling setup spans, so top-level non-pipeline spans
83    // contribute to wall time and their own buckets.
84    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    // Residual = wall - sum of "real work" categories at the top level.
93    // Imports/parallel/spawn count as work too; the category buckets cover
94    // them so we subtract depth-1 work plus any host setup sibling spans.
95    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
122/// The costliest builtins recorded during the run, capped for readability.
123/// Empty unless `builtin_profile` recording was enabled.
124fn top_builtins() -> Vec<crate::builtin_profile::BuiltinBucket> {
125    let mut buckets = crate::builtin_profile::snapshot();
126    buckets.truncate(TOP_N);
127    buckets
128}
129
130/// Build one aggregate profile from independent span snapshots.
131///
132/// Each input snapshot can reuse span ids starting at 1. The helper remaps ids
133/// before folding so parent/child relationships do not collide across runs.
134pub 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    // Sum per kind across ALL spans of that kind (any depth). This is
177    // the user's mental model of "how much LLM time was there in this
178    // run?" — overlapping/nested doesn't matter because LLM calls are
179    // leaves.
180    let mut totals: BTreeMap<String, (u64, u64)> = BTreeMap::new();
181    for span in spans {
182        if is_profile_root_span(span) {
183            // Skip the synthetic pipeline span; it's the wall-time
184            // denominator, not a category bucket.
185            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        // "other" approximates VM + script overhead inside the step.
258        // LLM/tool durations can overlap (parallel calls), so clamp at 0.
259        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
290/// Render a profile to a human-readable string suitable for stderr after
291/// a `harn run --profile` invocation. ANSI-styled to match the existing
292/// `--trace` output.
293pub 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        // 1000 wall - (600 + 300) depth-1 = 100 ms residual
462        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        // Pipeline (1000ms) > Step (800ms) > LlmCall (700ms)
531        // Depth-1 sum = 800 (the step), residual = 200, NOT 1000-700-800.
532        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        // When --profile was requested but no spans landed, we still
603        // render the header + a zero-everything residual line — the
604        // user explicitly asked for output and an empty string would
605        // look like the flag did nothing.
606        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}