Skip to main content

liminal/tracing/
logging.rs

1use std::error::Error;
2use std::fmt::{Display, Formatter, Result as FmtResult};
3use std::ptr;
4use std::sync::OnceLock;
5use std::sync::atomic::{AtomicPtr, AtomicU8, Ordering};
6use std::time::Instant;
7
8use crate::tracing::{TraceContext, current_trace_context};
9
10// Zero-overhead "is an emitter installed?" sentinel: an atomic pointer that is
11// null until an emitter is installed, then points at a known-valid static token
12// (never at heap data). Mirrors the span-collector marker in span.rs. The actual
13// emitter lives in the OnceLock; this marker only gates the no-op fast path.
14static LOG_EMITTER_MARKER: AtomicPtr<()> = AtomicPtr::new(ptr::null_mut());
15static LOG_EMITTER_TOKEN: () = ();
16static GLOBAL_LOG_EMITTER: OnceLock<Box<dyn LogEmitter>> = OnceLock::new();
17static MIN_LOG_LEVEL: AtomicU8 = AtomicU8::new(MIN_LOG_LEVEL_UNSET);
18
19const MIN_LOG_LEVEL_UNSET: u8 = 0;
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
22pub enum LogLevel {
23    Trace,
24    Debug,
25    Info,
26    Warn,
27    Error,
28}
29
30#[derive(Clone, Debug, PartialEq)]
31pub enum LogValue {
32    String(String),
33    I64(i64),
34    U64(u64),
35    F64(f64),
36    Bool(bool),
37}
38
39#[derive(Clone, Debug, PartialEq)]
40pub struct LogEntry {
41    pub timestamp: Instant,
42    pub level: LogLevel,
43    pub target: &'static str,
44    pub message: String,
45    pub trace_context: Option<TraceContext>,
46    pub fields: Vec<(String, LogValue)>,
47}
48
49impl LogEntry {
50    #[must_use]
51    pub const fn trace_id(&self) -> Option<u128> {
52        match self.trace_context {
53            Some(context) => Some(context.trace_id()),
54            None => None,
55        }
56    }
57
58    #[must_use]
59    pub const fn span_id(&self) -> Option<u64> {
60        match self.trace_context {
61            Some(context) => Some(context.span_id()),
62            None => None,
63        }
64    }
65}
66
67pub trait LogEmitter: std::fmt::Debug + Send + Sync + 'static {
68    fn emit(&self, entry: &LogEntry);
69}
70
71#[derive(Debug, Clone, Copy, Default)]
72pub struct NoopEmitter;
73
74impl LogEmitter for NoopEmitter {
75    fn emit(&self, entry: &LogEntry) {
76        let _ = entry;
77    }
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum LogEmitterInstallError {
82    AlreadyInstalled,
83}
84
85impl Display for LogEmitterInstallError {
86    fn fmt(&self, formatter: &mut Formatter<'_>) -> FmtResult {
87        match self {
88            Self::AlreadyInstalled => {
89                formatter.write_str("global log emitter is already installed")
90            }
91        }
92    }
93}
94
95impl Error for LogEmitterInstallError {}
96
97#[derive(Debug, Clone, PartialEq)]
98pub struct LogEntryBuilder {
99    level: LogLevel,
100    target: &'static str,
101    message: String,
102    fields: Vec<(String, LogValue)>,
103}
104
105impl LogEntryBuilder {
106    #[must_use]
107    pub fn new(level: LogLevel, target: &'static str, message: impl Into<String>) -> Self {
108        Self {
109            level,
110            target,
111            message: message.into(),
112            fields: Vec::new(),
113        }
114    }
115
116    #[must_use]
117    pub fn field(mut self, key: impl Into<String>, value: LogValue) -> Self {
118        self.fields.push((key.into(), value));
119        self
120    }
121
122    #[must_use]
123    pub fn build(self) -> LogEntry {
124        LogEntry {
125            timestamp: Instant::now(),
126            level: self.level,
127            target: self.target,
128            message: self.message,
129            trace_context: current_trace_context(),
130            fields: self.fields,
131        }
132    }
133}
134
135/// # Errors
136///
137/// Returns an error when a global log emitter has already been installed.
138pub fn install_log_emitter<Emitter>(emitter: Emitter) -> Result<(), LogEmitterInstallError>
139where
140    Emitter: LogEmitter,
141{
142    install_boxed_log_emitter(Box::new(emitter))
143}
144
145/// # Errors
146///
147/// Returns an error when a global log emitter has already been installed.
148pub fn install_boxed_log_emitter(
149    emitter: Box<dyn LogEmitter>,
150) -> Result<(), LogEmitterInstallError> {
151    match GLOBAL_LOG_EMITTER.set(emitter) {
152        Ok(()) => {
153            // Publish the marker after the emitter is in the OnceLock, so any
154            // thread that observes a non-null marker also observes the emitter.
155            LOG_EMITTER_MARKER.store(
156                ptr::addr_of!(LOG_EMITTER_TOKEN).cast_mut(),
157                Ordering::Release,
158            );
159            Ok(())
160        }
161        Err(emitter) => {
162            drop(emitter);
163            Err(LogEmitterInstallError::AlreadyInstalled)
164        }
165    }
166}
167
168#[must_use]
169pub fn log_emitter_enabled() -> bool {
170    !LOG_EMITTER_MARKER.load(Ordering::Acquire).is_null()
171}
172
173#[must_use]
174pub fn global_log_emitter() -> Option<&'static dyn LogEmitter> {
175    if log_emitter_enabled() {
176        GLOBAL_LOG_EMITTER.get().map(Box::as_ref)
177    } else {
178        None
179    }
180}
181
182pub fn set_min_log_level(level: LogLevel) {
183    MIN_LOG_LEVEL.store(log_level_filter_value(level), Ordering::Release);
184}
185
186pub fn clear_min_log_level() {
187    MIN_LOG_LEVEL.store(MIN_LOG_LEVEL_UNSET, Ordering::Release);
188}
189
190#[must_use]
191pub fn min_log_level() -> Option<LogLevel> {
192    log_level_from_filter_value(MIN_LOG_LEVEL.load(Ordering::Acquire))
193}
194
195#[must_use]
196pub fn level_enabled(level: LogLevel) -> bool {
197    let minimum = MIN_LOG_LEVEL.load(Ordering::Acquire);
198    minimum == MIN_LOG_LEVEL_UNSET || log_level_filter_value(level) >= minimum
199}
200
201#[must_use]
202pub fn log_enabled(level: LogLevel) -> bool {
203    log_emitter_enabled() && level_enabled(level)
204}
205
206pub fn emit_entry(entry: &LogEntry) {
207    if let Some(emitter) = enabled_log_emitter(entry.level) {
208        emitter.emit(entry);
209    }
210}
211
212pub fn log(level: LogLevel, target: &'static str, message: impl Into<String>) {
213    log_with_fields(level, target, message, |builder| builder);
214}
215
216pub fn log_with_fields<Fields>(
217    level: LogLevel,
218    target: &'static str,
219    message: impl Into<String>,
220    fields: Fields,
221) where
222    Fields: FnOnce(LogEntryBuilder) -> LogEntryBuilder,
223{
224    let Some(emitter) = enabled_log_emitter(level) else {
225        return;
226    };
227
228    let entry = fields(LogEntryBuilder::new(level, target, message)).build();
229    emitter.emit(&entry);
230}
231
232fn enabled_log_emitter(level: LogLevel) -> Option<&'static dyn LogEmitter> {
233    if !log_emitter_enabled() || !level_enabled(level) {
234        return None;
235    }
236
237    GLOBAL_LOG_EMITTER.get().map(Box::as_ref)
238}
239
240const fn log_level_filter_value(level: LogLevel) -> u8 {
241    match level {
242        LogLevel::Trace => 1,
243        LogLevel::Debug => 2,
244        LogLevel::Info => 3,
245        LogLevel::Warn => 4,
246        LogLevel::Error => 5,
247    }
248}
249
250const fn log_level_from_filter_value(value: u8) -> Option<LogLevel> {
251    match value {
252        MIN_LOG_LEVEL_UNSET => None,
253        1 => Some(LogLevel::Trace),
254        2 => Some(LogLevel::Debug),
255        3 => Some(LogLevel::Info),
256        4 => Some(LogLevel::Warn),
257        5 => Some(LogLevel::Error),
258        _ => None,
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use std::cell::Cell;
265
266    use std::sync::{Arc, Mutex, PoisonError};
267
268    use super::{
269        LogEmitter, LogEntry, LogEntryBuilder, LogLevel, LogValue, NoopEmitter,
270        clear_min_log_level, install_log_emitter, level_enabled, log, min_log_level,
271        set_min_log_level,
272    };
273    use crate::tracing::{SpanGuard, TraceContext};
274
275    #[test]
276    fn log_levels_are_ordered_by_severity() {
277        assert!(LogLevel::Trace < LogLevel::Debug);
278        assert!(LogLevel::Debug < LogLevel::Info);
279        assert!(LogLevel::Info < LogLevel::Warn);
280        assert!(LogLevel::Warn < LogLevel::Error);
281    }
282
283    #[test]
284    fn public_logging_types_are_debug() {
285        fn assert_debug<T: std::fmt::Debug>() {}
286
287        assert_debug::<super::LogLevel>();
288        assert_debug::<super::LogValue>();
289        assert_debug::<super::LogEntry>();
290        assert_debug::<super::LogEntryBuilder>();
291        assert_debug::<super::NoopEmitter>();
292        assert_debug::<super::LogEmitterInstallError>();
293    }
294
295    #[test]
296    fn builder_constructs_structured_entries() {
297        let entry = LogEntryBuilder::new(LogLevel::Info, module_path!(), "user login")
298            .field("user_id", LogValue::String("alice".to_owned()))
299            .field("attempt", LogValue::I64(-1))
300            .field("count", LogValue::U64(2))
301            .field("ratio", LogValue::F64(0.5))
302            .field("success", LogValue::Bool(true))
303            .build();
304
305        assert_eq!(entry.level, LogLevel::Info);
306        assert_eq!(entry.target, module_path!());
307        assert_eq!(entry.message, "user login");
308        assert_eq!(entry.trace_context, None);
309        assert_eq!(entry.fields.len(), 5);
310        assert_eq!(
311            entry.fields[0],
312            ("user_id".to_owned(), LogValue::String("alice".to_owned()))
313        );
314        assert_eq!(entry.fields[1], ("attempt".to_owned(), LogValue::I64(-1)));
315        assert_eq!(entry.fields[2], ("count".to_owned(), LogValue::U64(2)));
316        assert!(
317            matches!(entry.fields[3], (ref key, LogValue::F64(value)) if key == "ratio" && (value - 0.5).abs() < f64::EPSILON)
318        );
319        assert_eq!(
320            entry.fields[4],
321            ("success".to_owned(), LogValue::Bool(true))
322        );
323    }
324
325    #[test]
326    fn builder_captures_active_trace_context_at_build_time() {
327        let builder = LogEntryBuilder::new(LogLevel::Debug, module_path!(), "inside span");
328        let guard = SpanGuard::start_conversation("conversation");
329
330        let entry = builder.build();
331
332        assert_eq!(entry.trace_context, Some(guard.context()));
333        assert_eq!(entry.trace_id(), Some(guard.context().trace_id()));
334        assert_eq!(entry.span_id(), Some(guard.context().span_id()));
335    }
336
337    #[test]
338    fn builder_records_no_context_after_span_leaves_scope() {
339        {
340            let guard = SpanGuard::start_conversation("conversation");
341            let entry = LogEntryBuilder::new(LogLevel::Info, module_path!(), "inside span").build();
342            assert_eq!(entry.trace_context, Some(guard.context()));
343        }
344
345        let entry = LogEntryBuilder::new(LogLevel::Info, module_path!(), "outside span").build();
346        assert_eq!(entry.trace_context, None);
347        assert_eq!(entry.trace_id(), None);
348        assert_eq!(entry.span_id(), None);
349    }
350
351    #[test]
352    fn noop_emitter_discards_entries() {
353        let emitter = NoopEmitter;
354        let entry = LogEntryBuilder::new(LogLevel::Warn, module_path!(), "discarded").build();
355
356        emitter.emit(&entry);
357    }
358
359    #[test]
360    fn minimum_level_filter_discards_lower_severity_entries() {
361        clear_min_log_level();
362        assert_eq!(min_log_level(), None);
363        assert!(level_enabled(LogLevel::Trace));
364        assert!(level_enabled(LogLevel::Debug));
365
366        set_min_log_level(LogLevel::Info);
367
368        assert_eq!(min_log_level(), Some(LogLevel::Info));
369        assert!(!level_enabled(LogLevel::Trace));
370        assert!(!level_enabled(LogLevel::Debug));
371        assert!(level_enabled(LogLevel::Info));
372        assert!(level_enabled(LogLevel::Warn));
373        assert!(level_enabled(LogLevel::Error));
374
375        clear_min_log_level();
376    }
377
378    /// Shared log of `(trace context, message)` pairs the recording emitter captures.
379    type RecordedLog = Arc<Mutex<Vec<(Option<TraceContext>, String)>>>;
380
381    /// Records the trace context and message of every emitted entry, so a test
382    /// can assert what the global `log` path actually emitted.
383    #[derive(Debug)]
384    struct RecordingEmitter(RecordedLog);
385
386    impl LogEmitter for RecordingEmitter {
387        fn emit(&self, entry: &LogEntry) {
388            self.0
389                .lock()
390                .unwrap_or_else(PoisonError::into_inner)
391                .push((entry.trace_context, entry.message.clone()));
392        }
393    }
394
395    // Single owner of the process-global log emitter: the emitter is a write-once
396    // OnceLock, so the no-op (no emitter), filtered, and emit-with-context paths are
397    // all exercised here in sequence rather than in separate, racy tests.
398    #[test]
399    fn global_log_path_is_no_op_until_installed_then_emits_with_trace_context() {
400        // 1. No emitter installed: the call does zero work — neither the fields
401        //    closure nor the message conversion runs.
402        clear_min_log_level();
403        let probed = Cell::new(false);
404        let mut fields_called = false;
405        super::log_with_fields(
406            LogLevel::Info,
407            module_path!(),
408            MessageProbe::new(&probed),
409            |builder| {
410                fields_called = true;
411                builder.field("called", LogValue::Bool(true))
412            },
413        );
414        assert!(
415            !fields_called,
416            "no-op log must not invoke the fields closure"
417        );
418        assert!(!probed.get(), "no-op log must not build the message");
419
420        // 2. Install the recording emitter (process-global, once).
421        let recorded = Arc::new(Mutex::new(Vec::new()));
422        assert!(
423            install_log_emitter(RecordingEmitter(Arc::clone(&recorded))).is_ok(),
424            "no emitter installed yet in this test binary"
425        );
426
427        // 3. Filtered: emitter installed but level below the floor → still no work,
428        //    and nothing is emitted to the sink.
429        set_min_log_level(LogLevel::Info);
430        let filtered_probe = Cell::new(false);
431        let mut filtered_fields = false;
432        super::log_with_fields(
433            LogLevel::Debug,
434            module_path!(),
435            MessageProbe::new(&filtered_probe),
436            |builder| {
437                filtered_fields = true;
438                builder.field("called", LogValue::Bool(true))
439            },
440        );
441        assert!(
442            !filtered_fields,
443            "filtered log must not invoke the fields closure"
444        );
445        assert!(
446            !filtered_probe.get(),
447            "filtered log must not build the message"
448        );
449        assert!(
450            recorded
451                .lock()
452                .unwrap_or_else(PoisonError::into_inner)
453                .is_empty(),
454            "filtered log must not reach the emitter"
455        );
456
457        // 4. Enabled, inside a span: the entry the emitter receives carries the
458        //    live trace context captured at emit time (end-to-end log -> emit wiring).
459        clear_min_log_level();
460        let context = {
461            let guard = SpanGuard::start_conversation("conversation");
462            log(LogLevel::Info, module_path!(), "inside span");
463            guard.context()
464        };
465
466        let entries = recorded.lock().unwrap_or_else(PoisonError::into_inner);
467        assert_eq!(
468            entries.len(),
469            1,
470            "exactly one entry emitted via the global path"
471        );
472        assert_eq!(
473            entries[0].0,
474            Some(context),
475            "emitted entry must carry the active trace context"
476        );
477        assert_eq!(entries[0].1, "inside span");
478        drop(entries);
479        clear_min_log_level();
480    }
481
482    #[derive(Debug)]
483    struct MessageProbe<'a>(&'a Cell<bool>);
484
485    impl<'a> MessageProbe<'a> {
486        const fn new(converted: &'a Cell<bool>) -> Self {
487            Self(converted)
488        }
489    }
490
491    impl From<MessageProbe<'_>> for String {
492        fn from(probe: MessageProbe<'_>) -> Self {
493            probe.0.set(true);
494            Self::new()
495        }
496    }
497}