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}