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    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
64/// Build a profile from a slice of completed spans. Designed to be called
65/// post-run with `crate::tracing::peek_spans()`. Pure function — does not
66/// touch globals.
67pub fn build(spans: &[Span]) -> RunProfile {
68    if spans.is_empty() {
69        return RunProfile::default();
70    }
71
72    // The pipeline root is conventionally the only top-level span. If a
73    // run somehow produced multiple, we sum them — accurate for total
74    // wall time, just unusual.
75    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    // Residual = wall - sum of "real work" categories at the top level.
84    // Imports/parallel/spawn count as work too; the category buckets cover
85    // them so we just subtract everything that was already accounted for
86    // at depth 1.
87    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    // Sum per kind across ALL spans of that kind (any depth). This is
118    // the user's mental model of "how much LLM time was there in this
119    // run?" — overlapping/nested doesn't matter because LLM calls are
120    // leaves.
121    let mut totals: BTreeMap<String, (u64, u64)> = BTreeMap::new();
122    for span in spans {
123        if span.parent_id.is_none() {
124            // Skip the synthetic pipeline span; it's the wall-time
125            // denominator, not a category bucket.
126            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        // "other" approximates VM + script overhead inside the step.
199        // LLM/tool durations can overlap (parallel calls), so clamp at 0.
200        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
231/// Render a profile to a human-readable string suitable for stderr after
232/// a `harn run --profile` invocation. ANSI-styled to match the existing
233/// `--trace` output.
234pub 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        // 1000 wall - (600 + 300) depth-1 = 100 ms residual
380        assert_eq!(profile.residual_ms, 100);
381    }
382
383    #[test]
384    fn nested_spans_do_not_double_count_residual() {
385        // Pipeline (1000ms) > Step (800ms) > LlmCall (700ms)
386        // Depth-1 sum = 800 (the step), residual = 200, NOT 1000-700-800.
387        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        // When --profile was requested but no spans landed, we still
458        // render the header + a zero-everything residual line — the
459        // user explicitly asked for output and an empty string would
460        // look like the flag did nothing.
461        let rendered = render(&RunProfile::default());
462        assert!(rendered.contains("Run profile"));
463        assert!(rendered.contains("vm/residual"));
464    }
465}