Skip to main content

soma_test_support/
tracing_capture.rs

1//! In-process tracing capture for tests.
2//!
3//! Install a temporary subscriber that writes events into an in-memory buffer,
4//! run code that emits tracing events, then assert on the captured output. Use
5//! this to lock down a structured-logging contract (e.g. that every action
6//! dispatch logs `surface`, `action`, and `outcome`) so it cannot silently
7//! regress.
8//!
9//! Tracing's default subscriber is thread-local, so concurrent capturing tests
10//! would clobber each other. [`tracing_test_lock`] serializes them: hold the
11//! guard for the whole capture. Pair with `#[tokio::test(flavor =
12//! "current_thread")]` so awaits stay on the thread whose default you set.
13
14use std::io::Write;
15use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
16
17/// Process-wide lock serializing tracing-capture tests. Hold the returned guard
18/// for the entire capture so another test's subscriber cannot interleave.
19pub fn tracing_test_lock() -> MutexGuard<'static, ()> {
20    static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
21    LOCK.get_or_init(|| Mutex::new(()))
22        .lock()
23        .unwrap_or_else(std::sync::PoisonError::into_inner)
24}
25
26/// A shared, in-memory buffer a tracing subscriber can write into.
27#[derive(Clone, Default)]
28pub struct SharedBuf(Arc<Mutex<Vec<u8>>>);
29
30impl SharedBuf {
31    pub fn new() -> Self {
32        Self::default()
33    }
34
35    /// Captured output decoded as UTF-8 (lossy).
36    pub fn contents(&self) -> String {
37        let bytes = self
38            .0
39            .lock()
40            .unwrap_or_else(std::sync::PoisonError::into_inner);
41        String::from_utf8_lossy(&bytes).into_owned()
42    }
43
44    /// A `MakeWriter` handle to pass to a `tracing_subscriber` fmt layer.
45    pub fn writer(&self) -> SharedWriter {
46        SharedWriter(self.0.clone())
47    }
48}
49
50/// Write handle for a [`SharedBuf`]; implements `io::Write` and `MakeWriter`.
51#[derive(Clone)]
52pub struct SharedWriter(Arc<Mutex<Vec<u8>>>);
53
54impl Write for SharedWriter {
55    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
56        self.0
57            .lock()
58            .unwrap_or_else(std::sync::PoisonError::into_inner)
59            .extend_from_slice(buf);
60        Ok(buf.len())
61    }
62
63    fn flush(&mut self) -> std::io::Result<()> {
64        Ok(())
65    }
66}
67
68impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for SharedWriter {
69    type Writer = SharedWriter;
70
71    fn make_writer(&'a self) -> Self::Writer {
72        self.clone()
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    #[test]
81    fn captures_emitted_events() {
82        let _lock = tracing_test_lock();
83        let buf = SharedBuf::new();
84        let subscriber = tracing_subscriber::fmt()
85            .with_writer(buf.writer())
86            .with_ansi(false)
87            .without_time()
88            .finish();
89        tracing::subscriber::with_default(subscriber, || {
90            tracing::info!(surface = "test", "captured event");
91        });
92        let logs = buf.contents();
93        assert!(logs.contains("captured event"), "logs were: {logs}");
94        assert!(logs.contains("surface"), "logs were: {logs}");
95    }
96}