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