Skip to main content

scirs2_core/structured_logging/
tracer.rs

1//! Distributed tracing with an OpenTelemetry-compatible span model.
2//!
3//! # Design
4//!
5//! - **Span** — the fundamental unit of work, carrying a `SpanContext`.
6//! - **Tracer** — creates spans, manages sampling, flushes to exporters.
7//! - **Sampler** — decides per-trace whether to record.
8//! - **SpanExporter** — receives completed spans for persistence / forwarding.
9//! - **Context propagation** — W3C `traceparent` header inject/extract.
10//!
11//! No async runtime is required; all operations are synchronous.
12
13use std::collections::HashMap;
14use std::sync::{Arc, Mutex};
15use std::time::{SystemTime, UNIX_EPOCH};
16
17use super::types::{FieldValue, SpanContext, TraceConfig};
18
19// ============================================================================
20// Internal ID generator (LCG, not cryptographically secure)
21// ============================================================================
22
23/// Simple linear-congruential generator seeded from the system clock.
24struct LcgId {
25    state: u64,
26}
27
28impl LcgId {
29    fn new() -> Self {
30        let seed = SystemTime::now()
31            .duration_since(UNIX_EPOCH)
32            .map(|d| d.as_nanos() as u64)
33            .unwrap_or(12345)
34            ^ 0xdeadbeef_cafebabe;
35        Self { state: seed }
36    }
37
38    fn next(&mut self) -> u64 {
39        // Knuth multiplicative hash (64-bit).
40        self.state = self
41            .state
42            .wrapping_mul(6_364_136_223_846_793_005)
43            .wrapping_add(1_442_695_040_888_963_407);
44        if self.state == 0 {
45            self.state = 1;
46        }
47        self.state
48    }
49}
50
51// Module-level generator protected by a mutex.
52fn next_id() -> u64 {
53    use std::cell::RefCell;
54    thread_local! {
55        static GEN: RefCell<LcgId> = RefCell::new(LcgId::new());
56    }
57    GEN.with(|g| g.borrow_mut().next())
58}
59
60// ============================================================================
61// SpanStatus
62// ============================================================================
63
64/// The outcome of a span.
65#[non_exhaustive]
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum SpanStatus {
68    /// No explicit status set by the caller.
69    Unset,
70    /// The operation completed successfully.
71    Ok,
72    /// The operation failed; the inner string contains a description.
73    Error(String),
74}
75
76impl Default for SpanStatus {
77    fn default() -> Self {
78        SpanStatus::Unset
79    }
80}
81
82// ============================================================================
83// SpanEvent
84// ============================================================================
85
86/// A timestamped annotation recorded inside a span.
87#[derive(Debug, Clone)]
88pub struct SpanEvent {
89    /// Name of the event (e.g. `"retry"`, `"cache.miss"`).
90    pub name: String,
91    /// Wall-clock time in nanoseconds since Unix epoch.
92    pub timestamp_ns: u64,
93    /// Arbitrary attributes attached to the event.
94    pub attributes: Vec<(String, FieldValue)>,
95}
96
97impl SpanEvent {
98    fn new(name: impl Into<String>, attributes: Vec<(String, FieldValue)>) -> Self {
99        let timestamp_ns = SystemTime::now()
100            .duration_since(UNIX_EPOCH)
101            .map(|d| d.as_nanos() as u64)
102            .unwrap_or(0);
103        Self {
104            name: name.into(),
105            timestamp_ns,
106            attributes,
107        }
108    }
109}
110
111// ============================================================================
112// Span
113// ============================================================================
114
115/// A single unit of work in a distributed trace.
116///
117/// Call `end()` when the work is complete; spans that are never ended are
118/// considered still-in-flight and will be dropped without export.
119#[derive(Debug, Clone)]
120pub struct Span {
121    /// Identifies this span within the trace hierarchy.
122    pub context: SpanContext,
123    /// Human-readable operation name.
124    pub name: String,
125    /// Wall-clock start time in nanoseconds since Unix epoch.
126    pub start_ns: u64,
127    /// Wall-clock end time; `None` while the span is in-flight.
128    pub end_ns: Option<u64>,
129    /// Timestamped annotations recorded during the span's lifetime.
130    pub events: Vec<SpanEvent>,
131    /// Key-value attributes describing this span.
132    pub attributes: Vec<(String, FieldValue)>,
133    /// Outcome of the span.
134    pub status: SpanStatus,
135    /// Whether this span was sampled (i.e. should be exported).
136    pub(crate) sampled: bool,
137}
138
139impl Span {
140    /// Set an attribute, replacing any existing entry with the same key.
141    pub fn set_attribute(&mut self, key: impl Into<String>, value: FieldValue) {
142        let key = key.into();
143        if let Some(entry) = self.attributes.iter_mut().find(|(k, _)| k == &key) {
144            entry.1 = value;
145        } else {
146            self.attributes.push((key, value));
147        }
148    }
149
150    /// Record an event inside this span.
151    pub fn add_event(&mut self, name: impl Into<String>, attrs: Vec<(String, FieldValue)>) {
152        self.events.push(SpanEvent::new(name, attrs));
153    }
154
155    /// Set the outcome status of this span.
156    pub fn set_status(&mut self, status: SpanStatus) {
157        self.status = status;
158    }
159
160    /// Mark the span as ended with the current wall-clock time.
161    pub fn end(&mut self) {
162        self.end_ns = Some(
163            SystemTime::now()
164                .duration_since(UNIX_EPOCH)
165                .map(|d| d.as_nanos() as u64)
166                .unwrap_or(0),
167        );
168    }
169
170    /// Return the elapsed wall-clock duration in nanoseconds, or `None` if
171    /// the span has not been ended.
172    pub fn duration_ns(&self) -> Option<u64> {
173        self.end_ns.map(|e| e.saturating_sub(self.start_ns))
174    }
175}
176
177// ============================================================================
178// Sampler trait + implementations
179// ============================================================================
180
181/// Determines whether a trace should be recorded.
182pub trait Sampler: Send + Sync {
183    /// Return `true` if the span identified by `trace_id` / `name` should be
184    /// recorded.
185    fn should_sample(&self, trace_id: u64, name: &str) -> bool;
186}
187
188/// Always records every span.
189pub struct AlwaysOnSampler;
190
191impl Sampler for AlwaysOnSampler {
192    fn should_sample(&self, _trace_id: u64, _name: &str) -> bool {
193        true
194    }
195}
196
197/// Never records any span.
198pub struct AlwaysOffSampler;
199
200impl Sampler for AlwaysOffSampler {
201    fn should_sample(&self, _trace_id: u64, _name: &str) -> bool {
202        false
203    }
204}
205
206/// Records a fraction of traces determined by `ratio` (0.0 – 1.0).
207///
208/// Sampling is deterministic per `trace_id`: the same trace always produces
209/// the same decision, which is critical for head-based sampling correctness.
210pub struct TraceIdRatioSampler {
211    /// Fraction of traces to sample.
212    ratio: f64,
213}
214
215impl TraceIdRatioSampler {
216    /// Create a sampler that records `ratio` fraction of traces.
217    ///
218    /// `ratio` is clamped to [0.0, 1.0].
219    pub fn new(ratio: f64) -> Self {
220        Self {
221            ratio: ratio.clamp(0.0, 1.0),
222        }
223    }
224}
225
226impl Sampler for TraceIdRatioSampler {
227    fn should_sample(&self, trace_id: u64, _name: &str) -> bool {
228        if self.ratio >= 1.0 {
229            return true;
230        }
231        if self.ratio <= 0.0 {
232            return false;
233        }
234        // Apply a mixing function (Murmur3 finaliser) to spread small or
235        // sequential IDs across the full 64-bit range before sampling.
236        let mut h = trace_id;
237        h ^= h >> 33;
238        h = h.wrapping_mul(0xff51_afd7_ed55_8ccd);
239        h ^= h >> 33;
240        h = h.wrapping_mul(0xc4ce_b9fe_1a85_ec53);
241        h ^= h >> 33;
242        // Map to [0.0, 1.0) and compare with ratio.
243        let normalised = (h as f64) / (u64::MAX as f64 + 1.0);
244        normalised < self.ratio
245    }
246}
247
248/// Follows the parent's sampling decision; uses `root_sampler` for root spans.
249pub struct ParentBasedSampler {
250    root_sampler: Box<dyn Sampler>,
251}
252
253impl ParentBasedSampler {
254    /// Create a `ParentBasedSampler` with the given root sampler.
255    pub fn new(root_sampler: Box<dyn Sampler>) -> Self {
256        Self { root_sampler }
257    }
258}
259
260impl Sampler for ParentBasedSampler {
261    fn should_sample(&self, trace_id: u64, name: &str) -> bool {
262        // Without a parent context we fall back to the root sampler.
263        self.root_sampler.should_sample(trace_id, name)
264    }
265}
266
267// Helper to test parent-based decisions with an explicit parent flag.
268impl ParentBasedSampler {
269    /// Honour an explicit parent decision if supplied; otherwise delegate to
270    /// the root sampler.
271    pub fn should_sample_with_parent(
272        &self,
273        parent_sampled: Option<bool>,
274        trace_id: u64,
275        name: &str,
276    ) -> bool {
277        match parent_sampled {
278            Some(decision) => decision,
279            None => self.root_sampler.should_sample(trace_id, name),
280        }
281    }
282}
283
284// ============================================================================
285// SpanExporter trait + implementations
286// ============================================================================
287
288/// Receives completed spans for export (file, network, memory, etc.).
289pub trait SpanExporter: Send + Sync {
290    /// Export a batch of completed spans.
291    fn export(&self, spans: Vec<Span>);
292}
293
294/// Stores completed spans in memory — useful for tests.
295pub struct InMemoryExporter {
296    spans: Mutex<Vec<Span>>,
297}
298
299impl InMemoryExporter {
300    /// Create an empty `InMemoryExporter`.
301    pub fn new() -> Self {
302        Self {
303            spans: Mutex::new(Vec::new()),
304        }
305    }
306
307    /// Return a copy of all exported spans.
308    pub fn spans(&self) -> Vec<Span> {
309        self.spans.lock().map(|g| g.clone()).unwrap_or_default()
310    }
311
312    /// Clear exported spans.
313    pub fn clear(&self) {
314        if let Ok(mut g) = self.spans.lock() {
315            g.clear();
316        }
317    }
318}
319
320impl Default for InMemoryExporter {
321    fn default() -> Self {
322        Self::new()
323    }
324}
325
326impl SpanExporter for InMemoryExporter {
327    fn export(&self, spans: Vec<Span>) {
328        if let Ok(mut g) = self.spans.lock() {
329            g.extend(spans);
330        }
331    }
332}
333
334// ============================================================================
335// OtlpSpan / OtlpStubExporter
336// ============================================================================
337
338/// An OTLP-compatible span as a plain struct (no network I/O).
339///
340/// Field names follow the OpenTelemetry Trace specification.
341#[derive(Debug, Clone)]
342pub struct OtlpSpan {
343    /// 16-byte hex string representing the trace ID.
344    pub trace_id: String,
345    /// 8-byte hex string representing the span ID.
346    pub span_id: String,
347    /// 8-byte hex string representing the parent span ID, empty for roots.
348    pub parent_span_id: String,
349    /// Human-readable operation name.
350    pub name: String,
351    /// Start time in nanoseconds since Unix epoch.
352    pub start_time_unix_nano: u64,
353    /// End time in nanoseconds since Unix epoch.
354    pub end_time_unix_nano: u64,
355    /// Key-value attributes as JSON-encoded string pairs.
356    pub attributes: Vec<(String, String)>,
357    /// Span status code as string (`"UNSET"`, `"OK"`, `"ERROR"`).
358    pub status_code: String,
359    /// Status message (non-empty only for `"ERROR"`).
360    pub status_message: String,
361}
362
363/// Serialises spans to OTLP format and retains them in memory.
364pub struct OtlpStubExporter {
365    spans: Mutex<Vec<OtlpSpan>>,
366}
367
368impl OtlpStubExporter {
369    /// Create an empty exporter.
370    pub fn new() -> Self {
371        Self {
372            spans: Mutex::new(Vec::new()),
373        }
374    }
375
376    /// Convert a `Span` to `OtlpSpan`.
377    pub fn to_otlp(span: &Span) -> OtlpSpan {
378        let trace_hex = format!("{:016x}{:016x}", 0u64, span.context.trace_id);
379        let span_hex = format!("{:016x}", span.context.span_id);
380        let parent_hex = span
381            .context
382            .parent_id
383            .map(|p| format!("{:016x}", p))
384            .unwrap_or_default();
385
386        let attributes: Vec<(String, String)> = span
387            .attributes
388            .iter()
389            .map(|(k, v)| (k.clone(), v.to_json_value()))
390            .collect();
391
392        let (status_code, status_message) = match &span.status {
393            SpanStatus::Unset => ("UNSET".to_owned(), String::new()),
394            SpanStatus::Ok => ("OK".to_owned(), String::new()),
395            SpanStatus::Error(msg) => ("ERROR".to_owned(), msg.clone()),
396        };
397
398        OtlpSpan {
399            trace_id: trace_hex,
400            span_id: span_hex,
401            parent_span_id: parent_hex,
402            name: span.name.clone(),
403            start_time_unix_nano: span.start_ns,
404            end_time_unix_nano: span.end_ns.unwrap_or(span.start_ns),
405            attributes,
406            status_code,
407            status_message,
408        }
409    }
410
411    /// Return all OTLP-formatted spans.
412    pub fn otlp_spans(&self) -> Vec<OtlpSpan> {
413        self.spans.lock().map(|g| g.clone()).unwrap_or_default()
414    }
415}
416
417impl Default for OtlpStubExporter {
418    fn default() -> Self {
419        Self::new()
420    }
421}
422
423impl SpanExporter for OtlpStubExporter {
424    fn export(&self, spans: Vec<Span>) {
425        let otlp: Vec<OtlpSpan> = spans.iter().map(Self::to_otlp).collect();
426        if let Ok(mut g) = self.spans.lock() {
427            g.extend(otlp);
428        }
429    }
430}
431
432// ============================================================================
433// Context propagation (W3C traceparent)
434// ============================================================================
435
436/// W3C Trace Context propagation.
437///
438/// Specification: <https://www.w3.org/TR/trace-context/>
439pub struct TraceContext;
440
441impl TraceContext {
442    /// Inject a W3C `traceparent` header into `headers`.
443    ///
444    /// Format: `00-{32-hex-trace-id}-{16-hex-span-id}-{flags}`
445    pub fn inject(context: &SpanContext, headers: &mut Vec<(String, String)>) {
446        // Trace ID is stored as u64; expand to 128-bit (pad with zeros).
447        let traceparent = format!(
448            "00-{:016x}{:016x}-{:016x}-01",
449            0u64, context.trace_id, context.span_id
450        );
451        // Remove any existing traceparent header.
452        headers.retain(|(k, _)| k.to_lowercase() != "traceparent");
453        headers.push(("traceparent".to_owned(), traceparent));
454
455        // Baggage header.
456        if !context.baggage.is_empty() {
457            let baggage: Vec<String> = context
458                .baggage
459                .iter()
460                .map(|(k, v)| format!("{}={}", k, v))
461                .collect();
462            headers.retain(|(k, _)| k.to_lowercase() != "baggage");
463            headers.push(("baggage".to_owned(), baggage.join(",")));
464        }
465    }
466
467    /// Extract a `SpanContext` from a `traceparent` header value.
468    ///
469    /// Returns `None` if the header is absent or malformed.
470    pub fn extract(headers: &[(String, String)]) -> Option<SpanContext> {
471        let traceparent = headers
472            .iter()
473            .find(|(k, _)| k.to_lowercase() == "traceparent")
474            .map(|(_, v)| v.as_str())?;
475
476        // Expected: 00-{32 hex}-{16 hex}-{2 hex}
477        let parts: Vec<&str> = traceparent.split('-').collect();
478        if parts.len() != 4 {
479            return None;
480        }
481        // Parse trace ID: take the lower 16 hex digits (our u64).
482        let trace_hex = parts[1];
483        if trace_hex.len() != 32 {
484            return None;
485        }
486        let trace_id = u64::from_str_radix(&trace_hex[16..], 16).ok()?;
487
488        let span_hex = parts[2];
489        if span_hex.len() != 16 {
490            return None;
491        }
492        let span_id = u64::from_str_radix(span_hex, 16).ok()?;
493
494        Some(SpanContext {
495            trace_id,
496            span_id,
497            parent_id: None,
498            baggage: Vec::new(),
499        })
500    }
501}
502
503// ============================================================================
504// Tracer
505// ============================================================================
506
507/// Creates and manages distributed tracing spans.
508pub struct Tracer {
509    config: TraceConfig,
510    sampler: Box<dyn Sampler>,
511    exporter: Arc<dyn SpanExporter>,
512    /// In-flight spans indexed by span_id.
513    active_spans: Arc<Mutex<HashMap<u64, Span>>>,
514}
515
516impl Tracer {
517    /// Create a tracer with the given configuration and exporter.
518    pub fn new(config: TraceConfig, exporter: Arc<dyn SpanExporter>) -> Self {
519        Self {
520            sampler: Box::new(AlwaysOnSampler),
521            config,
522            exporter,
523            active_spans: Arc::new(Mutex::new(HashMap::new())),
524        }
525    }
526
527    /// Replace the sampler.
528    pub fn with_sampler(mut self, sampler: Box<dyn Sampler>) -> Self {
529        self.sampler = sampler;
530        self
531    }
532
533    /// Start a new span, optionally as a child of `parent`.
534    pub fn start_span(&self, name: &str, parent: Option<&SpanContext>) -> Span {
535        let (trace_id, parent_id) = match parent {
536            Some(p) => (p.trace_id, Some(p.span_id)),
537            None => (next_id(), None),
538        };
539        let span_id = next_id();
540
541        let sampled = self.sampler.should_sample(trace_id, name);
542
543        let context = SpanContext {
544            trace_id,
545            span_id,
546            parent_id,
547            baggage: parent.map(|p| p.baggage.clone()).unwrap_or_default(),
548        };
549
550        let start_ns = SystemTime::now()
551            .duration_since(UNIX_EPOCH)
552            .map(|d| d.as_nanos() as u64)
553            .unwrap_or(0);
554
555        let span = Span {
556            context,
557            name: name.to_owned(),
558            start_ns,
559            end_ns: None,
560            events: Vec::new(),
561            attributes: Vec::new(),
562            status: SpanStatus::default(),
563            sampled,
564        };
565
566        if sampled {
567            if let Ok(mut active) = self.active_spans.lock() {
568                // Cap to configured maximum.
569                if active.len() >= self.config.max_spans {
570                    // Drop the oldest span by start_ns.
571                    if let Some(&oldest_id) = active
572                        .iter()
573                        .min_by_key(|(_, s)| s.start_ns)
574                        .map(|(id, _)| id)
575                    {
576                        active.remove(&oldest_id);
577                    }
578                }
579                active.insert(span_id, span.clone());
580            }
581        }
582
583        span
584    }
585
586    /// Finalise a span and export it if it was sampled.
587    pub fn finish_span(&self, mut span: Span) {
588        if span.end_ns.is_none() {
589            span.end();
590        }
591        if !span.sampled {
592            return;
593        }
594        // Remove from active map.
595        if let Ok(mut active) = self.active_spans.lock() {
596            active.remove(&span.context.span_id);
597        }
598        self.exporter.export(vec![span]);
599    }
600
601    /// Execute `f` within a new span that is automatically ended on return.
602    pub fn with_span<F, R>(&self, name: &str, f: F) -> R
603    where
604        F: FnOnce(&mut Span) -> R,
605    {
606        let mut span = self.start_span(name, None);
607        let result = f(&mut span);
608        self.finish_span(span);
609        result
610    }
611
612    /// Execute `f` as a child of `parent`, automatically ending the span on return.
613    pub fn with_child_span<F, R>(&self, name: &str, parent: &SpanContext, f: F) -> R
614    where
615        F: FnOnce(&mut Span) -> R,
616    {
617        let mut span = self.start_span(name, Some(parent));
618        let result = f(&mut span);
619        self.finish_span(span);
620        result
621    }
622}
623
624// ============================================================================
625// Tests
626// ============================================================================
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631
632    fn make_tracer() -> (Tracer, Arc<InMemoryExporter>) {
633        let exporter = Arc::new(InMemoryExporter::new());
634        let tracer = Tracer::new(
635            TraceConfig::default(),
636            Arc::clone(&exporter) as Arc<dyn SpanExporter>,
637        );
638        (tracer, exporter)
639    }
640
641    #[test]
642    fn test_span_lifecycle() {
643        let (tracer, exporter) = make_tracer();
644        tracer.with_span("op", |span| {
645            span.add_event("retry", vec![]);
646            span.set_attribute("host", FieldValue::Str("localhost".into()));
647        });
648        let spans = exporter.spans();
649        assert_eq!(spans.len(), 1);
650        assert_eq!(spans[0].name, "op");
651        assert_eq!(spans[0].events.len(), 1);
652        assert_eq!(spans[0].attributes.len(), 1);
653        assert!(spans[0].end_ns.is_some());
654    }
655
656    #[test]
657    fn test_span_parent_child() {
658        let (tracer, exporter) = make_tracer();
659        let parent = tracer.start_span("parent", None);
660        let parent_ctx = parent.context.clone();
661        tracer.finish_span(parent);
662
663        let child = tracer.start_span("child", Some(&parent_ctx));
664        assert_eq!(child.context.trace_id, parent_ctx.trace_id);
665        assert_eq!(child.context.parent_id, Some(parent_ctx.span_id));
666        tracer.finish_span(child);
667
668        let spans = exporter.spans();
669        assert_eq!(spans.len(), 2);
670    }
671
672    #[test]
673    fn test_tracer_with_span() {
674        let (tracer, exporter) = make_tracer();
675        let val = tracer.with_span("compute", |_span| 42);
676        assert_eq!(val, 42);
677        assert_eq!(exporter.spans().len(), 1);
678    }
679
680    #[test]
681    fn test_in_memory_exporter() {
682        let exporter = InMemoryExporter::new();
683        let span = Span {
684            context: SpanContext::root(1, 2),
685            name: "test".into(),
686            start_ns: 0,
687            end_ns: Some(100),
688            events: vec![],
689            attributes: vec![],
690            status: SpanStatus::Ok,
691            sampled: true,
692        };
693        exporter.export(vec![span]);
694        assert_eq!(exporter.spans().len(), 1);
695    }
696
697    #[test]
698    fn test_always_on_sampler() {
699        let s = AlwaysOnSampler;
700        for i in 0..100u64 {
701            assert!(s.should_sample(i, "op"));
702        }
703    }
704
705    #[test]
706    fn test_ratio_sampler_approx() {
707        let s = TraceIdRatioSampler::new(0.5);
708        let sampled = (0u64..10_000)
709            .filter(|&id| s.should_sample(id, "op"))
710            .count();
711        // Expect roughly 5000 ± 10%
712        assert!(sampled > 4000, "sampled={}", sampled);
713        assert!(sampled < 6000, "sampled={}", sampled);
714    }
715
716    #[test]
717    fn test_parent_based_sampler_follows() {
718        let s = ParentBasedSampler::new(Box::new(AlwaysOffSampler));
719        // When parent says true, follow it.
720        assert!(s.should_sample_with_parent(Some(true), 0, "op"));
721        // When parent says false, follow it.
722        assert!(!s.should_sample_with_parent(Some(false), 0, "op"));
723        // With no parent, fall back to AlwaysOffSampler.
724        assert!(!s.should_sample_with_parent(None, 0, "op"));
725    }
726
727    #[test]
728    fn test_traceparent_inject() {
729        let ctx = SpanContext::root(0xdeadbeef, 0xcafe);
730        let mut headers = Vec::new();
731        TraceContext::inject(&ctx, &mut headers);
732        let tp = headers
733            .iter()
734            .find(|(k, _)| k == "traceparent")
735            .map(|(_, v)| v.as_str())
736            .expect("traceparent header missing");
737        // Format: 00-{32hex}-{16hex}-01
738        assert!(tp.starts_with("00-"));
739        assert!(tp.ends_with("-01"));
740        let parts: Vec<&str> = tp.split('-').collect();
741        assert_eq!(parts.len(), 4);
742        assert_eq!(parts[1].len(), 32);
743        assert_eq!(parts[2].len(), 16);
744    }
745
746    #[test]
747    fn test_traceparent_extract() {
748        let ctx = SpanContext::root(0xdeadbeef, 0xcafe);
749        let mut headers = Vec::new();
750        TraceContext::inject(&ctx, &mut headers);
751        let extracted = TraceContext::extract(&headers).expect("extraction failed");
752        assert_eq!(extracted.trace_id, ctx.trace_id);
753        assert_eq!(extracted.span_id, ctx.span_id);
754    }
755
756    #[test]
757    fn test_span_status_error() {
758        let (tracer, exporter) = make_tracer();
759        tracer.with_span("failing", |span| {
760            span.set_status(SpanStatus::Error("timeout".into()));
761        });
762        let spans = exporter.spans();
763        assert_eq!(spans.len(), 1);
764        match &spans[0].status {
765            SpanStatus::Error(msg) => assert_eq!(msg, "timeout"),
766            other => panic!("expected Error, got {:?}", other),
767        }
768    }
769}