grafos_observe/
export.rs

1//! Span exporters for grafOS distributed tracing.
2//!
3//! - [`NullExporter`]: Collects spans in memory for test assertions.
4//! - [`SpanExporter`]: Trait for pluggable export backends.
5
6extern crate alloc;
7
8#[cfg(feature = "std")]
9use alloc::boxed::Box;
10use alloc::vec::Vec;
11
12use crate::span::ResourceSpan;
13
14#[cfg(feature = "std")]
15static GLOBAL_SPAN_EXPORTER: std::sync::OnceLock<Box<dyn SpanExporter + Send + Sync>> =
16    std::sync::OnceLock::new();
17
18/// Trait for exporting completed spans.
19pub trait SpanExporter {
20    /// Export a batch of spans. Implementations should not block the caller
21    /// for extended periods; buffering and async flush are preferred.
22    fn export(&self, spans: &[ResourceSpan]);
23
24    /// Flush any buffered spans. Called on shutdown.
25    fn flush(&self);
26}
27
28/// Install a process-wide span exporter.
29///
30/// Only the first call takes effect. Runtime and scheduler paths use
31/// [`emit_span`] after this has been installed. The global exporter is
32/// intentionally process-wide, mirroring the event sink: callers that
33/// need per-subsystem routing should install a multiplexer exporter.
34#[cfg(feature = "std")]
35pub fn set_global_span_exporter(exporter: Box<dyn SpanExporter + Send + Sync>) -> bool {
36    GLOBAL_SPAN_EXPORTER.set(exporter).is_ok()
37}
38
39/// Emit one completed [`ResourceSpan`] to the process-wide exporter.
40///
41/// If no exporter has been installed this is a no-op; the caller's
42/// operation must not depend on observability delivery.
43#[cfg(feature = "std")]
44pub fn emit_span(span: ResourceSpan) {
45    if let Some(exporter) = GLOBAL_SPAN_EXPORTER.get() {
46        exporter.export(&[span]);
47    }
48}
49
50/// Flush the process-wide span exporter when one has been installed.
51#[cfg(feature = "std")]
52pub fn flush_global_span_exporter() {
53    if let Some(exporter) = GLOBAL_SPAN_EXPORTER.get() {
54        exporter.flush();
55    }
56}
57
58/// No-op in `no_std` builds.
59#[cfg(not(feature = "std"))]
60pub fn emit_span(_span: ResourceSpan) {}
61
62/// A test exporter that collects spans in memory.
63///
64/// Thread-safe via `std::sync::Mutex` (available when `std` feature is on)
65/// or via single-threaded usage in `no_std` tests.
66///
67/// # Examples
68///
69/// ```
70/// use grafos_observe::export::NullExporter;
71/// use grafos_observe::export::SpanExporter;
72/// use grafos_observe::span::ResourceSpan;
73/// use grafos_observe::trace::TraceContext;
74///
75/// let exporter = NullExporter::new();
76/// let span = ResourceSpan::new("test", TraceContext::default());
77/// exporter.export(&[span]);
78/// assert_eq!(exporter.len(), 1);
79///
80/// let spans = exporter.drain();
81/// assert_eq!(spans.len(), 1);
82/// assert_eq!(spans[0].name, "test");
83/// ```
84pub struct NullExporter {
85    #[cfg(feature = "std")]
86    spans: std::sync::Mutex<Vec<ResourceSpan>>,
87    #[cfg(not(feature = "std"))]
88    spans: core::cell::RefCell<Vec<ResourceSpan>>,
89}
90
91impl NullExporter {
92    /// Create a new null exporter.
93    pub fn new() -> Self {
94        NullExporter {
95            #[cfg(feature = "std")]
96            spans: std::sync::Mutex::new(Vec::new()),
97            #[cfg(not(feature = "std"))]
98            spans: core::cell::RefCell::new(Vec::new()),
99        }
100    }
101
102    /// Number of spans collected so far.
103    pub fn len(&self) -> usize {
104        #[cfg(feature = "std")]
105        {
106            self.spans.lock().unwrap().len()
107        }
108        #[cfg(not(feature = "std"))]
109        {
110            self.spans.borrow().len()
111        }
112    }
113
114    /// Whether any spans have been collected.
115    pub fn is_empty(&self) -> bool {
116        self.len() == 0
117    }
118
119    /// Drain all collected spans, returning them and clearing the buffer.
120    pub fn drain(&self) -> Vec<ResourceSpan> {
121        #[cfg(feature = "std")]
122        {
123            let mut guard = self.spans.lock().unwrap();
124            core::mem::take(&mut *guard)
125        }
126        #[cfg(not(feature = "std"))]
127        {
128            let mut guard = self.spans.borrow_mut();
129            core::mem::take(&mut *guard)
130        }
131    }
132
133    /// Get a snapshot (clone) of all collected spans.
134    pub fn snapshot(&self) -> Vec<ResourceSpan> {
135        #[cfg(feature = "std")]
136        {
137            self.spans.lock().unwrap().clone()
138        }
139        #[cfg(not(feature = "std"))]
140        {
141            self.spans.borrow().clone()
142        }
143    }
144}
145
146impl Default for NullExporter {
147    fn default() -> Self {
148        Self::new()
149    }
150}
151
152impl SpanExporter for NullExporter {
153    fn export(&self, spans: &[ResourceSpan]) {
154        #[cfg(feature = "std")]
155        {
156            let mut guard = self.spans.lock().unwrap();
157            guard.extend(spans.iter().cloned());
158        }
159        #[cfg(not(feature = "std"))]
160        {
161            let mut guard = self.spans.borrow_mut();
162            guard.extend(spans.iter().cloned());
163        }
164    }
165
166    fn flush(&self) {
167        // Nothing to flush — spans are already in memory.
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use crate::trace::TraceContext;
175    #[cfg(feature = "std")]
176    use std::sync::{Arc, Mutex};
177
178    #[test]
179    fn null_exporter_collects_and_drains() {
180        let exporter = NullExporter::new();
181        assert!(exporter.is_empty());
182
183        let span1 = ResourceSpan::new("op1", TraceContext::default());
184        let span2 = ResourceSpan::new("op2", TraceContext::default());
185        exporter.export(&[span1, span2]);
186
187        assert_eq!(exporter.len(), 2);
188
189        let drained = exporter.drain();
190        assert_eq!(drained.len(), 2);
191        assert_eq!(drained[0].name, "op1");
192        assert_eq!(drained[1].name, "op2");
193        assert!(exporter.is_empty());
194    }
195
196    #[test]
197    fn null_exporter_snapshot_does_not_drain() {
198        let exporter = NullExporter::new();
199        let span = ResourceSpan::new("snap", TraceContext::default());
200        exporter.export(&[span]);
201
202        let snap = exporter.snapshot();
203        assert_eq!(snap.len(), 1);
204        assert_eq!(exporter.len(), 1); // still there
205    }
206
207    #[test]
208    fn null_exporter_multiple_exports() {
209        let exporter = NullExporter::new();
210        for i in 0..10 {
211            let name = alloc::format!("span_{i}");
212            let span = ResourceSpan::new(&name, TraceContext::default());
213            exporter.export(&[span]);
214        }
215        assert_eq!(exporter.len(), 10);
216    }
217
218    #[cfg(feature = "std")]
219    #[derive(Clone)]
220    struct SharedExporter {
221        spans: Arc<Mutex<Vec<ResourceSpan>>>,
222    }
223
224    #[cfg(feature = "std")]
225    impl SpanExporter for SharedExporter {
226        fn export(&self, spans: &[ResourceSpan]) {
227            self.spans.lock().unwrap().extend(spans.iter().cloned());
228        }
229
230        fn flush(&self) {}
231    }
232
233    /// Phase 219.1 slice 199 — span production paths should share a
234    /// single grafos-observe exporter hook instead of each crate
235    /// inventing a local global. This pins the real `emit_span`
236    /// routing path used by scheduler/runtime integration slices.
237    #[cfg(feature = "std")]
238    #[test]
239    fn global_span_exporter_receives_emit_span() {
240        let spans = Arc::new(Mutex::new(Vec::new()));
241        let exporter = SharedExporter {
242            spans: spans.clone(),
243        };
244
245        let _ = set_global_span_exporter(Box::new(exporter));
246        emit_span(ResourceSpan::new(
247            crate::contract::PHASE_219_SPAN_NAMES[0],
248            TraceContext::default(),
249        ));
250
251        let captured = spans.lock().unwrap();
252        assert!(
253            captured
254                .iter()
255                .any(|span| span.name == crate::contract::PHASE_219_SPAN_NAMES[0]),
256            "global span exporter must receive spans emitted through emit_span()"
257        );
258    }
259}