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}