1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum SpanKind {
22 Pipeline,
23 FnCall,
24 LlmCall,
25 ToolCall,
26 Import,
27 Parallel,
28 Spawn,
29 Step,
31 VmSetup,
33 Suspension,
35 Resume,
37 Drain,
39 DrainDecision,
41 PoolSubmit,
43 PoolDequeue,
47 ChannelEmit,
50 ChannelMatch,
55 UserTiming,
59 ModelRoute,
65 ToolMount,
69 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
102pub mod meta {
107 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 pub const FROM_MODEL: &str = "from_model";
118 pub const TO_MODEL: &str = "to_model";
119 pub const REASON: &str = "reason";
120
121 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 pub const TOOL_NAME: &str = "tool_name";
129 pub const QUERY: &str = "query";
130 pub const SCORE: &str = "score";
131}
132
133pub 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
153pub 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
166pub 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
185pub 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#[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#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
227#[serde(default)]
228pub struct SpanEvent {
229 pub name: String,
230 pub time_unix_ms: u64,
232 pub offset_ms: u64,
234 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
235 pub attributes: BTreeMap<String, serde_json::Value>,
236}
237
238#[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 pub start_ms: u64,
248 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
258struct 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 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
276pub struct SpanCollector {
279 trace_id: String,
280 next_id: u64,
281 active_stack: Vec<u64>,
283 open: BTreeMap<u64, OpenSpan>,
285 completed: Vec<Span>,
287 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 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 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 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 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 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 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 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 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 pub fn current_span_id(&self) -> Option<u64> {
464 self.active_stack.last().copied()
465 }
466
467 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 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 pub fn take_spans(&mut self) -> Vec<Span> {
482 std::mem::take(&mut self.completed)
483 }
484
485 pub fn spans(&self) -> &[Span] {
487 &self.completed
488 }
489
490 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
506pub(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 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
588fn 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
602fn mock_monotonic_ms() -> Option<u64> {
609 crate::clock_mock::active_clock().map(|clock| clock.monotonic_ms().max(0) as u64)
610}
611
612pub 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
620pub 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
639pub fn is_tracing_enabled() -> bool {
641 TRACING_ENABLED.with(|e| *e.borrow())
642}
643
644pub 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
652pub 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
660pub 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
668pub 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
676pub 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
685pub 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
710pub 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
724pub 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
733pub 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
744pub 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
752pub 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
760pub 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
768pub fn take_spans() -> Vec<Span> {
770 COLLECTOR.with(|c| c.borrow_mut().take_spans())
771}
772
773pub fn peek_spans() -> Vec<Span> {
775 COLLECTOR.with(|c| c.borrow().spans().to_vec())
776}
777
778pub fn reset_tracing() {
780 COLLECTOR.with(|c| c.borrow_mut().reset());
781}
782
783pub 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
841pub 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 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 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 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 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}