Skip to main content

nros_log/
lib.rs

1//! Phase 88 — portable leveled-logging facade for nano-ros.
2//!
3//! See [`docs/roadmap/archived/phase-88-nros-log.md`](../../../docs/roadmap/archived/phase-88-nros-log.md)
4//! for the design and acceptance criteria.
5//!
6//! ## Layering
7//!
8//! - This crate carries only the portable types + dispatcher +
9//!   macros + `PlatformSink`. No backend code.
10//! - Per-platform log delivery is the responsibility of each
11//!   `nros-platform-<rtos>` crate, exposing
12//!   `nros_platform_log_write` / `nros_platform_log_flush` via the
13//!   `nros_platform_*` ABI (header at
14//!   `packages/platform/nros-platform-api/include/nros/platform.h`).
15//! - `PlatformSink` is the bridge: a single `LogSink` impl that
16//!   forwards to the ABI. Apps that want fan-out (e.g.
17//!   `Platform + /rosout`) compose a `&'static [&dyn LogSink]`
18//!   manually and pass it to [`init`].
19//!
20//! ## Quick start
21//!
22//! ```ignore
23//! use nros_log::{Logger, Severity};
24//! use nros_log::{nros_info, nros_warn};
25//!
26//! static LOGGER: Logger = Logger::new("my_node");
27//!
28//! fn main() {
29//!     nros_log::register_logger(&LOGGER);
30//!     nros_log::init(nros_log::sinks::default());
31//!     nros_info!(&LOGGER, "started; domain = {}", 42);
32//! }
33//! ```
34
35#![cfg_attr(not(feature = "std"), no_std)]
36#![deny(unsafe_op_in_unsafe_fn)]
37#![warn(missing_docs)]
38
39#[cfg(feature = "alloc")]
40extern crate alloc;
41
42// Phase 88.16.E — portable-atomic polyfill for CAS-less targets
43// (RISC-V `imc`, etc.). Feature unification: a consuming bare-metal
44// crate enables `unsafe-assume-single-core` / `critical-section` on
45// its own `portable-atomic` dep; native CAS targets get the
46// passthrough.
47use portable_atomic::{AtomicPtr, AtomicU8, Ordering};
48
49pub mod early;
50#[cfg(feature = "log-compat")]
51pub mod log_compat;
52pub mod macros;
53pub mod sinks;
54
55mod buffer;
56
57pub use buffer::{FormatBuffer, format_buffer_capacity};
58
59/// REP-2012 severity levels, mirroring `rcutils_log_severity_t`.
60///
61/// The integer representation is stable and part of the ABI for
62/// `nros_platform_log_write`. Lower value = more verbose.
63#[repr(u8)]
64#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
65pub enum Severity {
66    /// Per-instruction granularity. Off unless `max-level-trace` is
67    /// the active ceiling.
68    Trace = 0,
69    /// Diagnostic information useful while developing.
70    Debug = 1,
71    /// Normal operation events worth surfacing once.
72    Info = 2,
73    /// Unexpected but recoverable conditions.
74    Warn = 3,
75    /// Errors the caller should surface; the system continues.
76    Error = 4,
77    /// Unrecoverable — the system is about to abort.
78    Fatal = 5,
79}
80
81impl Severity {
82    /// Short uppercase label suitable for log-line rendering.
83    #[must_use]
84    pub const fn as_str(self) -> &'static str {
85        match self {
86            Self::Trace => "TRACE",
87            Self::Debug => "DEBUG",
88            Self::Info => "INFO",
89            Self::Warn => "WARN",
90            Self::Error => "ERROR",
91            Self::Fatal => "FATAL",
92        }
93    }
94
95    /// Stable `u8` discriminant for cross-ABI use.
96    #[must_use]
97    pub const fn as_u8(self) -> u8 {
98        self as u8
99    }
100}
101
102/// Reconstruct a [`Severity`] from its `u8` discriminant.
103///
104/// Returns `None` for `> 5`.
105#[must_use]
106pub const fn severity_from_u8(value: u8) -> Option<Severity> {
107    match value {
108        0 => Some(Severity::Trace),
109        1 => Some(Severity::Debug),
110        2 => Some(Severity::Info),
111        3 => Some(Severity::Warn),
112        4 => Some(Severity::Error),
113        5 => Some(Severity::Fatal),
114        _ => None,
115    }
116}
117
118/// Compile-time ceiling check used by the `nros_*!` macros.
119///
120/// Returns `true` iff `severity` is allowed under the configured
121/// `max-level-*` feature.
122#[must_use]
123pub const fn severity_enabled_at_compile_time(severity: Severity) -> bool {
124    if cfg!(feature = "max-level-off") {
125        return false;
126    }
127    let ceiling = compile_time_ceiling();
128    (severity as u8) >= (ceiling as u8)
129}
130
131const fn compile_time_ceiling() -> Severity {
132    if cfg!(feature = "max-level-trace") {
133        Severity::Trace
134    } else if cfg!(feature = "max-level-debug") {
135        Severity::Debug
136    } else if cfg!(feature = "max-level-info") {
137        Severity::Info
138    } else if cfg!(feature = "max-level-warn") {
139        Severity::Warn
140    } else if cfg!(feature = "max-level-error") {
141        Severity::Error
142    } else {
143        // No ceiling feature = treat as `max-level-trace`.
144        Severity::Trace
145    }
146}
147
148/// One log entry, handed to each [`LogSink`].
149///
150/// `message` is already formatted — sinks must NOT re-format.
151#[derive(Debug)]
152pub struct Record<'a> {
153    /// Severity of the record.
154    pub severity: Severity,
155    /// Name of the originating [`Logger`].
156    pub logger_name: &'a str,
157    /// Formatted message text (no trailing newline).
158    pub message: &'a str,
159    /// File the macro invocation came from (`core::file!()`).
160    pub file: &'static str,
161    /// Line within `file` (`core::line!()`).
162    pub line: u32,
163    /// Monotonic timestamp in nanoseconds. `0` if unavailable.
164    pub timestamp_ns: u64,
165}
166
167/// Backend a log record is delivered to.
168///
169/// Implementations must be `Sync` so the dispatcher can hold them
170/// in `&'static [&dyn LogSink]`. ISR-safety is per-impl — see the
171/// table in `docs/roadmap/archived/phase-88-nros-log.md`.
172pub trait LogSink: Sync {
173    /// Render `record`. Called only when the record's severity passes
174    /// both the compile-time ceiling AND the [`Logger`]'s runtime
175    /// threshold.
176    fn log(&self, record: &Record<'_>);
177
178    /// Optional flush hook (default no-op).
179    fn flush(&self) {}
180}
181
182/// A named logger with a runtime severity threshold.
183///
184/// Threshold defaults to [`Severity::Info`]. Use [`register_logger`]
185/// to publish a `'static Logger` so multiple call sites with the
186/// same name share the same threshold.
187pub struct Logger {
188    name: &'static str,
189    level: AtomicU8,
190}
191
192impl Logger {
193    /// `const`-construct with the default threshold ([`Severity::Info`]).
194    #[must_use]
195    pub const fn new(name: &'static str) -> Self {
196        Self {
197            name,
198            level: AtomicU8::new(Severity::Info as u8),
199        }
200    }
201
202    /// `const`-construct with an explicit threshold.
203    #[must_use]
204    pub const fn with_level(name: &'static str, level: Severity) -> Self {
205        Self {
206            name,
207            level: AtomicU8::new(level as u8),
208        }
209    }
210
211    /// Logger name (used as `Record::logger_name`).
212    #[must_use]
213    pub const fn name(&self) -> &'static str {
214        self.name
215    }
216
217    /// Current runtime threshold.
218    #[must_use]
219    pub fn level(&self) -> Severity {
220        severity_from_u8(self.level.load(Ordering::Relaxed)).unwrap_or(Severity::Info)
221    }
222
223    /// Update the runtime threshold.
224    pub fn set_level(&self, level: Severity) {
225        self.level.store(level as u8, Ordering::Relaxed);
226    }
227
228    /// Whether a record at `severity` would be emitted by this
229    /// logger AT RUNTIME.
230    #[must_use]
231    pub fn is_enabled(&self, severity: Severity) -> bool {
232        (severity as u8) >= self.level.load(Ordering::Relaxed)
233    }
234
235    /// Hand `record` to every registered sink, after the runtime
236    /// threshold check.
237    ///
238    /// Macros call this; user code should not.
239    pub fn dispatch(&self, record: &Record<'_>) {
240        if !self.is_enabled(record.severity) {
241            return;
242        }
243        dispatch_to_sinks(record);
244    }
245}
246
247// -----------------------------------------------------------------------------
248// Static intern table for `get_logger("name")`. Bounded; no alloc.
249// -----------------------------------------------------------------------------
250
251/// Maximum number of named loggers that can be registered via
252/// [`register_logger`]. Beyond this, [`get_logger`] returns
253/// [`DEFAULT_LOGGER`].
254pub const MAX_LOGGERS: usize = 32;
255
256/// Catch-all logger returned when the requested name is not
257/// registered (or the intern table is full).
258pub static DEFAULT_LOGGER: Logger = Logger::new("nros");
259
260mod intern {
261    use super::{AtomicPtr, Logger, MAX_LOGGERS, Ordering};
262
263    pub(super) struct InternTable {
264        slots: [AtomicPtr<Logger>; MAX_LOGGERS],
265    }
266
267    impl InternTable {
268        pub(super) const fn new() -> Self {
269            // `AtomicPtr::new` is `const` on both `core::sync::atomic`
270            // and `portable_atomic`, so we can initialise the array
271            // by repeating the call rather than naming a `const` —
272            // which clippy flags as interior-mutable.
273            #[allow(clippy::declare_interior_mutable_const)]
274            const NULL: AtomicPtr<Logger> = AtomicPtr::new(core::ptr::null_mut());
275            Self {
276                slots: [NULL; MAX_LOGGERS],
277            }
278        }
279
280        pub(super) fn lookup(&self, name: &str) -> Option<&'static Logger> {
281            for slot in &self.slots {
282                let ptr = slot.load(Ordering::Acquire);
283                if ptr.is_null() {
284                    return None;
285                }
286                // SAFETY: pointer published via Release after the
287                // owner constructed a `'static Logger`. The Acquire
288                // load synchronizes.
289                let logger: &'static Logger = unsafe { &*ptr };
290                if logger.name() == name {
291                    return Some(logger);
292                }
293            }
294            None
295        }
296
297        pub(super) fn insert(&self, logger: &'static Logger) -> Option<&'static Logger> {
298            if let Some(existing) = self.lookup(logger.name()) {
299                return Some(existing);
300            }
301            let ptr = logger as *const _ as *mut Logger;
302            for slot in &self.slots {
303                if slot
304                    .compare_exchange(
305                        core::ptr::null_mut(),
306                        ptr,
307                        Ordering::AcqRel,
308                        Ordering::Acquire,
309                    )
310                    .is_ok()
311                {
312                    return Some(logger);
313                }
314                let existing_ptr = slot.load(Ordering::Acquire);
315                if !existing_ptr.is_null() {
316                    // SAFETY: same publication invariant as `lookup`.
317                    let existing: &'static Logger = unsafe { &*existing_ptr };
318                    if existing.name() == logger.name() {
319                        return Some(existing);
320                    }
321                }
322            }
323            None
324        }
325    }
326}
327
328static INTERN: intern::InternTable = intern::InternTable::new();
329
330/// Publish `logger` under its name so subsequent `get_logger`
331/// calls with that name return THIS reference.
332///
333/// On name collision returns the pre-existing entry (the input
334/// `logger` is NOT inserted). On a full table returns
335/// [`DEFAULT_LOGGER`].
336pub fn register_logger(logger: &'static Logger) -> &'static Logger {
337    INTERN.insert(logger).unwrap_or(&DEFAULT_LOGGER)
338}
339
340/// Look up a registered logger by name. Returns [`DEFAULT_LOGGER`]
341/// if none is registered (call [`register_logger`] for a `'static
342/// Logger` to publish one).
343///
344/// Total — every call returns a usable handle the macros can
345/// dispatch through.
346#[must_use]
347pub fn get_logger(name: &str) -> &'static Logger {
348    INTERN.lookup(name).unwrap_or(&DEFAULT_LOGGER)
349}
350
351// -----------------------------------------------------------------------------
352// Sink list. Set once at `init`; read every dispatch.
353// -----------------------------------------------------------------------------
354
355static SINKS_PTR: AtomicPtr<&'static [&'static dyn LogSink]> =
356    AtomicPtr::new(core::ptr::null_mut());
357
358// issue 0710 — `init_default()` was here. It named a default this crate can no
359// longer name: the platform sink moved to `nros_platform_cffi::log`, where the
360// ABI it speaks is a dependency rather than a feature. Call
361// `nros_platform_cffi::log::init_default()`, or `init()` with your own sinks.
362
363/// Install the global sink list.
364///
365/// MUST be called at app startup BEFORE any record-emitting macro
366/// runs (otherwise the dispatch is a no-op — records are silently
367/// dropped). Calling `init` more than once swaps the list
368/// atomically; the previous pointer is leaked (intentional: the
369/// read path is lock-free so we can't safely free).
370///
371/// The sinks themselves must outlive the program (`'static`).
372pub fn init(sinks: &'static [&'static dyn LogSink]) {
373    // Indirect through a small `'static` cell so the read path
374    // dereferences a fat-pointer-sized slot rather than reading
375    // a wide pointer atomically.
376    #[cfg(feature = "alloc")]
377    {
378        let boxed: alloc::boxed::Box<&'static [&'static dyn LogSink]> =
379            alloc::boxed::Box::new(sinks);
380        let ptr = alloc::boxed::Box::into_raw(boxed);
381        SINKS_PTR.store(ptr, Ordering::Release);
382    }
383    #[cfg(not(feature = "alloc"))]
384    {
385        static CELL: SinkSlot = SinkSlot::new();
386        CELL.store(sinks);
387        SINKS_PTR.store(CELL.as_ptr(), Ordering::Release);
388    }
389    // AFTER publishing, so a record raised during the drain reaches `sinks`
390    // directly rather than joining a ring nobody will drain again.
391    early::drain(sinks);
392    let lost = early::overflowed();
393    if lost > 0 {
394        // Reported through the sinks just installed, because the alternative is
395        // a silent hole exactly where the boot story is (`nros-log` cannot know
396        // what those records said, but it does know how many there were).
397        let logger = Logger::new("nros_log");
398        crate::nros_warn!(
399            &logger,
400            "{lost} record(s) raised before `init` did not fit the early ring \
401             (see `nros_log::early`; raise `early-records-<N>`)"
402        );
403    }
404}
405
406#[cfg(not(feature = "alloc"))]
407struct SinkSlot {
408    inner: core::cell::UnsafeCell<Option<&'static [&'static dyn LogSink]>>,
409}
410
411#[cfg(not(feature = "alloc"))]
412// SAFETY: only written from `init`, which the user contracts to call
413// once at startup before any concurrent reader exists.
414unsafe impl Sync for SinkSlot {}
415
416#[cfg(not(feature = "alloc"))]
417impl SinkSlot {
418    const fn new() -> Self {
419        Self {
420            inner: core::cell::UnsafeCell::new(None),
421        }
422    }
423    fn store(&self, sinks: &'static [&'static dyn LogSink]) {
424        // SAFETY: see Sync note above.
425        unsafe {
426            *self.inner.get() = Some(sinks);
427        }
428    }
429    fn as_ptr(&self) -> *mut &'static [&'static dyn LogSink] {
430        self.inner.get().cast()
431    }
432}
433
434/// Current monotonic time for `Record::timestamp_ns` (issue #503).
435///
436/// With the `platform-clock` feature this reads
437/// `nros_platform_clock_ns` — the universal per-platform export the
438/// executor's timer accounting already links — scaled to nanoseconds.
439/// Without the feature it returns `0` ("unavailable"), the historical
440/// behavior, and imposes no link-time requirement.
441///
442/// Public because the emission macros expand it in user crates; not
443/// part of the supported API surface.
444#[doc(hidden)]
445#[must_use]
446pub fn __timestamp_ns() -> u64 {
447    #[cfg(feature = "platform-clock")]
448    {
449        unsafe extern "C" {
450            fn nros_platform_clock_ns() -> u64;
451        }
452        // SAFETY: bare query of the platform's monotonic us counter;
453        // the symbol comes from whichever `nros-platform-<rtos>` port
454        // linked the binary (the contract `PlatformSink ->
455        // nros_platform_log_write` already relies on).
456        unsafe { nros_platform_clock_ns() }
457    }
458    #[cfg(not(feature = "platform-clock"))]
459    {
460        0
461    }
462}
463
464fn dispatch_to_sinks(record: &Record<'_>) {
465    if recursion_guard_check_and_set() {
466        return;
467    }
468    let ptr = SINKS_PTR.load(Ordering::Acquire);
469    if ptr.is_null() {
470        // Nothing installed yet — HOLD the record; `init` replays it into
471        // whatever sinks the board chooses. See `early` for why this crate
472        // does not reach for the platform sink itself: doing that (issue 0710)
473        // put `nros_platform_log_write` on a path every binary executes, which
474        // turned a pluggable delivery into a link-time requirement that no
475        // Cargo feature can undo under workspace feature unification.
476        //
477        // It is also a better answer to issue 0708 than installing a default
478        // was: the early records land in the sink the board picked, not in the
479        // one dispatch guessed before the board had spoken.
480        early::hold(record);
481        recursion_guard_clear();
482        return;
483    }
484    {
485        // SAFETY: `init` published a valid `'static` slice reference.
486        let sinks: &'static [&'static dyn LogSink] = unsafe { *ptr };
487        for sink in sinks {
488            sink.log(record);
489        }
490    }
491    recursion_guard_clear();
492}
493
494/// Flush every registered sink.
495pub fn flush() {
496    let ptr = SINKS_PTR.load(Ordering::Acquire);
497    if ptr.is_null() {
498        return;
499    }
500    // SAFETY: same invariant as `dispatch_to_sinks`.
501    let sinks: &'static [&'static dyn LogSink] = unsafe { *ptr };
502    for sink in sinks {
503        sink.flush();
504    }
505}
506
507// -----------------------------------------------------------------------------
508// Recursion guard — process-global single AtomicBool.
509//
510// Granularity is intentionally coarse (process-wide, not per-thread).
511// The guard exists to break a sink that triggers log() during write
512// — not to serialize concurrent loggers across threads. A thread
513// re-entering through its own sink loses its other in-flight
514// sinks for that call; a different thread logging concurrently is
515// also short-circuited momentarily. This is acceptable: the alt is
516// per-thread storage which doesn't exist uniformly across our
517// `no_std` targets (`thread_local!` requires `std`).
518// -----------------------------------------------------------------------------
519
520use portable_atomic::AtomicBool;
521static RECURSION_GUARD: AtomicBool = AtomicBool::new(false);
522
523fn recursion_guard_check_and_set() -> bool {
524    RECURSION_GUARD
525        .compare_exchange(false, true, Ordering::Acquire, Ordering::Acquire)
526        .is_err()
527}
528
529fn recursion_guard_clear() {
530    RECURSION_GUARD.store(false, Ordering::Release);
531}
532
533// -----------------------------------------------------------------------------
534// Tests (host-only).
535// -----------------------------------------------------------------------------
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540
541    #[test]
542    fn severity_round_trips_through_u8() {
543        for s in [
544            Severity::Trace,
545            Severity::Debug,
546            Severity::Info,
547            Severity::Warn,
548            Severity::Error,
549            Severity::Fatal,
550        ] {
551            assert_eq!(severity_from_u8(s.as_u8()), Some(s));
552        }
553        assert_eq!(severity_from_u8(99), None);
554    }
555
556    #[test]
557    fn logger_runtime_threshold_filters_below() {
558        let logger = Logger::with_level("test_thresh", Severity::Warn);
559        assert!(!logger.is_enabled(Severity::Info));
560        assert!(logger.is_enabled(Severity::Warn));
561        assert!(logger.is_enabled(Severity::Error));
562        logger.set_level(Severity::Debug);
563        assert!(logger.is_enabled(Severity::Info));
564    }
565
566    #[test]
567    fn unregistered_get_logger_returns_default() {
568        let l = get_logger("definitely-not-registered-99");
569        assert_eq!(l.name(), DEFAULT_LOGGER.name());
570    }
571
572    #[test]
573    fn registered_logger_round_trips_through_intern_table() {
574        static LOGGER: Logger = Logger::new("test_intern_round_trip");
575        let published = register_logger(&LOGGER);
576        assert_eq!(published.name(), LOGGER.name());
577        let looked_up = get_logger("test_intern_round_trip");
578        assert!(core::ptr::eq(published, looked_up));
579    }
580
581    #[test]
582    fn compile_time_ceiling_matches_enabled_feature() {
583        let expected = if cfg!(feature = "max-level-off") {
584            None
585        } else if cfg!(feature = "max-level-trace") {
586            Some(Severity::Trace)
587        } else if cfg!(feature = "max-level-debug") {
588            Some(Severity::Debug)
589        } else if cfg!(feature = "max-level-info") {
590            Some(Severity::Info)
591        } else if cfg!(feature = "max-level-warn") {
592            Some(Severity::Warn)
593        } else if cfg!(feature = "max-level-error") {
594            Some(Severity::Error)
595        } else {
596            // No ceiling feature = treat as `max-level-trace`.
597            Some(Severity::Trace)
598        };
599
600        for severity in [
601            Severity::Trace,
602            Severity::Debug,
603            Severity::Info,
604            Severity::Warn,
605            Severity::Error,
606            Severity::Fatal,
607        ] {
608            let enabled = expected.is_some_and(|ceiling| severity >= ceiling);
609            assert_eq!(severity_enabled_at_compile_time(severity), enabled);
610        }
611    }
612}