Skip to main content

lean_rs/error/
capture.rs

1//! In-process [`tracing`] event capture for tests and downstream apps.
2//!
3//! [`DiagnosticCapture`] installs a per-thread subscriber for its
4//! lifetime, buffers up to a bounded number of `lean_rs` events into a
5//! [`std::collections::VecDeque`], and exposes them as
6//! [`CapturedEvent`] records. On `Drop` the subscriber is uninstalled
7//! and the previous default (if any) is restored.
8//!
9//! This is the always-present test affordance: no cargo feature, no
10//! external `tracing-subscriber` install boilerplate. Production
11//! downstream applications that want a different sink (`fmt`,
12//! `tracing-bunyan`, OpenTelemetry, …) simply install their own
13//! subscriber instead of constructing a [`DiagnosticCapture`].
14
15use std::collections::VecDeque;
16use std::fmt;
17use std::marker::PhantomData;
18use std::sync::{Arc, Mutex};
19
20use tracing::field::{Field, Visit};
21use tracing::span::Attributes;
22use tracing::{Event, Id, Subscriber};
23use tracing_subscriber::Layer;
24use tracing_subscriber::layer::{Context, SubscriberExt};
25use tracing_subscriber::registry::{LookupSpan, Registry};
26
27use crate::error::LeanDiagnosticCode;
28
29/// Soft cap on the number of events a [`DiagnosticCapture`] retains.
30///
31/// When the buffer is full, the *oldest* event is dropped and
32/// [`DiagnosticCapture::overflowed`] increments. The cap exists so a
33/// long-running test cannot grow the buffer without bound; tests that
34/// expect more than a few hundred events should construct the capture
35/// with a larger budget via [`DiagnosticCapture::with_capacity`].
36pub const DIAGNOSTIC_CAPTURE_DEFAULT_CAPACITY: usize = 256;
37
38/// One captured tracing event.
39///
40/// Built by the per-thread layer installed by [`DiagnosticCapture`]. The
41/// `code` field is populated when an event carries a `code = "..."`
42/// field whose value matches one of [`LeanDiagnosticCode::as_str`].
43/// `fields` carries other structured fields verbatim, with values
44/// rendered via [`fmt::Debug`] (the standard tracing protocol).
45#[derive(Clone, Debug, Eq, PartialEq)]
46pub struct CapturedEvent {
47    /// The event's target, e.g. `"lean_rs"`. Tracing assigns the
48    /// containing module path by default.
49    pub target: String,
50    /// The event's level as the lowercase string `"error"`, `"warn"`,
51    /// `"info"`, `"debug"`, or `"trace"`.
52    pub level: &'static str,
53    /// The event's `name` (assigned by `tracing` from the source-site
54    /// macro), e.g. `"event src/host/session.rs:271"`. Use [`Self::span`]
55    /// to identify *which* `#[instrument]`-style span produced the
56    /// event.
57    pub name: &'static str,
58    /// The span name the event was emitted inside, if any. This is
59    /// the identifier used by the documented span catalogue
60    /// (`lean_rs.host.session.import`, `lean_rs.module.library.open`,
61    /// …).
62    pub span: Option<String>,
63    /// The diagnostic code attached to this event, if it carries a
64    /// `code` field that matches a known [`LeanDiagnosticCode`].
65    pub code: Option<LeanDiagnosticCode>,
66    /// The event's `message` field if present, else an empty string.
67    pub message: String,
68    /// Other structured fields (`(name, value)` pairs), excluding
69    /// `code` and `message`.
70    pub fields: Vec<(&'static str, String)>,
71}
72
73/// Buffered tracing-event collector for the current thread.
74///
75/// Construct with [`Self::install`]; access events through
76/// [`Self::events`]; drop to uninstall. Single-threaded by construction:
77/// the installed subscriber is per-thread (via
78/// [`tracing::subscriber::set_default`]), and the inner buffer is
79/// reachable only through this guard. `!Send` is structural — the
80/// `Rc` and the [`tracing::dispatcher::DefaultGuard`] both inherit it.
81#[must_use = "Drop the DiagnosticCapture only when you are done collecting"]
82pub struct DiagnosticCapture {
83    inner: Arc<Mutex<CaptureBuffer>>,
84    // `tracing::subscriber::set_default` returns a guard whose `Drop`
85    // restores the previous default subscriber on this thread.
86    _default_guard: tracing::subscriber::DefaultGuard,
87    // Mark the guard `!Send + !Sync`. The internal buffer uses
88    // `Arc<Mutex<_>>` only because `tracing`'s `Subscriber` trait
89    // requires `Send + Sync`; the *guard* is intentionally pinned to
90    // the thread that installed it (the default-subscriber slot is
91    // thread-local).
92    _not_send_sync: PhantomData<*mut ()>,
93}
94
95impl DiagnosticCapture {
96    /// Install a capture with the default capacity
97    /// ([`DIAGNOSTIC_CAPTURE_DEFAULT_CAPACITY`]).
98    ///
99    /// Captures `lean_rs` events at every level for the duration of the
100    /// returned guard. Events from other targets are dropped (they
101    /// still pass through to any *outer* subscriber the test may have
102    /// installed earlier, because `set_default` is scoped per thread).
103    pub fn install() -> Self {
104        Self::with_capacity(DIAGNOSTIC_CAPTURE_DEFAULT_CAPACITY)
105    }
106
107    /// Install a capture with a custom event-buffer capacity.
108    pub fn with_capacity(capacity: usize) -> Self {
109        let inner = Arc::new(Mutex::new(CaptureBuffer::new(capacity)));
110        let layer = CaptureLayer {
111            buffer: Arc::clone(&inner),
112        };
113        let subscriber = Registry::default().with(layer);
114        let default_guard = tracing::subscriber::set_default(subscriber);
115        Self {
116            inner,
117            _default_guard: default_guard,
118            _not_send_sync: PhantomData,
119        }
120    }
121
122    /// Snapshot of the captured events so far, in insertion order.
123    /// Cheap clone; the capture buffer keeps accumulating after the
124    /// call.
125    #[must_use]
126    pub fn events(&self) -> Vec<CapturedEvent> {
127        let inner = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
128        inner.events.iter().cloned().collect()
129    }
130
131    /// Number of events that were dropped because the bounded buffer
132    /// was full. `0` for any test that stays under [`Self::capacity`].
133    #[must_use]
134    pub fn overflowed(&self) -> usize {
135        let inner = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
136        inner.overflowed
137    }
138
139    /// The buffer's capacity in events.
140    #[must_use]
141    pub fn capacity(&self) -> usize {
142        let inner = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
143        inner.capacity
144    }
145}
146
147impl fmt::Debug for DiagnosticCapture {
148    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149        let inner = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
150        f.debug_struct("DiagnosticCapture")
151            .field("events", &inner.events.len())
152            .field("overflowed", &inner.overflowed)
153            .finish_non_exhaustive()
154    }
155}
156
157/// Internal shared state for the layer and the guard.
158struct CaptureBuffer {
159    events: VecDeque<CapturedEvent>,
160    overflowed: usize,
161    capacity: usize,
162}
163
164impl CaptureBuffer {
165    fn new(capacity: usize) -> Self {
166        let capacity = capacity.max(1);
167        Self {
168            events: VecDeque::with_capacity(capacity),
169            overflowed: 0,
170            capacity,
171        }
172    }
173
174    fn push(&mut self, event: CapturedEvent) {
175        if self.events.len() >= self.capacity {
176            self.events.pop_front();
177            self.overflowed = self.overflowed.saturating_add(1);
178        }
179        self.events.push_back(event);
180    }
181}
182
183/// `tracing` layer that pushes incoming events into the shared buffer.
184struct CaptureLayer {
185    buffer: Arc<Mutex<CaptureBuffer>>,
186}
187
188impl<S> Layer<S> for CaptureLayer
189where
190    S: Subscriber + for<'a> LookupSpan<'a>,
191{
192    fn on_new_span(&self, attrs: &Attributes<'_>, id: &Id, ctx: Context<'_, S>) {
193        // Snapshot the span name as a CapturedEvent so callers can see
194        // that a span (e.g. `lean_rs.host.session.import`) was entered
195        // even when no inner event fires.
196        let metadata = attrs.metadata();
197        if !metadata.target().starts_with("lean_rs") {
198            return;
199        }
200        let mut visitor = FieldVisitor::default();
201        attrs.record(&mut visitor);
202        let span_name = metadata.name();
203        let event = CapturedEvent {
204            target: metadata.target().to_owned(),
205            level: level_str(*metadata.level()),
206            name: "span_open",
207            span: Some(span_name.to_owned()),
208            code: visitor.code,
209            message: visitor.message.unwrap_or_default(),
210            fields: visitor.other_fields,
211        };
212        // SAFETY (logical): the layer only runs on the thread that
213        // owns the `Rc<RefCell<_>>`; `set_default` is scoped per-thread.
214        if let Ok(mut buf) = self.buffer.lock() {
215            buf.push(event);
216        }
217        // The span itself is intentionally ignored after this — we do
218        // not retain per-span extension storage. `ctx` is only here for
219        // potential future enrichment.
220        let _ = (id, ctx);
221    }
222
223    fn on_event(&self, event: &Event<'_>, ctx: Context<'_, S>) {
224        let metadata = event.metadata();
225        if !metadata.target().starts_with("lean_rs") {
226            return;
227        }
228        let mut visitor = FieldVisitor::default();
229        event.record(&mut visitor);
230        let span_name = ctx
231            .event_span(event)
232            .map(|s| s.name().to_owned())
233            .or_else(|| ctx.lookup_current().map(|s| s.name().to_owned()));
234        let captured = CapturedEvent {
235            target: metadata.target().to_owned(),
236            level: level_str(*metadata.level()),
237            name: metadata.name(),
238            span: span_name,
239            code: visitor.code,
240            message: visitor.message.unwrap_or_default(),
241            fields: visitor.other_fields,
242        };
243        if let Ok(mut buf) = self.buffer.lock() {
244            buf.push(captured);
245        }
246    }
247}
248
249const fn level_str(level: tracing::Level) -> &'static str {
250    match level {
251        tracing::Level::ERROR => "error",
252        tracing::Level::WARN => "warn",
253        tracing::Level::INFO => "info",
254        tracing::Level::DEBUG => "debug",
255        tracing::Level::TRACE => "trace",
256    }
257}
258
259/// Tracing field visitor that extracts `code`, `message`, and other
260/// structured fields into a typed shape.
261#[derive(Default)]
262struct FieldVisitor {
263    code: Option<LeanDiagnosticCode>,
264    message: Option<String>,
265    other_fields: Vec<(&'static str, String)>,
266}
267
268impl Visit for FieldVisitor {
269    fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
270        let rendered = format!("{value:?}");
271        self.record_str(field, &rendered);
272    }
273
274    fn record_str(&mut self, field: &Field, value: &str) {
275        match field.name() {
276            "code" => {
277                if let Some(known) = parse_code_str(value) {
278                    self.code = Some(known);
279                } else {
280                    self.other_fields.push(("code", value.to_owned()));
281                }
282            }
283            "message" => self.message = Some(value.to_owned()),
284            other => self.other_fields.push((other, value.to_owned())),
285        }
286    }
287}
288
289/// Map a `code` field's rendered value back to a [`LeanDiagnosticCode`].
290///
291/// Accepts both the stable id (`"lean_rs.linking"`) and the bare
292/// variant name (`"Linking"`); also tolerates the `Debug` rendering
293/// `tracing` produces for `&str` fields, which is `"\"...\""` (with
294/// embedded quotes).
295fn parse_code_str(raw: &str) -> Option<LeanDiagnosticCode> {
296    let trimmed = raw.trim_matches('"');
297    match trimmed {
298        "lean_rs.runtime_init" | "RuntimeInit" => Some(LeanDiagnosticCode::RuntimeInit),
299        "lean_rs.linking" | "Linking" => Some(LeanDiagnosticCode::Linking),
300        "lean_rs.module_init" | "ModuleInit" => Some(LeanDiagnosticCode::ModuleInit),
301        "lean_rs.symbol_lookup" | "SymbolLookup" => Some(LeanDiagnosticCode::SymbolLookup),
302        "lean_rs.abi_conversion" | "AbiConversion" => Some(LeanDiagnosticCode::AbiConversion),
303        "lean_rs.lean_exception" | "LeanException" => Some(LeanDiagnosticCode::LeanException),
304        "lean_rs.elaboration" | "Elaboration" => Some(LeanDiagnosticCode::Elaboration),
305        "lean_rs.unsupported" | "Unsupported" => Some(LeanDiagnosticCode::Unsupported),
306        "lean_rs.internal" | "Internal" => Some(LeanDiagnosticCode::Internal),
307        _ => None,
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314    use tracing::{info, info_span};
315
316    #[test]
317    fn captures_lean_rs_event() {
318        let capture = DiagnosticCapture::install();
319        info!(target: "lean_rs", code = "lean_rs.linking", "linker failed");
320        let events = capture.events();
321        assert!(
322            events
323                .iter()
324                .any(|e| e.code == Some(LeanDiagnosticCode::Linking) && e.message == "linker failed"),
325            "expected one linking event, got {events:?}",
326        );
327    }
328
329    #[test]
330    fn ignores_other_targets() {
331        let capture = DiagnosticCapture::install();
332        info!(target: "some_other_crate", "boring");
333        assert!(capture.events().is_empty());
334    }
335
336    #[test]
337    fn captures_span_open() {
338        let capture = DiagnosticCapture::install();
339        let _g = info_span!(target: "lean_rs", "lean_rs.host.session.import").entered();
340        let events = capture.events();
341        assert!(
342            events
343                .iter()
344                .any(|e| e.span.as_deref() == Some("lean_rs.host.session.import")),
345            "expected a span_open record, got {events:?}",
346        );
347    }
348
349    #[test]
350    fn bounded_buffer_drops_oldest() {
351        let capture = DiagnosticCapture::with_capacity(2);
352        info!(target: "lean_rs", "one");
353        info!(target: "lean_rs", "two");
354        info!(target: "lean_rs", "three");
355        let events = capture.events();
356        assert_eq!(events.len(), 2);
357        let messages: Vec<&str> = events.iter().map(|e| e.message.as_str()).collect();
358        assert_eq!(messages, ["two", "three"]);
359        assert_eq!(capture.overflowed(), 1);
360    }
361}