Skip to main content

xbbg_log/
lib.rs

1//! Logging infrastructure for the xbbg workspace.
2//!
3//! Provides a zero-GIL tracing setup for Rust-Python hybrid libraries.
4//! Python controls the log level via an [`AtomicU8`] — no GIL acquisition
5//! ever happens on the logging hot path.
6//!
7//! # Architecture
8//!
9//! ```text
10//! tracing::debug!("...")
11//!   → AtomicLevelFilter (reads AtomicU8 ~1ns, zero GIL)
12//!   → fmt::layer
13//!   → stderr
14//! ```
15//!
16//! # Usage from Python
17//!
18//! ```python
19//! import xbbg
20//! xbbg.set_log_level("debug")   # sets AtomicU8, returns immediately
21//! xbbg.set_log_level("warn")    # back to quiet (default)
22//! ```
23//!
24//! # Usage from Rust
25//!
26//! Other crates in the workspace depend on `xbbg-log` and use the
27//! re-exported tracing macros:
28//!
29//! ```rust,ignore
30//! use xbbg_log::{trace, debug, info, warn, error};
31//!
32//! info!(worker_id = 0, "request completed");
33//! ```
34//!
35//! # Developer Override
36//!
37//! Setting `RUST_LOG` to a simple level (`trace`, `debug`, `info`, `warn`, or
38//! `error`) sets the initial global level. Python can still change it later via
39//! `xbbg.set_log_level()`.
40
41use std::sync::atomic::{AtomicU8, Ordering};
42use std::sync::OnceLock;
43
44use tracing_core::{LevelFilter, Metadata};
45use tracing_subscriber::layer::{Context, Filter};
46use tracing_subscriber::registry::LookupSpan;
47
48// Re-export tracing macros so other crates depend only on xbbg-log.
49pub use tracing::{debug, error, info, trace, warn};
50pub use tracing::{debug_span, error_span, info_span, trace_span, warn_span};
51
52// Re-export Level for set_level callers.
53pub use tracing::Level;
54
55// ---------------------------------------------------------------------------
56// Atomic level filter
57// ---------------------------------------------------------------------------
58
59/// Maps [`Level`] to a `u8` for atomic storage.
60///
61/// Lower numeric value = more verbose.
62const fn level_to_u8(level: Level) -> u8 {
63    match level {
64        Level::TRACE => 0,
65        Level::DEBUG => 1,
66        Level::INFO => 2,
67        Level::WARN => 3,
68        Level::ERROR => 4,
69    }
70}
71
72/// Maps a `u8` back to a [`Level`].
73const fn u8_to_level(val: u8) -> Level {
74    match val {
75        0 => Level::TRACE,
76        1 => Level::DEBUG,
77        2 => Level::INFO,
78        3 => Level::WARN,
79        _ => Level::ERROR,
80    }
81}
82
83/// Global atomic holding the current log level.
84///
85/// Accessed from every `tracing` callsite — a single `Relaxed` load (~1 ns).
86static LEVEL: OnceLock<AtomicU8> = OnceLock::new();
87
88fn global_level() -> &'static AtomicU8 {
89    LEVEL.get_or_init(|| AtomicU8::new(level_to_u8(Level::WARN)))
90}
91
92/// Set the global log level.
93///
94/// This is the function exposed to Python via `xbbg.set_log_level()`.
95/// It performs a single atomic store — no locks, no GIL.
96pub fn set_level(level: Level) {
97    global_level().store(level_to_u8(level), Ordering::Relaxed);
98}
99
100/// Get the current global log level.
101pub fn current_level() -> Level {
102    u8_to_level(global_level().load(Ordering::Relaxed))
103}
104
105/// Parse a level string (case-insensitive) into a [`Level`].
106///
107/// Accepts: `"trace"`, `"debug"`, `"info"`, `"warn"` / `"warning"`,
108/// `"error"`, or numeric `"0"`–`"4"`.
109pub fn parse_level(s: &str) -> Option<Level> {
110    match s.to_ascii_lowercase().as_str() {
111        "trace" | "0" => Some(Level::TRACE),
112        "debug" | "1" => Some(Level::DEBUG),
113        "info" | "2" => Some(Level::INFO),
114        "warn" | "warning" | "3" => Some(Level::WARN),
115        "error" | "4" => Some(Level::ERROR),
116        _ => None,
117    }
118}
119
120/// A [`tracing_subscriber::layer::Filter`] backed by an [`AtomicU8`].
121///
122/// Every callsite hits a single `Relaxed` atomic load to decide whether
123/// the event is enabled — no allocation, no lock, no GIL.
124pub struct AtomicLevelFilter;
125
126impl<S> Filter<S> for AtomicLevelFilter
127where
128    S: tracing::Subscriber + for<'a> LookupSpan<'a>,
129{
130    fn enabled(&self, meta: &Metadata<'_>, _cx: &Context<'_, S>) -> bool {
131        let threshold = global_level().load(Ordering::Relaxed);
132        level_to_u8(*meta.level()) >= threshold
133    }
134
135    fn max_level_hint(&self) -> Option<LevelFilter> {
136        // Tell tracing the widest level we might ever accept so that
137        // callsites aren't permanently disabled.  Because the user can
138        // change the level to TRACE at any time, we must report TRACE.
139        Some(LevelFilter::TRACE)
140    }
141}
142
143// ---------------------------------------------------------------------------
144// Initialization
145// ---------------------------------------------------------------------------
146
147/// Initialize the global tracing subscriber.
148///
149/// Call this **once** from the PyO3 module init (`_core`).
150///
151/// # Behaviour
152///
153/// `RUST_LOG` may set the initial global level when it contains a simple level
154/// string. Output goes directly to stderr; the logging path remains GIL-free.
155pub fn init() {
156    use tracing_subscriber::fmt;
157    use tracing_subscriber::prelude::*;
158
159    if let Ok(level) = std::env::var("RUST_LOG") {
160        if let Some(level) = parse_level(&level) {
161            set_level(level);
162        }
163    }
164
165    let fmt_layer = fmt::layer()
166        .with_writer(std::io::stderr)
167        .with_target(true)
168        .with_thread_ids(true)
169        .with_file(true)
170        .with_line_number(true);
171
172    let subscriber = tracing_subscriber::registry().with(fmt_layer.with_filter(AtomicLevelFilter));
173    let _ = tracing::subscriber::set_global_default(subscriber);
174}
175
176// ---------------------------------------------------------------------------
177// Tests
178// ---------------------------------------------------------------------------
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn level_roundtrip() {
186        for level in [
187            Level::TRACE,
188            Level::DEBUG,
189            Level::INFO,
190            Level::WARN,
191            Level::ERROR,
192        ] {
193            assert_eq!(u8_to_level(level_to_u8(level)), level);
194        }
195    }
196
197    #[test]
198    fn parse_level_cases() {
199        assert_eq!(parse_level("trace"), Some(Level::TRACE));
200        assert_eq!(parse_level("DEBUG"), Some(Level::DEBUG));
201        assert_eq!(parse_level("Info"), Some(Level::INFO));
202        assert_eq!(parse_level("warning"), Some(Level::WARN));
203        assert_eq!(parse_level("WARN"), Some(Level::WARN));
204        assert_eq!(parse_level("error"), Some(Level::ERROR));
205        assert_eq!(parse_level("0"), Some(Level::TRACE));
206        assert_eq!(parse_level("4"), Some(Level::ERROR));
207        assert_eq!(parse_level("garbage"), None);
208    }
209
210    #[test]
211    fn set_and_get_level() {
212        set_level(Level::DEBUG);
213        assert_eq!(current_level(), Level::DEBUG);
214        set_level(Level::WARN);
215        assert_eq!(current_level(), Level::WARN);
216    }
217}