Skip to main content

dynomite/core/log/
mod.rs

1//! Leveled logger built on `tracing` + `tracing-subscriber`.
2//!
3//! The Dynomite C engine uses an integer log level on the `-v` command
4//! line (`LOG_EMERG = 0` ... `LOG_PVERB = 11`). The Rust port preserves
5//! the numeric scale exactly: callers pass the same 0..11 verbosity and
6//! [`tracing_level_for`] maps it onto the underlying `tracing` level
7//! filter.
8//!
9//! On startup, [`log_init`] installs a global [`tracing_subscriber`]
10//! that writes to either standard error or to a configurable log file.
11//! Sending `SIGHUP` reopens the log file at the stored path; the helper
12//! [`reopen_on_sighup`] is what the signal handler invokes.
13//!
14//! Four output shapes are supported, dispatched by [`LogFormat`]:
15//! [`LogFormat::Default`] (the historical text format), [`LogFormat::Rfc5424`]
16//! (modern syslog), [`LogFormat::Rfc3164`] (BSD syslog), and
17//! [`LogFormat::Json`] (NDJSON). Operators select a shape via the
18//! `log_format:` configuration key or the `--log-format` CLI flag;
19//! when neither is set the default value reproduces the pre-existing
20//! behavior byte-for-byte.
21//!
22//! # Examples
23//!
24//! ```
25//! use dynomite::core::log::{log_init, tracing_level_for, LOG_NOTICE};
26//! use tracing::Level;
27//!
28//! assert_eq!(tracing_level_for(LOG_NOTICE), Level::INFO);
29//! // `log_init` is process-global; this is illustrative only.
30//! let _ = log_init(LOG_NOTICE, None);
31//! ```
32
33use std::fs::{File, OpenOptions};
34use std::io::{self, Write};
35use std::path::{Path, PathBuf};
36use std::sync::atomic::{AtomicU8, Ordering};
37use std::sync::OnceLock;
38
39use parking_lot::Mutex;
40use tracing::Level;
41use tracing_subscriber::filter::LevelFilter;
42use tracing_subscriber::fmt::MakeWriter;
43use tracing_subscriber::layer::SubscriberExt as _;
44use tracing_subscriber::registry::LookupSpan;
45use tracing_subscriber::util::SubscriberInitExt as _;
46use tracing_subscriber::{EnvFilter, Layer, Registry};
47
48use crate::core::types::{DynError, Status};
49
50mod format;
51mod host;
52mod syslog;
53
54pub use format::{LogFormat, LogFormatParseError};
55pub use host::local_hostname;
56
57/// System is unusable.
58pub const LOG_EMERG: u8 = 0;
59/// Action must be taken immediately.
60pub const LOG_ALERT: u8 = 1;
61/// Critical conditions.
62pub const LOG_CRIT: u8 = 2;
63/// Error conditions.
64pub const LOG_ERR: u8 = 3;
65/// Warning conditions.
66pub const LOG_WARN: u8 = 4;
67/// Normal but significant condition (default).
68pub const LOG_NOTICE: u8 = 5;
69/// Informational.
70pub const LOG_INFO: u8 = 6;
71/// Debug messages.
72pub const LOG_DEBUG: u8 = 7;
73/// Verbose messages.
74pub const LOG_VERB: u8 = 8;
75/// Verbose messages, second tier.
76pub const LOG_VVERB: u8 = 9;
77/// Verbose messages, third tier.
78pub const LOG_VVVERB: u8 = 10;
79/// Periodic verbose messages.
80pub const LOG_PVERB: u8 = 11;
81
82/// Largest accepted verbosity level.
83///
84/// # Examples
85///
86/// ```
87/// use dynomite::core::log::{clamp_level, LOG_LEVEL_MAX};
88/// assert_eq!(clamp_level(LOG_LEVEL_MAX + 5), LOG_LEVEL_MAX);
89/// ```
90pub const LOG_LEVEL_MAX: u8 = LOG_PVERB;
91
92/// Map a Dynomite numeric verbosity to a `tracing::Level`.
93///
94/// Levels 0..=4 are mapped to `ERROR`, 5..=6 to `INFO`, 7 to `DEBUG`,
95/// and 8..=11 to `TRACE`. Values above [`LOG_LEVEL_MAX`] saturate to
96/// `TRACE`.
97///
98/// # Examples
99///
100/// ```
101/// use dynomite::core::log::{tracing_level_for, LOG_DEBUG, LOG_PVERB, LOG_WARN};
102/// use tracing::Level;
103///
104/// assert_eq!(tracing_level_for(LOG_WARN), Level::ERROR);
105/// assert_eq!(tracing_level_for(LOG_DEBUG), Level::DEBUG);
106/// assert_eq!(tracing_level_for(LOG_PVERB), Level::TRACE);
107/// ```
108pub fn tracing_level_for(level: u8) -> Level {
109    match level {
110        0..=4 => Level::ERROR,
111        5..=6 => Level::INFO,
112        7 => Level::DEBUG,
113        _ => Level::TRACE,
114    }
115}
116
117/// Clamp the supplied verbosity to the inclusive `[0, LOG_LEVEL_MAX]`
118/// window.
119///
120/// # Examples
121///
122/// ```
123/// use dynomite::core::log::{clamp_level, LOG_LEVEL_MAX};
124/// assert_eq!(clamp_level(3), 3);
125/// assert_eq!(clamp_level(255), LOG_LEVEL_MAX);
126/// ```
127pub fn clamp_level(level: u8) -> u8 {
128    level.min(LOG_LEVEL_MAX)
129}
130
131struct State {
132    path: Mutex<Option<PathBuf>>,
133    sink: Mutex<Box<dyn Write + Send>>,
134    nerror: Mutex<u64>,
135}
136
137static STATE: OnceLock<State> = OnceLock::new();
138static CURRENT_LEVEL: AtomicU8 = AtomicU8::new(LOG_NOTICE);
139
140#[derive(Clone)]
141struct LoggerWriter;
142
143impl Write for LoggerWriter {
144    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
145        let Some(state) = STATE.get() else {
146            return io::stderr().write(buf);
147        };
148        let mut sink = state.sink.lock();
149        match sink.write_all(buf) {
150            Ok(()) => Ok(buf.len()),
151            Err(err) => {
152                *state.nerror.lock() += 1;
153                Err(err)
154            }
155        }
156    }
157
158    fn flush(&mut self) -> io::Result<()> {
159        let Some(state) = STATE.get() else {
160            return io::stderr().flush();
161        };
162        let mut sink = state.sink.lock();
163        sink.flush()
164    }
165}
166
167impl<'a> MakeWriter<'a> for LoggerWriter {
168    type Writer = LoggerWriter;
169    fn make_writer(&'a self) -> Self::Writer {
170        LoggerWriter
171    }
172}
173
174fn open_log_file(path: &Path) -> io::Result<File> {
175    OpenOptions::new()
176        .append(true)
177        .create(true)
178        .mode_for_append()
179        .open(path)
180}
181
182trait OpenOptionsExt {
183    fn mode_for_append(&mut self) -> &mut Self;
184}
185
186impl OpenOptionsExt for OpenOptions {
187    #[cfg(unix)]
188    fn mode_for_append(&mut self) -> &mut Self {
189        use std::os::unix::fs::OpenOptionsExt as _;
190        self.mode(0o644)
191    }
192    #[cfg(not(unix))]
193    fn mode_for_append(&mut self) -> &mut Self {
194        self
195    }
196}
197
198/// Bundle of tunables consumed by [`build_logs_layer`] and
199/// [`install_logs_only`].
200///
201/// Mirrors the legacy positional triple `(level, path, format)`
202/// that [`log_init_with_format`] takes; lifting it to a struct
203/// lets the binary share a single value between the OTLP-on and
204/// OTLP-off install paths.
205///
206/// # Examples
207///
208/// ```
209/// use dynomite::core::log::{LogConfig, LogFormat, LOG_NOTICE};
210/// let cfg = LogConfig::new(LOG_NOTICE, None, LogFormat::Json);
211/// assert_eq!(cfg.verbosity, LOG_NOTICE);
212/// assert_eq!(cfg.format, LogFormat::Json);
213/// ```
214#[derive(Debug, Clone)]
215pub struct LogConfig {
216    /// Numeric verbosity (`0..=LOG_LEVEL_MAX`).
217    pub verbosity: u8,
218    /// Optional log file. When `None`, log records flow to standard
219    /// error.
220    pub output: Option<PathBuf>,
221    /// Wire shape for emitted records.
222    pub format: LogFormat,
223}
224
225impl LogConfig {
226    /// Convenience constructor.
227    ///
228    /// # Examples
229    ///
230    /// ```
231    /// use dynomite::core::log::{LogConfig, LogFormat, LOG_INFO};
232    /// let cfg = LogConfig::new(LOG_INFO, None, LogFormat::Default);
233    /// assert_eq!(cfg.verbosity, LOG_INFO);
234    /// ```
235    pub fn new(verbosity: u8, output: Option<PathBuf>, format: LogFormat) -> Self {
236        Self {
237            verbosity,
238            output,
239            format,
240        }
241    }
242}
243
244/// Boxed fmt layer returned by [`build_logs_layer`].
245///
246/// The layer is parameterised on [`tracing_subscriber::Registry`]
247/// so callers can drop it into any registry stack the binary
248/// builds (with or without an [`tracing_opentelemetry`] layer
249/// stacked on top).
250pub type LogsLayer = Box<dyn Layer<Registry> + Send + Sync + 'static>;
251
252/// Token returned by [`build_logs_layer`] proving the SIGHUP
253/// log-reopen state has been initialised.
254///
255/// The token is zero-sized; its sole purpose is to make the
256/// "build the fmt layer, then install it as a global" handshake
257/// type-checked: callers cannot call [`reopen_on_sighup`] before
258/// the writer state has been wired, because they cannot construct
259/// a [`ReopenHandle`] without first calling [`build_logs_layer`]
260/// or one of its convenience wrappers.
261///
262/// # Examples
263///
264/// ```
265/// use dynomite::core::log::{build_logs_layer, LogConfig, LogFormat, LOG_NOTICE};
266/// // Building the layer also populates the SIGHUP-reopen state.
267/// // The handle below is the proof of that wiring.
268/// // (The example does not install the layer as a global so it
269/// // can run side-by-side with the rest of the doctest suite.)
270/// let cfg = LogConfig::new(LOG_NOTICE, None, LogFormat::Default);
271/// let _ = build_logs_layer(&cfg);
272/// ```
273#[derive(Debug)]
274#[must_use = "the reopen handle must be threaded into install_global so SIGHUP-reopen is wired"]
275pub struct ReopenHandle {
276    _private: (),
277}
278
279/// Build the EnvFilter the install paths feed into the registry.
280///
281/// Honours `RUST_LOG` and falls back to the `tracing` level
282/// derived from the supplied `verbosity`.
283///
284/// # Examples
285///
286/// ```
287/// use dynomite::core::log::{build_env_filter, LOG_NOTICE};
288/// let _filter = build_env_filter(LOG_NOTICE);
289/// ```
290pub fn build_env_filter(verbosity: u8) -> EnvFilter {
291    let level_filter = LevelFilter::from_level(tracing_level_for(clamp_level(verbosity)));
292    EnvFilter::builder()
293        .with_default_directive(level_filter.into())
294        .from_env_lossy()
295}
296
297fn init_reopen_state(verbosity: u8, path: Option<&Path>) -> Result<ReopenHandle, DynError> {
298    let sink: Box<dyn Write + Send> = match path {
299        Some(p) => Box::new(open_log_file(p).map_err(DynError::Io)?),
300        None => Box::new(io::stderr()),
301    };
302    let stored_path = path.map(PathBuf::from);
303
304    let state = State {
305        path: Mutex::new(stored_path),
306        sink: Mutex::new(sink),
307        nerror: Mutex::new(0),
308    };
309    STATE
310        .set(state)
311        .map_err(|_| DynError::generic("log: writer state already installed"))?;
312    CURRENT_LEVEL.store(clamp_level(verbosity), Ordering::Relaxed);
313    Ok(ReopenHandle { _private: () })
314}
315
316/// Build the fmt layer for the configured shape and wire the
317/// SIGHUP-reopen writer state.
318///
319/// Returns the boxed layer plus a [`ReopenHandle`] proving the
320/// internal writer state has been populated. The layer is *not*
321/// installed as a global; the caller composes it into a
322/// [`tracing_subscriber::Registry`] (typically together with an
323/// [`EnvFilter`] and an optional `OpenTelemetryLayer`) and calls
324/// `try_init`.
325///
326/// May only be called once per process: the underlying writer
327/// state is a `OnceLock` and a second call returns
328/// [`DynError::Generic`].
329///
330/// # Errors
331/// Returns [`DynError::Io`] when the configured `output` cannot
332/// be opened for append, and [`DynError::Generic`] when the
333/// writer state has already been initialised.
334///
335/// # Examples
336///
337/// ```no_run
338/// use dynomite::core::log::{build_env_filter, build_logs_layer, LogConfig, LogFormat, LOG_NOTICE};
339/// use tracing_subscriber::layer::SubscriberExt as _;
340/// use tracing_subscriber::util::SubscriberInitExt as _;
341///
342/// let cfg = LogConfig::new(LOG_NOTICE, None, LogFormat::Default);
343/// let (layer, _reopen) = build_logs_layer(&cfg).expect("build layer");
344/// tracing_subscriber::registry()
345///     .with(layer)
346///     .with(build_env_filter(LOG_NOTICE))
347///     .try_init()
348///     .expect("install");
349/// ```
350pub fn build_logs_layer(cfg: &LogConfig) -> Result<(LogsLayer, ReopenHandle), DynError> {
351    let reopen = init_reopen_state(cfg.verbosity, cfg.output.as_deref())?;
352    let layer = fmt_layer_for_format::<Registry>(cfg.format);
353    Ok((layer, reopen))
354}
355
356fn fmt_layer_for_format<S>(format: LogFormat) -> Box<dyn Layer<S> + Send + Sync + 'static>
357where
358    S: tracing::Subscriber + for<'lookup> LookupSpan<'lookup>,
359{
360    match format {
361        LogFormat::Default => Box::new(
362            tracing_subscriber::fmt::Layer::default()
363                .with_writer(LoggerWriter)
364                .with_target(true),
365        ),
366        LogFormat::Json => Box::new(
367            tracing_subscriber::fmt::Layer::default()
368                .with_writer(LoggerWriter)
369                .with_target(true)
370                .json()
371                .flatten_event(false)
372                .with_current_span(true)
373                .with_span_list(false),
374        ),
375        LogFormat::Rfc5424 => Box::new(
376            tracing_subscriber::fmt::Layer::default()
377                .with_writer(LoggerWriter)
378                .event_format(syslog::Rfc5424Formatter::new())
379                .with_ansi(false),
380        ),
381        LogFormat::Rfc3164 => Box::new(
382            tracing_subscriber::fmt::Layer::default()
383                .with_writer(LoggerWriter)
384                .event_format(syslog::Rfc3164Formatter::new())
385                .with_ansi(false),
386        ),
387    }
388}
389
390/// Install a fmt-only global tracing subscriber.
391///
392/// Composes a [`Registry`] with the EnvFilter built from
393/// `cfg.verbosity` and the fmt layer built from `cfg.format`,
394/// then sets it as the global default. The SIGHUP-reopen writer
395/// state is wired as a side effect.
396///
397/// This is the OTLP-off install path; the OTLP-on install path
398/// lives in [`dynomited::observability::install_global`] and
399/// stacks an `OpenTelemetryLayer` on top of the same fmt layer.
400///
401/// May only be called once per process.
402///
403/// # Errors
404/// Returns [`DynError::Io`] when `cfg.output` cannot be opened
405/// for append, and [`DynError::Generic`] when a global tracing
406/// subscriber has already been installed.
407///
408/// # Examples
409///
410/// ```no_run
411/// use dynomite::core::log::{install_logs_only, LogConfig, LogFormat, LOG_NOTICE};
412/// install_logs_only(&LogConfig::new(LOG_NOTICE, None, LogFormat::Default))
413///     .expect("install logger");
414/// ```
415pub fn install_logs_only(cfg: &LogConfig) -> Status {
416    let env = build_env_filter(cfg.verbosity);
417    let (fmt_layer, _reopen) = build_logs_layer(cfg)?;
418    tracing_subscriber::registry()
419        .with(fmt_layer)
420        .with(env)
421        .try_init()
422        .map_err(|e| DynError::generic(format!("install_logs_only: {e}")))?;
423    Ok(())
424}
425
426/// Install the global tracing subscriber.
427///
428/// `level` is the C-style numeric verbosity in `0..=LOG_LEVEL_MAX`.
429/// Values above the maximum saturate. When `path` is `Some`, log
430/// records are appended to that file (created if missing); when `None`,
431/// records are written to standard error.
432///
433/// This entry point preserves the historical default output shape
434/// ([`LogFormat::Default`]). To pick a different shape, call
435/// [`log_init_with_format`] directly. Both wrappers delegate to
436/// [`install_logs_only`].
437///
438/// `log_init` may be called only once per process; subsequent calls
439/// return [`DynError::Generic`].
440///
441/// # Examples
442///
443/// ```no_run
444/// use dynomite::core::log::{log_init, LOG_NOTICE};
445/// log_init(LOG_NOTICE, None).expect("install logger");
446/// ```
447pub fn log_init(level: u8, path: Option<&Path>) -> Status {
448    log_init_with_format(level, path, LogFormat::Default)
449}
450
451/// Install the global tracing subscriber with a chosen output shape.
452///
453/// See [`LogFormat`] for the supported values. The default value
454/// (`LogFormat::Default`) is byte-identical to what [`log_init`]
455/// installs, so passing it here is equivalent to the original
456/// two-argument call.
457///
458/// # Examples
459///
460/// ```no_run
461/// use dynomite::core::log::{log_init_with_format, LogFormat, LOG_NOTICE};
462/// log_init_with_format(LOG_NOTICE, None, LogFormat::Json).expect("install logger");
463/// ```
464pub fn log_init_with_format(level: u8, path: Option<&Path>, format: LogFormat) -> Status {
465    install_logs_only(&LogConfig {
466        verbosity: level,
467        output: path.map(PathBuf::from),
468        format,
469    })
470}
471
472/// Reopen the log file at the path remembered by [`log_init`].
473///
474/// Intended to be invoked from the SIGHUP handler. When the active sink
475/// is standard error (no path was set), this is a no-op and returns
476/// [`Ok`]. When the file cannot be reopened, the previous sink is left
477/// in place and the error is returned.
478///
479/// # Examples
480///
481/// ```no_run
482/// use dynomite::core::log::reopen_on_sighup;
483/// reopen_on_sighup().expect("reopen log");
484/// ```
485pub fn reopen_on_sighup() -> Status {
486    let state = STATE
487        .get()
488        .ok_or_else(|| DynError::generic("reopen_on_sighup: log not initialised"))?;
489    let path_guard = state.path.lock();
490    let Some(path) = path_guard.as_ref() else {
491        return Ok(());
492    };
493    let new_file = open_log_file(path).map_err(DynError::Io)?;
494    *state.sink.lock() = Box::new(new_file);
495    Ok(())
496}
497
498/// Number of write errors observed by the underlying sink.
499///
500/// # Examples
501///
502/// ```
503/// use dynomite::core::log::write_error_count;
504/// // Before logging is initialised the count is zero.
505/// let _: u64 = write_error_count();
506/// ```
507pub fn write_error_count() -> u64 {
508    STATE.get().map_or(0, |s| *s.nerror.lock())
509}
510
511/// Return the current numeric verbosity stored by [`log_init`].
512///
513/// # Examples
514///
515/// ```
516/// use dynomite::core::log::{current_level, log_level_set, LOG_INFO};
517/// log_level_set(LOG_INFO);
518/// assert_eq!(current_level(), LOG_INFO);
519/// ```
520pub fn current_level() -> u8 {
521    CURRENT_LEVEL.load(Ordering::Relaxed)
522}
523
524/// Bump the stored verbosity by one, saturating at [`LOG_LEVEL_MAX`].
525///
526/// The actual `tracing` filter is set once at [`log_init`] time; this
527/// updates a numeric counter that downstream subsystems read to gate
528/// periodic-verbose output.
529///
530/// # Examples
531///
532/// ```
533/// use dynomite::core::log::{log_level_increment, log_level_set};
534/// log_level_set(3);
535/// assert_eq!(log_level_increment(), 4);
536/// ```
537pub fn log_level_increment() -> u8 {
538    let prev = CURRENT_LEVEL.load(Ordering::Relaxed);
539    let next = clamp_level(prev.saturating_add(1));
540    CURRENT_LEVEL.store(next, Ordering::Relaxed);
541    next
542}
543
544/// Drop the stored verbosity by one, saturating at zero.
545///
546/// # Examples
547///
548/// ```
549/// use dynomite::core::log::{log_level_decrement, log_level_set};
550/// log_level_set(0);
551/// assert_eq!(log_level_decrement(), 0);
552/// log_level_set(5);
553/// assert_eq!(log_level_decrement(), 4);
554/// ```
555pub fn log_level_decrement() -> u8 {
556    let prev = CURRENT_LEVEL.load(Ordering::Relaxed);
557    let next = prev.saturating_sub(1);
558    CURRENT_LEVEL.store(next, Ordering::Relaxed);
559    next
560}
561
562/// Set the stored verbosity directly, clamped to `[0, LOG_LEVEL_MAX]`.
563///
564/// # Examples
565///
566/// ```
567/// use dynomite::core::log::{current_level, log_level_set, LOG_LEVEL_MAX};
568/// log_level_set(255);
569/// assert_eq!(current_level(), LOG_LEVEL_MAX);
570/// ```
571pub fn log_level_set(level: u8) {
572    CURRENT_LEVEL.store(clamp_level(level), Ordering::Relaxed);
573}
574
575/// Return whether a given numeric level is loud enough to be logged.
576///
577/// A message at `level` is loggable iff `level <= current_level()`.
578///
579/// # Examples
580///
581/// ```
582/// use dynomite::core::log::{log_level_set, log_loggable};
583/// log_level_set(5);
584/// assert!(log_loggable(0));
585/// assert!(log_loggable(5));
586/// assert!(!log_loggable(6));
587/// ```
588pub fn log_loggable(level: u8) -> bool {
589    level <= current_level()
590}
591
592#[cfg(test)]
593mod tests {
594    use super::*;
595
596    #[test]
597    fn level_mapping_is_monotone_in_verbosity() {
598        // Map both directions through a monotonic integer scale to be
599        // robust against the orientation of `tracing::Level`'s `Ord`
600        // impl, which has flipped between releases.
601        let severity = |l: Level| -> u8 {
602            match l {
603                Level::ERROR => 0,
604                Level::WARN => 1,
605                Level::INFO => 2,
606                Level::DEBUG => 3,
607                Level::TRACE => 4,
608            }
609        };
610        let mut prev = severity(tracing_level_for(0));
611        for lvl in 1..=LOG_LEVEL_MAX {
612            let cur = severity(tracing_level_for(lvl));
613            assert!(cur >= prev, "level {lvl}: severity {cur} not >= {prev}");
614            prev = cur;
615        }
616    }
617
618    #[test]
619    fn clamp_saturates() {
620        assert_eq!(clamp_level(0), 0);
621        assert_eq!(clamp_level(LOG_LEVEL_MAX), LOG_LEVEL_MAX);
622        assert_eq!(clamp_level(LOG_LEVEL_MAX + 5), LOG_LEVEL_MAX);
623        assert_eq!(clamp_level(255), LOG_LEVEL_MAX);
624    }
625
626    #[test]
627    fn level_constants_match_c() {
628        assert_eq!(LOG_EMERG, 0);
629        assert_eq!(LOG_ALERT, 1);
630        assert_eq!(LOG_CRIT, 2);
631        assert_eq!(LOG_ERR, 3);
632        assert_eq!(LOG_WARN, 4);
633        assert_eq!(LOG_NOTICE, 5);
634        assert_eq!(LOG_INFO, 6);
635        assert_eq!(LOG_DEBUG, 7);
636        assert_eq!(LOG_VERB, 8);
637        assert_eq!(LOG_VVERB, 9);
638        assert_eq!(LOG_VVVERB, 10);
639        assert_eq!(LOG_PVERB, 11);
640    }
641
642    #[test]
643    fn level_increment_and_decrement_saturate() {
644        log_level_set(0);
645        assert_eq!(log_level_decrement(), 0);
646        for _ in 0..(u32::from(LOG_LEVEL_MAX) + 5) {
647            log_level_increment();
648        }
649        assert_eq!(current_level(), LOG_LEVEL_MAX);
650        log_level_set(5);
651        assert!(log_loggable(0));
652        assert!(log_loggable(5));
653        assert!(!log_loggable(6));
654    }
655
656    #[test]
657    fn build_logs_layer_writer_state_swaps_on_reopen() {
658        // The fmt layer returned by `build_logs_layer` writes
659        // through the shared STATE.sink. Renaming the configured
660        // file out from under the writer and then calling
661        // `reopen_on_sighup` must rebind STATE.sink to a freshly
662        // re-created file at the original path; events emitted
663        // before the rotate land in the renamed file, events
664        // emitted after the reopen land in the new file.
665        //
666        // STATE is a `OnceLock`, so this test is the only test
667        // in the module that initialises it. nextest runs each
668        // `#[test]` in its own process; when the suite is run
669        // under plain `cargo test` this test is the canonical
670        // owner of STATE.
671        use std::fs;
672
673        let dir = tempfile::tempdir().expect("tempdir");
674        let log_path = dir.path().join("dyn.log");
675        let cfg = LogConfig::new(LOG_NOTICE, Some(log_path.clone()), LogFormat::Default);
676        let (fmt_layer, _reopen) = build_logs_layer(&cfg).expect("build layer");
677
678        let env = build_env_filter(LOG_NOTICE);
679        let sub = tracing_subscriber::registry().with(fmt_layer).with(env);
680
681        tracing::subscriber::with_default(sub, || {
682            tracing::info!(target: "dynomite::test", "first-line-marker");
683            // tracing's fmt layer writes synchronously inside
684            // `on_event`; no flush needed.
685            let rotated = dir.path().join("dyn.log.1");
686            fs::rename(&log_path, &rotated).expect("rotate file");
687            reopen_on_sighup().expect("reopen");
688            tracing::info!(target: "dynomite::test", "second-line-marker");
689        });
690
691        let rotated_contents =
692            fs::read_to_string(dir.path().join("dyn.log.1")).expect("read rotated");
693        let new_contents = fs::read_to_string(&log_path).expect("read new");
694
695        assert!(
696            rotated_contents.contains("first-line-marker"),
697            "rotated file missing first marker: {rotated_contents:?}",
698        );
699        assert!(
700            !rotated_contents.contains("second-line-marker"),
701            "rotated file unexpectedly contained second marker: {rotated_contents:?}",
702        );
703        assert!(
704            new_contents.contains("second-line-marker"),
705            "new file missing second marker: {new_contents:?}",
706        );
707        assert!(
708            !new_contents.contains("first-line-marker"),
709            "new file unexpectedly contained first marker: {new_contents:?}",
710        );
711    }
712}
713
714#[cfg(test)]
715mod format_tests {
716    //! Per-format unit tests.
717    //!
718    //! These tests cannot install the global subscriber - that
719    //! is process-wide and other tests already use it - so each
720    //! test scopes a `tracing_subscriber` to a closure via
721    //! `tracing::subscriber::with_default` and captures the bytes
722    //! the subscriber writes to a shared `Vec<u8>`. The captured
723    //! buffer is then asserted against the format's documented
724    //! shape (regex for syslog, line-per-event JSON for NDJSON,
725    //! field-name presence for the default text format).
726
727    use std::io::{self, Write};
728    use std::sync::{Arc, Mutex};
729
730    use regex::Regex;
731    use tracing_subscriber::fmt::MakeWriter;
732
733    use super::syslog::{Rfc3164Formatter, Rfc5424Formatter};
734
735    /// Cloneable byte sink used as the writer behind a scoped
736    /// subscriber. Each `make_writer()` call hands back a
737    /// `Buffer` that pushes into the shared `Vec<u8>`.
738    #[derive(Clone, Default)]
739    struct CaptureBuffer(Arc<Mutex<Vec<u8>>>);
740
741    impl CaptureBuffer {
742        fn snapshot(&self) -> Vec<u8> {
743            self.0.lock().expect("lock CaptureBuffer").clone()
744        }
745        fn snapshot_string(&self) -> String {
746            String::from_utf8(self.snapshot()).expect("captured bytes are utf-8")
747        }
748    }
749
750    impl Write for CaptureBuffer {
751        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
752            let mut guard = self.0.lock().expect("lock CaptureBuffer");
753            guard.extend_from_slice(buf);
754            Ok(buf.len())
755        }
756        fn flush(&mut self) -> io::Result<()> {
757            Ok(())
758        }
759    }
760
761    impl<'a> MakeWriter<'a> for CaptureBuffer {
762        type Writer = CaptureBuffer;
763        fn make_writer(&'a self) -> Self::Writer {
764            self.clone()
765        }
766    }
767
768    /// Build the four subscriber shapes against the given capture
769    /// buffer. The functions are factored out so the assertions are
770    /// next to the regex and not next to the subscriber wiring.
771    fn run_default(buf: &CaptureBuffer) {
772        let sub = tracing_subscriber::fmt()
773            .with_writer(buf.clone())
774            .with_target(true)
775            .with_ansi(false)
776            .finish();
777        tracing::subscriber::with_default(sub, || {
778            tracing::info!(answer = 42, name = "ada", "hello");
779        });
780    }
781
782    fn run_rfc5424(buf: &CaptureBuffer) {
783        use tracing_subscriber::layer::SubscriberExt as _;
784        let layer = tracing_subscriber::fmt::Layer::new()
785            .with_writer(buf.clone())
786            .event_format(Rfc5424Formatter::new())
787            .with_ansi(false);
788        let sub = tracing_subscriber::registry().with(layer);
789        tracing::subscriber::with_default(sub, || {
790            tracing::info!(answer = 42, "hello");
791        });
792    }
793
794    fn run_rfc3164(buf: &CaptureBuffer) {
795        use tracing_subscriber::layer::SubscriberExt as _;
796        let layer = tracing_subscriber::fmt::Layer::new()
797            .with_writer(buf.clone())
798            .event_format(Rfc3164Formatter::new())
799            .with_ansi(false);
800        let sub = tracing_subscriber::registry().with(layer);
801        tracing::subscriber::with_default(sub, || {
802            tracing::info!(answer = 42, "hello");
803        });
804    }
805
806    fn run_json(buf: &CaptureBuffer) {
807        let sub = tracing_subscriber::fmt()
808            .with_writer(buf.clone())
809            .json()
810            .with_target(true)
811            .flatten_event(false)
812            .with_current_span(true)
813            .with_span_list(false)
814            .finish();
815        tracing::subscriber::with_default(sub, || {
816            tracing::info!(answer = 42, name = "ada", "first");
817            tracing::warn!(retry = true, "second");
818        });
819    }
820
821    #[test]
822    fn default_format_unchanged_from_baseline() {
823        let buf = CaptureBuffer::default();
824        run_default(&buf);
825        let text = buf.snapshot_string();
826        // The historical text format stamps the level, the target,
827        // the message and the field key/value pairs as
828        // `key=value`. Anything weaker would fail to detect a
829        // regression where a future refactor accidentally swaps
830        // formatters.
831        assert!(text.contains(" INFO "), "missing INFO level: {text:?}");
832        assert!(text.contains("hello"), "missing message text: {text:?}");
833        assert!(
834            text.contains("answer=42"),
835            "missing kv 'answer=42': {text:?}"
836        );
837        assert!(text.contains("name=\"ada\""), "missing kv 'name': {text:?}");
838        // Sanity: line ends with a trailing newline.
839        assert!(text.ends_with('\n'), "missing trailing newline");
840    }
841
842    #[test]
843    fn rfc5424_format_starts_with_pri_version() {
844        let buf = CaptureBuffer::default();
845        run_rfc5424(&buf);
846        let text = buf.snapshot_string();
847        // The brief specifies the regex
848        // `^<\d+>1 [\d-]+T[\d:.+-]+ \S+ dynomited \d+ - `
849        let re =
850            Regex::new(r"^<\d+>1 [\d-]+T[\d:.+\-]+ \S+ dynomited \d+ - ").expect("compile regex");
851        let first_line = text.lines().next().expect("at least one line");
852        assert!(
853            re.is_match(first_line),
854            "RFC 5424 line did not match regex: {first_line:?}"
855        );
856        assert!(
857            first_line.contains("origin@32473"),
858            "missing structured-data ID: {first_line:?}"
859        );
860        assert!(
861            first_line.contains("hello"),
862            "missing message: {first_line:?}"
863        );
864    }
865
866    #[test]
867    fn rfc3164_format_starts_with_pri_then_timestamp() {
868        let buf = CaptureBuffer::default();
869        run_rfc3164(&buf);
870        let text = buf.snapshot_string();
871        // The brief specifies the regex
872        // `^<\d+>[A-Z][a-z]{2} [\d ]\d \d{2}:\d{2}:\d{2} \S+ \S+: `
873        let re = Regex::new(r"^<\d+>[A-Z][a-z]{2} [\d ]\d \d{2}:\d{2}:\d{2} \S+ \S+: ")
874            .expect("compile regex");
875        let first_line = text.lines().next().expect("at least one line");
876        assert!(
877            re.is_match(first_line),
878            "RFC 3164 line did not match regex: {first_line:?}"
879        );
880        assert!(
881            first_line.contains("hello"),
882            "missing message: {first_line:?}"
883        );
884    }
885
886    #[test]
887    fn ndjson_format_is_one_json_per_line() {
888        let buf = CaptureBuffer::default();
889        run_json(&buf);
890        let text = buf.snapshot_string();
891        let lines: Vec<_> = text.lines().filter(|l| !l.is_empty()).collect();
892        assert!(
893            lines.len() >= 2,
894            "expected at least two JSON lines: {text:?}"
895        );
896        for line in &lines {
897            // Each line must be a self-contained JSON object.
898            let v: serde_json::Value = serde_json::from_str(line)
899                .unwrap_or_else(|e| panic!("line is not valid JSON ({e}): {line:?}"));
900            // Required keys, per the brief: timestamp, level,
901            // target, fields. The `tracing-subscriber` JSON
902            // formatter always emits `timestamp`, `level`,
903            // `target`, and a `fields` object.
904            for key in ["timestamp", "level", "target", "fields"] {
905                assert!(
906                    v.get(key).is_some(),
907                    "JSON line missing key {key:?}: {line}"
908                );
909            }
910            // Inner-newline check: a valid NDJSON line must not
911            // contain a literal '\n' character.
912            assert!(!line.contains('\n'));
913        }
914    }
915}