1use std::cell::RefCell;
11use std::collections::BTreeMap;
12use std::time::{Instant, SystemTime, UNIX_EPOCH};
13
14use crate::value::VmValue;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum SpanKind {
19 Pipeline,
20 FnCall,
21 LlmCall,
22 ToolCall,
23 Import,
24 Parallel,
25 Spawn,
26 Step,
28 VmSetup,
30 Suspension,
32 Resume,
34 Drain,
36 DrainDecision,
38 PoolSubmit,
40 PoolDequeue,
44 ChannelEmit,
47 ChannelMatch,
52 UserTiming,
56 ModelRoute,
62 ToolMount,
66 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
99pub mod meta {
104 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 pub const FROM_MODEL: &str = "from_model";
115 pub const TO_MODEL: &str = "to_model";
116 pub const REASON: &str = "reason";
117
118 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 pub const TOOL_NAME: &str = "tool_name";
126 pub const QUERY: &str = "query";
127 pub const SCORE: &str = "score";
128}
129
130#[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 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
171pub 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
191pub 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
204pub 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
223pub 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#[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#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
265#[serde(default)]
266pub struct SpanEvent {
267 pub name: String,
268 pub time_unix_ms: u64,
270 pub offset_ms: u64,
272 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
273 pub attributes: BTreeMap<String, serde_json::Value>,
274}
275
276#[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 pub start_ms: u64,
286 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
296struct 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 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
314pub struct SpanCollector {
317 trace_id: String,
318 next_id: u64,
319 active_stack: Vec<u64>,
321 open: BTreeMap<u64, OpenSpan>,
323 completed: Vec<Span>,
325 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 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 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 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 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 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 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 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 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 pub fn current_span_id(&self) -> Option<u64> {
502 self.active_stack.last().copied()
503 }
504
505 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 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 pub fn take_spans(&mut self) -> Vec<Span> {
520 std::mem::take(&mut self.completed)
521 }
522
523 pub fn spans(&self) -> &[Span] {
525 &self.completed
526 }
527
528 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
544fn 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
558fn mock_monotonic_ms() -> Option<u64> {
565 crate::clock_mock::active_mock_clock().map(|mock| mock.now_monotonic_ms() as u64)
566}
567
568pub 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
576pub 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
595pub fn is_tracing_enabled() -> bool {
597 TRACING_ENABLED.with(|e| *e.borrow())
598}
599
600pub 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
608pub 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
616pub 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
624pub 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
632pub 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
641pub 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
666pub 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
680pub 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
689pub 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
700pub 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
708pub 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
716pub 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
724pub fn take_spans() -> Vec<Span> {
726 COLLECTOR.with(|c| c.borrow_mut().take_spans())
727}
728
729pub fn peek_spans() -> Vec<Span> {
731 COLLECTOR.with(|c| c.borrow().spans().to_vec())
732}
733
734pub fn reset_tracing() {
736 COLLECTOR.with(|c| c.borrow_mut().reset());
737}
738
739pub 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
797pub 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 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 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 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 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}