Skip to main content

harn_vm/
tracing.rs

1//! Pipeline Observability: structured tracing spans with parent/child relationships.
2//!
3//! When tracing is enabled (`vm.enable_tracing()`), the VM automatically emits
4//! spans for pipeline execution, function calls, LLM calls, tool invocations,
5//! imports, and async operations. Spans form a tree via parent_span_id.
6//!
7//! Access via builtins: `trace_spans()` returns all completed spans,
8//! `trace_summary()` returns a formatted summary.
9
10use std::cell::RefCell;
11use std::collections::BTreeMap;
12use std::time::{Instant, SystemTime, UNIX_EPOCH};
13
14use crate::value::VmValue;
15
16/// The kind of operation a span represents.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum SpanKind {
19    Pipeline,
20    FnCall,
21    LlmCall,
22    ToolCall,
23    Import,
24    Parallel,
25    Spawn,
26    /// A `@step`-annotated function while its frame is on the call stack.
27    Step,
28    /// Host-side VM setup before user bytecode starts executing.
29    VmSetup,
30    /// Cooperative worker suspension while the durable checkpoint is written.
31    Suspension,
32    /// Worker resumption after a cooperative suspension.
33    Resume,
34    /// Pipeline drain / settlement phase.
35    Drain,
36    /// One drain settlement decision.
37    DrainDecision,
38    /// `pool.submit()` boundary — accepted, rejected, or queued (PL-06).
39    PoolSubmit,
40    /// Pool worker picks the task out of the queue (PL-06). Links back to
41    /// the originating `PoolSubmit` span across the async boundary so
42    /// queue dwell time can be reconstructed from a single trace.
43    PoolDequeue,
44    /// `emit_channel(...)` boundary — opened at `emit_channel`, closed
45    /// after the durable append + trigger fan-out finishes (CH-06 / #1877).
46    ChannelEmit,
47    /// Channel-source trigger match boundary — opened at trigger fan-out
48    /// just before the handler is invoked, closed once dispatch finishes.
49    /// Links back to the originating `ChannelEmit` span (multi-link for
50    /// batched / aggregated triggers).
51    ChannelMatch,
52    /// Script-opened user timing span via `std/timing`. Modeled as an
53    /// OTel INTERNAL span — distinct from `FnCall` so OTel exporters and
54    /// `harn run --profile-json` do not confuse them with LLM/tool work.
55    UserTiming,
56    /// A model routing / escalation decision — the agent switched the
57    /// serving model mid-run. Metadata carries `from_model`, `to_model`,
58    /// and `reason` (see [`meta`]). Emitted as a zero-duration marker at
59    /// the decision point so viewers can annotate the flame graph with the
60    /// switch instead of inferring it from adjacent `llm_call` models.
61    ModelRoute,
62    /// A batch of tools promoted into the active surface (MCP bootstrap,
63    /// skill activation, or a search-driven mount). Metadata carries
64    /// `tool_names`, `tool_count`, `source`, and an optional `detail`.
65    ToolMount,
66    /// A single deferred tool schema promoted via `tool_search`. Metadata
67    /// carries `tool_name`, `query`, and the match `score`.
68    DeferredToolLoad,
69}
70
71impl SpanKind {
72    pub fn as_str(self) -> &'static str {
73        match self {
74            Self::Pipeline => "pipeline",
75            Self::FnCall => "fn_call",
76            Self::LlmCall => "llm_call",
77            Self::ToolCall => "tool_call",
78            Self::Import => "import",
79            Self::Parallel => "parallel",
80            Self::Spawn => "spawn",
81            Self::Step => "step",
82            Self::VmSetup => "vm_setup",
83            Self::Suspension => "suspension",
84            Self::Resume => "resume",
85            Self::Drain => "drain",
86            Self::DrainDecision => "drain_decision",
87            Self::PoolSubmit => "pool_submit",
88            Self::PoolDequeue => "pool_dequeue",
89            Self::ChannelEmit => "channel_emit",
90            Self::ChannelMatch => "channel_match",
91            Self::UserTiming => "user_timing",
92            Self::ModelRoute => "model_route",
93            Self::ToolMount => "tool_mount",
94            Self::DeferredToolLoad => "deferred_tool_load",
95        }
96    }
97}
98
99/// Canonical metadata keys for VM trace spans. Downstream viewers (Burin
100/// portal, harn-cloud dashboard) key off these exact strings to render
101/// token flame graphs and tool-selection events, so they are defined once
102/// here rather than retyped at each emission site.
103pub mod meta {
104    // llm_call token + cost attribution.
105    pub const MODEL: &str = "model";
106    pub const PROVIDER: &str = "provider";
107    pub const INPUT_TOKENS: &str = "input_tokens";
108    pub const OUTPUT_TOKENS: &str = "output_tokens";
109    pub const CACHE_READ_TOKENS: &str = "cache_read_tokens";
110    pub const CACHE_WRITE_TOKENS: &str = "cache_write_tokens";
111    pub const COST_USD: &str = "cost_usd";
112
113    // model_route.
114    pub const FROM_MODEL: &str = "from_model";
115    pub const TO_MODEL: &str = "to_model";
116    pub const REASON: &str = "reason";
117
118    // tool_mount.
119    pub const TOOL_NAMES: &str = "tool_names";
120    pub const TOOL_COUNT: &str = "tool_count";
121    pub const SOURCE: &str = "source";
122    pub const DETAIL: &str = "detail";
123
124    // deferred_tool_load.
125    pub const TOOL_NAME: &str = "tool_name";
126    pub const QUERY: &str = "query";
127    pub const SCORE: &str = "score";
128}
129
130/// Structured per-LLM-call token and cost attribution for an `llm_call`
131/// span. Built once at the call site and lowered to metadata pairs via
132/// [`LlmCallUsage::metadata_pairs`] so every emission uses the canonical
133/// [`meta`] keys instead of ad-hoc strings. `cost_usd` is `None` when the
134/// (provider, model) pair has no catalog pricing.
135#[derive(Debug, Clone, Default, PartialEq)]
136pub struct LlmCallUsage {
137    pub model: String,
138    pub provider: String,
139    pub input_tokens: i64,
140    pub output_tokens: i64,
141    pub cache_read_tokens: i64,
142    pub cache_write_tokens: i64,
143    pub cost_usd: Option<f64>,
144}
145
146impl LlmCallUsage {
147    /// Lower to `(key, value)` pairs keyed by the canonical [`meta`]
148    /// constants, suitable for `annotate_current_span` / `span_set_metadata`.
149    pub fn metadata_pairs(&self) -> Vec<(&'static str, serde_json::Value)> {
150        let mut pairs = vec![
151            (meta::MODEL, serde_json::json!(self.model)),
152            (meta::PROVIDER, serde_json::json!(self.provider)),
153            (meta::INPUT_TOKENS, serde_json::json!(self.input_tokens)),
154            (meta::OUTPUT_TOKENS, serde_json::json!(self.output_tokens)),
155            (
156                meta::CACHE_READ_TOKENS,
157                serde_json::json!(self.cache_read_tokens),
158            ),
159            (
160                meta::CACHE_WRITE_TOKENS,
161                serde_json::json!(self.cache_write_tokens),
162            ),
163        ];
164        if let Some(cost) = self.cost_usd {
165            pairs.push((meta::COST_USD, serde_json::json!(cost)));
166        }
167        pairs
168    }
169}
170
171/// Emit a zero-duration marker span of `kind` carrying `metadata`. Marker
172/// spans model point-in-time telemetry events (model routing, tool mounts,
173/// deferred-tool promotions) that have no meaningful duration but need to
174/// appear in the trace tree at their causal position under the current
175/// active span. No-op when tracing is disabled.
176pub fn emit_marker_span(
177    kind: SpanKind,
178    name: impl Into<String>,
179    metadata: Vec<(&str, serde_json::Value)>,
180) {
181    let span_id = span_start(kind, name.into());
182    if span_id == 0 {
183        return;
184    }
185    for (key, value) in metadata {
186        span_set_metadata(span_id, key, value);
187    }
188    span_end(span_id);
189}
190
191/// Emit a [`SpanKind::ModelRoute`] marker for a model switch / escalation.
192pub fn emit_model_route(from_model: &str, to_model: &str, reason: &str) {
193    emit_marker_span(
194        SpanKind::ModelRoute,
195        "model_route",
196        vec![
197            (meta::FROM_MODEL, serde_json::json!(from_model)),
198            (meta::TO_MODEL, serde_json::json!(to_model)),
199            (meta::REASON, serde_json::json!(reason)),
200        ],
201    );
202}
203
204/// Emit a [`SpanKind::ToolMount`] marker for a batch of tools promoted into
205/// the active surface. `source` is the promotion origin (`"mcp"`,
206/// `"skill"`, `"search"`); `detail` optionally names the concrete source
207/// (e.g. an MCP server name).
208pub fn emit_tool_mount(tool_names: &[String], source: &str, detail: Option<&str>) {
209    if tool_names.is_empty() {
210        return;
211    }
212    let mut metadata = vec![
213        (meta::TOOL_NAMES, serde_json::json!(tool_names)),
214        (meta::TOOL_COUNT, serde_json::json!(tool_names.len())),
215        (meta::SOURCE, serde_json::json!(source)),
216    ];
217    if let Some(detail) = detail {
218        metadata.push((meta::DETAIL, serde_json::json!(detail)));
219    }
220    emit_marker_span(SpanKind::ToolMount, "tool_mount", metadata);
221}
222
223/// Emit a [`SpanKind::DeferredToolLoad`] marker for a single deferred tool
224/// schema promoted via `tool_search`.
225pub fn emit_deferred_tool_load(tool_name: &str, query: &str, score: Option<f64>) {
226    let mut metadata = vec![
227        (meta::TOOL_NAME, serde_json::json!(tool_name)),
228        (meta::QUERY, serde_json::json!(query)),
229    ];
230    if let Some(score) = score {
231        metadata.push((meta::SCORE, serde_json::json!(score)));
232    }
233    emit_marker_span(SpanKind::DeferredToolLoad, "deferred_tool_load", metadata);
234}
235
236/// Link to a span that is causally related but not the parent.
237#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
238#[serde(default)]
239pub struct SpanLink {
240    pub trace_id: String,
241    pub span_id: String,
242    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
243    pub attributes: BTreeMap<String, String>,
244}
245
246impl SpanLink {
247    pub fn new(trace_id: impl Into<String>, span_id: impl Into<String>) -> Self {
248        Self {
249            trace_id: trace_id.into(),
250            span_id: span_id.into(),
251            attributes: BTreeMap::new(),
252        }
253    }
254
255    pub fn with_attributes(mut self, attributes: BTreeMap<String, String>) -> Self {
256        self.attributes = attributes;
257        self
258    }
259}
260
261/// One sub-phase annotation attached to a span. Modeled after OTel span
262/// events: a named checkpoint with optional structured attributes that
263/// piggy-backs on the enclosing span rather than allocating a new one.
264#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
265#[serde(default)]
266pub struct SpanEvent {
267    pub name: String,
268    /// Wall-clock time of the event in milliseconds since the UNIX epoch.
269    pub time_unix_ms: u64,
270    /// Monotonic offset from the parent span's start, in milliseconds.
271    pub offset_ms: u64,
272    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
273    pub attributes: BTreeMap<String, serde_json::Value>,
274}
275
276/// A completed tracing span.
277#[derive(Debug, Clone)]
278pub struct Span {
279    pub trace_id: String,
280    pub span_id: u64,
281    pub parent_id: Option<u64>,
282    pub kind: SpanKind,
283    pub name: String,
284    /// Monotonic offset from the collector's epoch, in milliseconds.
285    pub start_ms: u64,
286    /// Wall-clock start in milliseconds since the UNIX epoch. Recorded
287    /// once at `start` for external correlation; duration is always
288    /// derived from the monotonic clock, not from wall-clock end - start.
289    pub start_unix_ms: u64,
290    pub duration_ms: u64,
291    pub metadata: BTreeMap<String, serde_json::Value>,
292    pub links: Vec<SpanLink>,
293    pub events: Vec<SpanEvent>,
294}
295
296/// An in-flight span (not yet completed).
297struct OpenSpan {
298    trace_id: String,
299    span_id: u64,
300    parent_id: Option<u64>,
301    kind: SpanKind,
302    name: String,
303    started_at: Instant,
304    /// Mock-monotonic snapshot at start, captured only when a
305    /// `clock_mock` override was active. Pairs with the closing snapshot
306    /// to compute deterministic durations under `mock_time(...)`.
307    started_at_mock_mono_ms: Option<u64>,
308    start_unix_ms: u64,
309    metadata: BTreeMap<String, serde_json::Value>,
310    links: Vec<SpanLink>,
311    events: Vec<SpanEvent>,
312}
313
314/// Thread-local span collector. Accumulates completed spans and tracks the
315/// active span stack for automatic parent assignment.
316pub struct SpanCollector {
317    trace_id: String,
318    next_id: u64,
319    /// Stack of open span IDs — the top is the current active span.
320    active_stack: Vec<u64>,
321    /// Open (in-flight) spans keyed by ID.
322    open: BTreeMap<u64, OpenSpan>,
323    /// Completed spans in chronological order.
324    completed: Vec<Span>,
325    /// Epoch for relative timing.
326    epoch: Instant,
327}
328
329impl Default for SpanCollector {
330    fn default() -> Self {
331        Self::new()
332    }
333}
334
335impl SpanCollector {
336    pub fn new() -> Self {
337        Self {
338            next_id: 1,
339            trace_id: format!("trace_{}", uuid::Uuid::now_v7()),
340            active_stack: Vec::new(),
341            open: BTreeMap::new(),
342            completed: Vec::new(),
343            epoch: Instant::now(),
344        }
345    }
346
347    /// Start a new span. Returns the span ID.
348    pub fn start(&mut self, kind: SpanKind, name: String) -> u64 {
349        let parent_id = self.active_stack.last().copied();
350        self.start_with_parent(kind, name, Vec::new(), parent_id)
351    }
352
353    /// Start a new span with non-parent causal links. Returns the span ID.
354    pub fn start_with_links(&mut self, kind: SpanKind, name: String, links: Vec<SpanLink>) -> u64 {
355        let parent_id = self.active_stack.last().copied();
356        self.start_with_parent(kind, name, links, parent_id)
357    }
358
359    /// Start a root span with non-parent causal links. Returns the span ID.
360    pub fn start_detached_with_links(
361        &mut self,
362        kind: SpanKind,
363        name: String,
364        links: Vec<SpanLink>,
365    ) -> u64 {
366        self.start_with_parent(kind, name, links, None)
367    }
368
369    fn start_with_parent(
370        &mut self,
371        kind: SpanKind,
372        name: String,
373        links: Vec<SpanLink>,
374        parent_id: Option<u64>,
375    ) -> u64 {
376        let id = self.next_id;
377        self.next_id += 1;
378        let now = Instant::now();
379        let started_at_mock_mono_ms = mock_monotonic_ms();
380        let start_unix_ms = wall_clock_ms();
381
382        let mut event_metadata = BTreeMap::new();
383        if !links.is_empty() {
384            event_metadata.insert("links".to_string(), serde_json::json!(links));
385        }
386        crate::events::emit_span_start(id, parent_id, &name, kind.as_str(), event_metadata);
387
388        self.open.insert(
389            id,
390            OpenSpan {
391                trace_id: self.trace_id.clone(),
392                span_id: id,
393                parent_id,
394                kind,
395                name,
396                started_at: now,
397                started_at_mock_mono_ms,
398                start_unix_ms,
399                metadata: BTreeMap::new(),
400                links,
401                events: Vec::new(),
402            },
403        );
404        self.active_stack.push(id);
405        id
406    }
407
408    /// Attach metadata to an open span.
409    pub fn set_metadata(&mut self, span_id: u64, key: &str, value: serde_json::Value) {
410        if let Some(span) = self.open.get_mut(&span_id) {
411            span.metadata.insert(key.to_string(), value);
412        }
413    }
414
415    /// Attach metadata to an open or completed span unless the key exists.
416    pub fn attach_metadata_if_absent(&mut self, span_id: u64, key: &str, value: serde_json::Value) {
417        if let Some(span) = self.open.get_mut(&span_id) {
418            span.metadata.entry(key.to_string()).or_insert(value);
419            return;
420        }
421        if let Some(span) = self
422            .completed
423            .iter_mut()
424            .rev()
425            .find(|span| span.span_id == span_id)
426        {
427            span.metadata.entry(key.to_string()).or_insert(value);
428        }
429    }
430
431    /// Append a sub-phase annotation to an open span. Returns `true` if
432    /// the event was attached; `false` if `span_id` does not match any
433    /// open span (already closed or never opened).
434    pub fn record_event(
435        &mut self,
436        span_id: u64,
437        name: String,
438        attributes: BTreeMap<String, serde_json::Value>,
439    ) -> bool {
440        let Some(span) = self.open.get_mut(&span_id) else {
441            return false;
442        };
443        let offset_ms = match (span.started_at_mock_mono_ms, mock_monotonic_ms()) {
444            (Some(start), Some(now)) => now.saturating_sub(start),
445            _ => span.started_at.elapsed().as_millis() as u64,
446        };
447        span.events.push(SpanEvent {
448            name,
449            time_unix_ms: wall_clock_ms(),
450            offset_ms,
451            attributes,
452        });
453        true
454    }
455
456    /// Read the wall-clock start of an open span.
457    pub fn open_start_unix_ms(&self, span_id: u64) -> Option<u64> {
458        self.open.get(&span_id).map(|span| span.start_unix_ms)
459    }
460
461    /// End a span. Moves it from open to completed and returns the
462    /// finalized span so callers (e.g. `std/timing`) can read its
463    /// `duration_ms` directly without re-scanning `take_spans()`.
464    pub fn end(&mut self, span_id: u64) -> Option<Span> {
465        let span = self.open.remove(&span_id)?;
466        let start_ms = span.started_at.duration_since(self.epoch).as_millis() as u64;
467        let duration_ms = match (span.started_at_mock_mono_ms, mock_monotonic_ms()) {
468            (Some(start), Some(end)) => end.saturating_sub(start),
469            _ => span.started_at.elapsed().as_millis() as u64,
470        };
471
472        let mut end_meta = span.metadata.clone();
473        end_meta.insert(
474            "duration_ms".to_string(),
475            serde_json::Value::Number(serde_json::Number::from(duration_ms)),
476        );
477        crate::events::emit_span_end(span_id, end_meta);
478
479        let completed = Span {
480            trace_id: span.trace_id,
481            span_id: span.span_id,
482            parent_id: span.parent_id,
483            kind: span.kind,
484            name: span.name,
485            start_ms,
486            start_unix_ms: span.start_unix_ms,
487            duration_ms,
488            metadata: span.metadata,
489            links: span.links,
490            events: span.events,
491        };
492        self.completed.push(completed.clone());
493
494        if let Some(pos) = self.active_stack.iter().rposition(|&id| id == span_id) {
495            self.active_stack.remove(pos);
496        }
497        Some(completed)
498    }
499
500    /// Get the current active span ID (if any).
501    pub fn current_span_id(&self) -> Option<u64> {
502        self.active_stack.last().copied()
503    }
504
505    /// Build a serializable link for an open span.
506    pub fn span_link(&self, span_id: u64) -> Option<SpanLink> {
507        self.open
508            .get(&span_id)
509            .map(|span| SpanLink::new(span.trace_id.clone(), span.span_id.to_string()))
510    }
511
512    /// Build a serializable link for the current active span.
513    pub fn current_span_link(&self) -> Option<SpanLink> {
514        self.current_span_id()
515            .and_then(|span_id| self.span_link(span_id))
516    }
517
518    /// Take all completed spans (drains the collector).
519    pub fn take_spans(&mut self) -> Vec<Span> {
520        std::mem::take(&mut self.completed)
521    }
522
523    /// Peek at all completed spans (non-destructive).
524    pub fn spans(&self) -> &[Span] {
525        &self.completed
526    }
527
528    /// Reset the collector.
529    pub fn reset(&mut self) {
530        self.active_stack.clear();
531        self.open.clear();
532        self.completed.clear();
533        self.next_id = 1;
534        self.trace_id = format!("trace_{}", uuid::Uuid::now_v7());
535        self.epoch = Instant::now();
536    }
537}
538
539thread_local! {
540    static COLLECTOR: RefCell<SpanCollector> = RefCell::new(SpanCollector::new());
541    static TRACING_ENABLED: RefCell<bool> = const { RefCell::new(false) };
542}
543
544/// Best-effort wall-clock millis since the UNIX epoch. Honors an active
545/// `clock_mock` override so spans recorded inside `mock_time(...)` blocks
546/// align with the rest of the runtime's clock reads; returns 0 only if
547/// the host clock is behind the epoch (e.g. unusual sandbox shims).
548fn wall_clock_ms() -> u64 {
549    if let Some(mock) = crate::clock_mock::active_mock_clock() {
550        return mock.now_wall_ms() as u64;
551    }
552    SystemTime::now()
553        .duration_since(UNIX_EPOCH)
554        .map(|d| d.as_millis() as u64)
555        .unwrap_or(0)
556}
557
558/// Mock-aware monotonic snapshot. Returns `Some(ms)` when a
559/// `clock_mock` override is active, `None` otherwise. Span lifecycle
560/// pairs the start/end snapshots so durations recorded under
561/// `mock_time(...)` reflect `advance_time(...)` instead of real
562/// wall-clock progress; spans without an active mock at start fall
563/// through to the standard `Instant::elapsed` path on close.
564fn mock_monotonic_ms() -> Option<u64> {
565    crate::clock_mock::active_mock_clock().map(|mock| mock.now_monotonic_ms() as u64)
566}
567
568/// Enable or disable VM tracing for the current thread.
569pub fn set_tracing_enabled(enabled: bool) {
570    TRACING_ENABLED.with(|e| *e.borrow_mut() = enabled);
571    if enabled {
572        COLLECTOR.with(|c| c.borrow_mut().reset());
573    }
574}
575
576/// Enable VM tracing for the current thread WITHOUT clobbering an
577/// in-flight trace. Unlike [`set_tracing_enabled(true)`], this only
578/// resets the span collector when it is idle (no open spans); when a
579/// caller is mid-trace — e.g. holding an enclosing `timed(...)` /
580/// `start_timing` span across a nested `workflow_execute` run — the
581/// collector is left intact so the caller's open span and its completed
582/// siblings survive. Note that user-timing spans populate the collector
583/// regardless of the enabled flag (see [`span_start_user_timing`]), so
584/// the open-span check (not [`is_tracing_enabled`]) is what distinguishes
585/// "someone is mid-trace" from "clean slate". When idle, behavior is
586/// identical to `set_tracing_enabled(true)`.
587pub fn enable_tracing_preserving_open_spans() {
588    let has_open_spans = COLLECTOR.with(|c| !c.borrow().open.is_empty());
589    TRACING_ENABLED.with(|e| *e.borrow_mut() = true);
590    if !has_open_spans {
591        COLLECTOR.with(|c| c.borrow_mut().reset());
592    }
593}
594
595/// Check if tracing is enabled.
596pub fn is_tracing_enabled() -> bool {
597    TRACING_ENABLED.with(|e| *e.borrow())
598}
599
600/// Start a span (no-op if tracing disabled). Returns span ID or 0.
601pub fn span_start(kind: SpanKind, name: String) -> u64 {
602    if !is_tracing_enabled() {
603        return 0;
604    }
605    COLLECTOR.with(|c| c.borrow_mut().start(kind, name))
606}
607
608/// Start a span with non-parent causal links (no-op if tracing disabled).
609pub fn span_start_with_links(kind: SpanKind, name: String, links: Vec<SpanLink>) -> u64 {
610    if !is_tracing_enabled() {
611        return 0;
612    }
613    COLLECTOR.with(|c| c.borrow_mut().start_with_links(kind, name, links))
614}
615
616/// Start a root span with non-parent causal links (no-op if tracing disabled).
617pub fn span_start_detached_with_links(kind: SpanKind, name: String, links: Vec<SpanLink>) -> u64 {
618    if !is_tracing_enabled() {
619        return 0;
620    }
621    COLLECTOR.with(|c| c.borrow_mut().start_detached_with_links(kind, name, links))
622}
623
624/// Attach metadata to an open span (no-op if span_id is 0).
625pub fn span_set_metadata(span_id: u64, key: &str, value: serde_json::Value) {
626    if span_id == 0 {
627        return;
628    }
629    COLLECTOR.with(|c| c.borrow_mut().set_metadata(span_id, key, value));
630}
631
632/// End a span (no-op if span_id is 0). Returns the finalized span when
633/// the id was a live open span.
634pub fn span_end(span_id: u64) -> Option<Span> {
635    if span_id == 0 {
636        return None;
637    }
638    COLLECTOR.with(|c| c.borrow_mut().end(span_id))
639}
640
641/// Start a user-timing span. Unlike [`span_start`], this always records
642/// regardless of [`is_tracing_enabled`] — `std/timing` callers depend on
643/// the returned `duration_ms` to function as a primitive replacement for
644/// hand-rolled `now_ms()` subtraction.
645pub fn span_start_user_timing(
646    name: String,
647    attrs: BTreeMap<String, serde_json::Value>,
648) -> (u64, String, Option<u64>, u64) {
649    COLLECTOR.with(|c| {
650        let mut c = c.borrow_mut();
651        let id = c.start(SpanKind::UserTiming, name);
652        for (key, value) in attrs {
653            c.set_metadata(id, &key, value);
654        }
655        let parent = c.open.get(&id).and_then(|span| span.parent_id);
656        let trace_id = c
657            .open
658            .get(&id)
659            .map(|span| span.trace_id.clone())
660            .unwrap_or_default();
661        let start_unix_ms = c.open_start_unix_ms(id).unwrap_or(0);
662        (id, trace_id, parent, start_unix_ms)
663    })
664}
665
666/// Record a sub-phase event on an open span. No-op when `span_id` is 0
667/// or already closed; returns whether the event was attached so callers
668/// can surface no-op feedback.
669pub fn span_record_event(
670    span_id: u64,
671    name: String,
672    attributes: BTreeMap<String, serde_json::Value>,
673) -> bool {
674    if span_id == 0 {
675        return false;
676    }
677    COLLECTOR.with(|c| c.borrow_mut().record_event(span_id, name, attributes))
678}
679
680/// Attach metadata to an open span. No-op when `span_id` is 0 or already
681/// closed.
682pub fn span_attach_metadata(span_id: u64, key: &str, value: serde_json::Value) {
683    if span_id == 0 {
684        return;
685    }
686    COLLECTOR.with(|c| c.borrow_mut().set_metadata(span_id, key, value));
687}
688
689/// Attach metadata to an open or completed span unless the key already exists.
690pub fn span_attach_metadata_if_absent(span_id: u64, key: &str, value: serde_json::Value) {
691    if span_id == 0 {
692        return;
693    }
694    COLLECTOR.with(|c| {
695        c.borrow_mut()
696            .attach_metadata_if_absent(span_id, key, value);
697    });
698}
699
700/// Get the currently active span id, if tracing is enabled and a span is open.
701pub fn current_span_id() -> Option<u64> {
702    if !is_tracing_enabled() {
703        return None;
704    }
705    COLLECTOR.with(|c| c.borrow().current_span_id())
706}
707
708/// Return a link reference for an open span.
709pub fn span_link(span_id: u64) -> Option<SpanLink> {
710    if span_id == 0 || !is_tracing_enabled() {
711        return None;
712    }
713    COLLECTOR.with(|c| c.borrow().span_link(span_id))
714}
715
716/// Return a link reference for the current active span.
717pub fn current_span_link() -> Option<SpanLink> {
718    if !is_tracing_enabled() {
719        return None;
720    }
721    COLLECTOR.with(|c| c.borrow().current_span_link())
722}
723
724/// Take all completed spans.
725pub fn take_spans() -> Vec<Span> {
726    COLLECTOR.with(|c| c.borrow_mut().take_spans())
727}
728
729/// Peek at completed spans (cloned).
730pub fn peek_spans() -> Vec<Span> {
731    COLLECTOR.with(|c| c.borrow().spans().to_vec())
732}
733
734/// Reset the tracing collector.
735pub fn reset_tracing() {
736    COLLECTOR.with(|c| c.borrow_mut().reset());
737}
738
739/// Convert a span to a VmValue dict for user access.
740pub fn span_to_vm_value(span: &Span) -> VmValue {
741    let mut d: BTreeMap<String, VmValue> = BTreeMap::new();
742    d.insert(
743        "trace_id".into(),
744        VmValue::String(arcstr::ArcStr::from(span.trace_id.as_str())),
745    );
746    d.insert("span_id".into(), VmValue::Int(span.span_id as i64));
747    d.insert(
748        "parent_id".into(),
749        span.parent_id
750            .map(|id| VmValue::Int(id as i64))
751            .unwrap_or(VmValue::Nil),
752    );
753    d.insert(
754        "kind".into(),
755        VmValue::String(arcstr::ArcStr::from(span.kind.as_str())),
756    );
757    d.insert(
758        "name".into(),
759        VmValue::String(arcstr::ArcStr::from(span.name.as_str())),
760    );
761    d.insert("start_ms".into(), VmValue::Int(span.start_ms as i64));
762    d.insert(
763        "start_unix_ms".into(),
764        VmValue::Int(span.start_unix_ms as i64),
765    );
766    d.insert("duration_ms".into(), VmValue::Int(span.duration_ms as i64));
767
768    if !span.metadata.is_empty() {
769        let meta: crate::value::DictMap = span
770            .metadata
771            .iter()
772            .map(|(k, v)| {
773                (
774                    crate::value::intern_key(k),
775                    crate::stdlib::json_to_vm_value(v),
776                )
777            })
778            .collect();
779        d.insert("metadata".into(), VmValue::dict(meta));
780    }
781    if !span.links.is_empty() {
782        d.insert(
783            "links".into(),
784            crate::stdlib::json_to_vm_value(&serde_json::json!(span.links)),
785        );
786    }
787    if !span.events.is_empty() {
788        d.insert(
789            "events".into(),
790            crate::stdlib::json_to_vm_value(&serde_json::json!(span.events)),
791        );
792    }
793
794    VmValue::dict(d)
795}
796
797/// Generate a formatted summary of all spans.
798pub fn format_summary() -> String {
799    let spans = peek_spans();
800    if spans.is_empty() {
801        return "No spans recorded.".into();
802    }
803
804    let mut lines = Vec::new();
805    let total_ms: u64 = spans
806        .iter()
807        .filter(|s| s.parent_id.is_none())
808        .map(|s| s.duration_ms)
809        .sum();
810
811    lines.push(format!("Trace: {} spans, {total_ms}ms total", spans.len()));
812    lines.push(String::new());
813
814    fn print_tree(spans: &[Span], parent_id: Option<u64>, depth: usize, lines: &mut Vec<String>) {
815        let children: Vec<&Span> = spans.iter().filter(|s| s.parent_id == parent_id).collect();
816        for span in children {
817            let indent = "  ".repeat(depth);
818            let meta_str = if span.metadata.is_empty() {
819                String::new()
820            } else {
821                let parts: Vec<String> = span
822                    .metadata
823                    .iter()
824                    .map(|(k, v)| format!("{k}={v}"))
825                    .collect();
826                format!(" ({})", parts.join(", "))
827            };
828            lines.push(format!(
829                "{indent}{} {} {}ms{meta_str}",
830                span.kind.as_str(),
831                span.name,
832                span.duration_ms,
833            ));
834            print_tree(spans, Some(span.span_id), depth + 1, lines);
835        }
836    }
837
838    print_tree(&spans, None, 0, &mut lines);
839    lines.join("\n")
840}
841
842#[cfg(test)]
843mod tests {
844    use super::*;
845
846    #[test]
847    fn test_span_collector_basic() {
848        let mut c = SpanCollector::new();
849        let id = c.start(SpanKind::Pipeline, "main".into());
850        assert_eq!(id, 1);
851        assert_eq!(c.current_span_id(), Some(1));
852        assert!(c.span_link(id).is_some());
853        c.end(id);
854        assert_eq!(c.current_span_id(), None);
855        assert_eq!(c.spans().len(), 1);
856        assert_eq!(c.spans()[0].name, "main");
857        assert_eq!(c.spans()[0].parent_id, None);
858    }
859
860    #[test]
861    fn test_span_parent_child() {
862        let mut c = SpanCollector::new();
863        let parent = c.start(SpanKind::Pipeline, "main".into());
864        let child = c.start(SpanKind::FnCall, "helper".into());
865        c.end(child);
866        c.end(parent);
867        assert_eq!(c.spans().len(), 2);
868        assert_eq!(c.spans()[0].parent_id, Some(parent));
869        assert_eq!(c.spans()[1].parent_id, None);
870    }
871
872    #[test]
873    fn test_span_metadata() {
874        let mut c = SpanCollector::new();
875        let id = c.start(SpanKind::LlmCall, "gpt-4".into());
876        c.set_metadata(id, "tokens", serde_json::json!(100));
877        c.end(id);
878        assert_eq!(c.spans()[0].metadata["tokens"], serde_json::json!(100));
879    }
880
881    #[test]
882    fn test_completed_span_metadata_can_be_attached_late() {
883        let mut c = SpanCollector::new();
884        let id = c.start(SpanKind::LlmCall, "gpt-4".into());
885        c.end(id);
886
887        c.attach_metadata_if_absent(id, "first_token_ms", serde_json::json!(125));
888        c.attach_metadata_if_absent(id, "first_token_ms", serde_json::json!(250));
889
890        assert_eq!(
891            c.spans()[0].metadata["first_token_ms"],
892            serde_json::json!(125)
893        );
894    }
895
896    #[test]
897    fn test_span_links_are_preserved() {
898        let mut c = SpanCollector::new();
899        let parent = c.start(SpanKind::Suspension, "suspend worker".into());
900        let link = c.span_link(parent).expect("link for open span");
901        c.end(parent);
902
903        let child = c.start_with_links(SpanKind::Resume, "resume worker".into(), vec![link]);
904        c.end(child);
905
906        assert_eq!(c.spans().len(), 2);
907        assert_eq!(c.spans()[1].parent_id, None);
908        assert_eq!(c.spans()[1].links.len(), 1);
909        assert_eq!(c.spans()[1].links[0].span_id, parent.to_string());
910    }
911
912    #[test]
913    fn test_detached_span_links_do_not_inherit_active_parent() {
914        let mut c = SpanCollector::new();
915        let pipeline = c.start(SpanKind::Pipeline, "pipeline".into());
916        let link = c.span_link(pipeline).expect("pipeline link");
917        let drain = c.start_detached_with_links(SpanKind::Drain, "drain".into(), vec![link]);
918        c.end(drain);
919        c.end(pipeline);
920
921        let drain = c
922            .spans()
923            .iter()
924            .find(|span| span.kind == SpanKind::Drain)
925            .expect("drain span");
926        assert_eq!(drain.parent_id, None);
927        assert_eq!(drain.links.len(), 1);
928        assert_eq!(drain.links[0].span_id, pipeline.to_string());
929    }
930
931    #[test]
932    fn test_noop_when_disabled() {
933        set_tracing_enabled(false);
934        let id = span_start(SpanKind::Pipeline, "test".into());
935        assert_eq!(id, 0);
936        assert!(span_end(id).is_none());
937    }
938
939    #[test]
940    fn test_user_timing_records_when_tracing_disabled() {
941        // UserTiming is the substrate behind `std/timing`. Script
942        // callers depend on a real `duration_ms` even when global VM
943        // tracing is off, so the collector must always record this
944        // kind.
945        set_tracing_enabled(false);
946        reset_tracing();
947        let mut attrs = BTreeMap::new();
948        attrs.insert("phase".into(), serde_json::json!("warmup"));
949        let (id, trace_id, parent, start_unix_ms) =
950            span_start_user_timing("script.work".into(), attrs);
951        assert!(id != 0);
952        assert!(!trace_id.is_empty());
953        assert_eq!(parent, None);
954        assert!(start_unix_ms > 0);
955
956        assert!(span_record_event(id, "checkpoint".into(), BTreeMap::new()));
957
958        let closed = span_end(id).expect("user timing always records");
959        assert_eq!(closed.kind, SpanKind::UserTiming);
960        assert_eq!(closed.events.len(), 1);
961        assert_eq!(closed.events[0].name, "checkpoint");
962        assert_eq!(closed.metadata["phase"], serde_json::json!("warmup"));
963
964        // The recorded user_timing span survives in the collector
965        // snapshot so `trace_spans()` / `harn run --profile-json`
966        // surface it alongside the other VM-emitted spans.
967        let snapshot = peek_spans();
968        assert!(snapshot
969            .iter()
970            .any(|span| span.kind == SpanKind::UserTiming && span.name == "script.work"));
971    }
972
973    #[test]
974    fn test_new_span_kinds_stringify() {
975        assert_eq!(SpanKind::ModelRoute.as_str(), "model_route");
976        assert_eq!(SpanKind::ToolMount.as_str(), "tool_mount");
977        assert_eq!(SpanKind::DeferredToolLoad.as_str(), "deferred_tool_load");
978    }
979
980    #[test]
981    fn test_llm_call_usage_metadata_pairs_carry_cache_tokens() {
982        let usage = LlmCallUsage {
983            model: "claude-sonnet-4".into(),
984            provider: "anthropic".into(),
985            input_tokens: 100,
986            output_tokens: 20,
987            cache_read_tokens: 40,
988            cache_write_tokens: 8,
989            cost_usd: Some(0.0123),
990        };
991        let pairs: BTreeMap<&str, serde_json::Value> = usage.metadata_pairs().into_iter().collect();
992        assert_eq!(pairs[meta::MODEL], serde_json::json!("claude-sonnet-4"));
993        assert_eq!(pairs[meta::PROVIDER], serde_json::json!("anthropic"));
994        assert_eq!(pairs[meta::INPUT_TOKENS], serde_json::json!(100));
995        assert_eq!(pairs[meta::OUTPUT_TOKENS], serde_json::json!(20));
996        assert_eq!(pairs[meta::CACHE_READ_TOKENS], serde_json::json!(40));
997        assert_eq!(pairs[meta::CACHE_WRITE_TOKENS], serde_json::json!(8));
998        assert_eq!(pairs[meta::COST_USD], serde_json::json!(0.0123));
999    }
1000
1001    #[test]
1002    fn test_llm_call_usage_omits_cost_when_unpriced() {
1003        let usage = LlmCallUsage {
1004            model: "local-model".into(),
1005            provider: "local".into(),
1006            input_tokens: 5,
1007            output_tokens: 1,
1008            cost_usd: None,
1009            ..LlmCallUsage::default()
1010        };
1011        let pairs: BTreeMap<&str, serde_json::Value> = usage.metadata_pairs().into_iter().collect();
1012        assert!(!pairs.contains_key(meta::COST_USD));
1013        // Token attribution is still present even when unpriced.
1014        assert_eq!(pairs[meta::INPUT_TOKENS], serde_json::json!(5));
1015    }
1016
1017    #[test]
1018    fn test_marker_spans_nest_under_active_span_and_carry_metadata() {
1019        set_tracing_enabled(true);
1020        reset_tracing();
1021        let parent = span_start(SpanKind::Pipeline, "agent_loop".into());
1022        emit_model_route("cheap-model", "smart-model", "no_progress");
1023        emit_tool_mount(
1024            &["read".to_string(), "write".to_string()],
1025            "mcp",
1026            Some("filesystem"),
1027        );
1028        emit_deferred_tool_load("grep", "search files", Some(4.5));
1029        span_end(parent);
1030
1031        let spans = peek_spans();
1032        let route = spans
1033            .iter()
1034            .find(|s| s.kind == SpanKind::ModelRoute)
1035            .expect("model_route span");
1036        assert_eq!(route.parent_id, Some(parent));
1037        assert_eq!(
1038            route.metadata[meta::TO_MODEL],
1039            serde_json::json!("smart-model")
1040        );
1041        assert_eq!(
1042            route.metadata[meta::FROM_MODEL],
1043            serde_json::json!("cheap-model")
1044        );
1045
1046        let mount = spans
1047            .iter()
1048            .find(|s| s.kind == SpanKind::ToolMount)
1049            .expect("tool_mount span");
1050        assert_eq!(mount.metadata[meta::TOOL_COUNT], serde_json::json!(2));
1051        assert_eq!(mount.metadata[meta::SOURCE], serde_json::json!("mcp"));
1052        assert_eq!(
1053            mount.metadata[meta::DETAIL],
1054            serde_json::json!("filesystem")
1055        );
1056
1057        let deferred = spans
1058            .iter()
1059            .find(|s| s.kind == SpanKind::DeferredToolLoad)
1060            .expect("deferred_tool_load span");
1061        assert_eq!(
1062            deferred.metadata[meta::TOOL_NAME],
1063            serde_json::json!("grep")
1064        );
1065        assert_eq!(deferred.metadata[meta::SCORE], serde_json::json!(4.5));
1066        set_tracing_enabled(false);
1067    }
1068
1069    #[test]
1070    fn test_empty_tool_mount_is_a_noop() {
1071        set_tracing_enabled(true);
1072        reset_tracing();
1073        let parent = span_start(SpanKind::Pipeline, "loop".into());
1074        emit_tool_mount(&[], "mcp", Some("empty-server"));
1075        span_end(parent);
1076        let spans = peek_spans();
1077        assert!(!spans.iter().any(|s| s.kind == SpanKind::ToolMount));
1078        set_tracing_enabled(false);
1079    }
1080
1081    #[test]
1082    fn test_span_event_offset_is_monotonic() {
1083        // Use a mock clock so the test is deterministic under any load and
1084        // requires zero wall-clock time. The mock is advanced by 10 ms
1085        // between the two events, guaranteeing a strictly-increasing offset
1086        // rather than relying on the OS scheduler to deliver >= 1 ms of
1087        // real elapsed time between the two `record_event` calls.
1088        let clock = crate::clock_mock::MockClock::at_wall_ms(1_000_000_000_000);
1089        let _guard = crate::clock_mock::install_override(clock.clone());
1090        let mut c = SpanCollector::new();
1091        let id = c.start(SpanKind::UserTiming, "outer".into());
1092        assert!(c.record_event(id, "before".into(), BTreeMap::new()));
1093        clock.advance_std_sync(std::time::Duration::from_millis(10));
1094        assert!(c.record_event(id, "after".into(), BTreeMap::new()));
1095        let closed = c.end(id).expect("open span");
1096        assert_eq!(closed.events.len(), 2);
1097        assert!(
1098            closed.events[1].offset_ms > closed.events[0].offset_ms,
1099            "second event should have a strictly greater offset after a 10ms advance; \
1100             before={} after={}",
1101            closed.events[0].offset_ms,
1102            closed.events[1].offset_ms
1103        );
1104    }
1105}