Skip to main content

media_pp\core/
log.rs

1//! Opt-in file logging owned exclusively by `media-pp`.
2//!
3//! [`crate::pp_log`]'s macros write directly to a private non-blocking file
4//! writer. They never install or emit through the process-global `log` or
5//! `tracing` facilities, so an embedding application's logs cannot enter these
6//! files and `media-pp` records cannot enter the application's logger.
7
8use std::{
9    fmt::{self, Write as _},
10    fs,
11    io::Write as _,
12    path::PathBuf,
13    sync::{
14        Arc, Mutex, OnceLock,
15        atomic::{AtomicBool, AtomicU64, Ordering},
16    },
17    thread,
18};
19
20use arc_swap::ArcSwapOption;
21use thiserror::Error as ThisError;
22use time::OffsetDateTime;
23use tracing_appender::{
24    non_blocking::{ErrorCounter, NonBlocking, NonBlockingBuilder, WorkerGuard},
25    rolling::{InitError, RollingFileAppender, Rotation},
26};
27
28use crate::pp_log::PpLog;
29
30const BUFFERED_LINES_LIMIT: usize = 4096;
31
32static LOGGER: OnceLock<PrivateLogger> = OnceLock::new();
33static INIT_LOCK: Mutex<()> = Mutex::new(());
34static NEXT_THREAD_NUMBER: AtomicU64 = AtomicU64::new(1);
35
36thread_local! {
37    /// Built once per thread, on that thread's first record. Formatting it
38    /// per record would mean a `thread::current()` handle clone and a
39    /// `String` build on every line, and the value never changes.
40    static THREAD_TAG: String = thread_tag();
41}
42
43/// Numbers threads in the order they first log, in the `name#number` shape
44/// the topology diagram already uses for elements. A thread name alone does
45/// not identify a thread — a pipeline with two sources has two threads both
46/// named `pipeline:source` — and `ThreadId`'s own value is not readable on
47/// stable Rust, so the number is assigned here.
48fn thread_tag() -> String {
49    let number = NEXT_THREAD_NUMBER.fetch_add(1, Ordering::Relaxed);
50    match thread::current().name() {
51        Some(name) => format!("{name}#{number}"),
52        None => format!("#{number}"),
53    }
54}
55
56/// Severity threshold for the private `media-pp` file logger.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
58pub enum Level {
59    Error,
60    Warn,
61    Info,
62    Debug,
63    Trace,
64}
65
66impl fmt::Display for Level {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        f.write_str(match self {
69            Self::Error => "ERROR",
70            Self::Warn => "WARN",
71            Self::Info => "INFO",
72            Self::Debug => "DEBUG",
73            Self::Trace => "TRACE",
74        })
75    }
76}
77
78#[derive(Debug, ThisError)]
79pub enum LogInitError {
80    #[error("the media-pp file logger has already been initialized")]
81    AlreadyInitialized,
82
83    #[error("failed to create log directory `{path}`: {source}")]
84    LogDirectory {
85        path: PathBuf,
86        source: std::io::Error,
87    },
88
89    #[error("failed to create log file appender: {0}")]
90    FileAppender(#[from] InitError),
91}
92
93/// Owns the private logging worker installed by [`init`].
94///
95/// Keep this value alive for as long as logging should remain active.
96///
97/// Dropping it rejects log calls that begin afterwards. A record already being
98/// emitted concurrently may complete or be discarded — the guard does not join
99/// the worker thread, the same trade the lossy writer already makes on a full
100/// queue.
101///
102/// The final flush is attempted, not promised. The drop asks [`WorkerGuard`] to
103/// shut the worker down, which enqueues a shutdown message on the same bounded
104/// channel the records use — waiting at most 100ms — and then waits at most one
105/// second for the worker's acknowledgement, sent only after it has flushed
106/// everything already queued. So under normal conditions queued records do reach
107/// the file, but a file writer stalled long enough to keep that channel full
108/// makes the drop give up and return with records still queued. On that path
109/// `tracing-appender` also prints one line to the process's stdout, which this
110/// crate cannot suppress. The worker still terminates and flushes on its own
111/// afterwards (see this type's `Drop`), just with nothing waiting for it.
112///
113/// It is deliberately not stored in a static because Rust does not drop static
114/// values at process exit, which would make even that attempt impossible.
115pub struct LogGuard {
116    active: Arc<AtomicBool>,
117    error_counter: ErrorCounter,
118    worker: Option<WorkerGuard>,
119}
120
121impl LogGuard {
122    /// Number of complete log records discarded because the bounded writer
123    /// queue was full.
124    pub fn dropped_lines(&self) -> usize {
125        self.error_counter.dropped_lines()
126    }
127}
128
129impl Drop for LogGuard {
130    fn drop(&mut self) {
131        self.active.store(false, Ordering::Release);
132        // Release this process-wide writer *before* running the worker
133        // guard. That guard gets 100ms to enqueue its shutdown message on
134        // the same bounded channel the records use; a stalled file writer
135        // can make that time out. While the static still held a sender the
136        // worker could then never observe a disconnect either, and would
137        // outlive this guard for the rest of the process. Dropping ours
138        // first leaves the worker guard's own sender as the last one, so
139        // that path terminates the worker through disconnect instead —
140        // after it drains and flushes what is already queued.
141        if let Some(logger) = LOGGER.get() {
142            logger.writer.store(None);
143        }
144        self.worker.take();
145    }
146}
147
148struct PrivateLogger {
149    level: Level,
150    active: Arc<AtomicBool>,
151    /// Cleared by [`LogGuard::drop`], which is the only thing that makes the
152    /// worker's channel reach zero senders — this value lives in a `static`
153    /// that Rust never drops. See that `Drop` impl for why the worker's
154    /// termination depends on it.
155    writer: ArcSwapOption<NonBlocking>,
156}
157
158/// Starts the private `media-pp` file logger.
159///
160/// Records are appended to `{log_prefix}.{date}.log` in `log_path`. Files
161/// rotate daily and only the newest `max_log_files` are retained. The bounded
162/// writer is lossy by design: if disk output falls behind a burst of records,
163/// the producing media thread drops that record instead of blocking. Use
164/// [`LogGuard::dropped_lines`] to inspect the count.
165///
166/// This does not install a global `log` logger or `tracing` subscriber. It can
167/// coexist with any logger installed by the embedding application.
168///
169/// The returned [`LogGuard`] must be retained for as long as logging is needed.
170/// Dropping it permanently stops this one-shot logger and makes a bounded
171/// attempt to flush what is still queued; see [`LogGuard`] for what that does
172/// and does not promise. Initialization is per process, not per guard: a second
173/// call returns
174/// [`LogInitError::AlreadyInitialized`] whether or not the first guard is still
175/// alive, so an application cannot re-enable logging or move it to a different
176/// directory afterwards. Integration tests that need this logger therefore need
177/// one test binary each, since `cargo test` runs a binary's tests in one
178/// process.
179pub fn init(
180    log_prefix: &str,
181    log_path: &str,
182    level: Level,
183    max_log_files: usize,
184) -> Result<LogGuard, LogInitError> {
185    let _init_guard = INIT_LOCK
186        .lock()
187        .unwrap_or_else(std::sync::PoisonError::into_inner);
188
189    if LOGGER.get().is_some() {
190        return Err(LogInitError::AlreadyInitialized);
191    }
192
193    fs::create_dir_all(log_path).map_err(|source| LogInitError::LogDirectory {
194        path: log_path.into(),
195        source,
196    })?;
197
198    let file_appender = RollingFileAppender::builder()
199        .filename_prefix(log_prefix)
200        .filename_suffix("log")
201        .rotation(Rotation::DAILY)
202        .max_log_files(max_log_files)
203        .build(log_path)?;
204
205    let (writer, worker) = NonBlockingBuilder::default()
206        .buffered_lines_limit(BUFFERED_LINES_LIMIT)
207        .lossy(true)
208        .thread_name("media-pp-log")
209        .finish(file_appender);
210    let error_counter = writer.error_counter();
211    let active = Arc::new(AtomicBool::new(true));
212
213    let logger = PrivateLogger {
214        level,
215        active: active.clone(),
216        writer: ArcSwapOption::from_pointee(writer),
217    };
218    if LOGGER.set(logger).is_err() {
219        return Err(LogInitError::AlreadyInitialized);
220    }
221
222    Ok(LogGuard {
223        active,
224        error_counter,
225        worker: Some(worker),
226    })
227}
228
229#[doc(hidden)]
230#[inline]
231pub fn enabled(level: Level) -> bool {
232    LOGGER
233        .get()
234        .is_some_and(|logger| logger.active.load(Ordering::Acquire) && level <= logger.level)
235}
236
237#[doc(hidden)]
238pub fn emit(level: Level, pp_log: &PpLog, args: fmt::Arguments<'_>) {
239    let Some(logger) = LOGGER.get() else {
240        return;
241    };
242    if !logger.active.load(Ordering::Acquire) || level > logger.level {
243        return;
244    }
245    let Some(writer) = logger.writer.load_full() else {
246        return;
247    };
248
249    // Build one complete line before calling `NonBlocking::write_all`.
250    // `NonBlocking` treats each write as an independent queued message, so
251    // writing the prefix and message separately could interleave fragments
252    // emitted concurrently by different media threads.
253    let timestamp = OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc());
254    let mut line = String::with_capacity(256);
255    write_timestamp(&mut line, timestamp);
256    let _ = write!(line, " {level}");
257    // Ahead of the element identity, not between it and the message: a
258    // reader grepping for one element's records should get its message on
259    // the same match, and the thread is a property of the record's origin
260    // like the timestamp and level, not part of who the element is.
261    // `try_with` because a record emitted from a `Drop` running during
262    // thread teardown would find this thread-local already destroyed; the
263    // field still gets written so every record has the same shape.
264    let tagged = THREAD_TAG.try_with(|tag| {
265        let _ = write!(line, " [thread={tag}]");
266    });
267    if tagged.is_err() {
268        let _ = line.write_str(" [thread=?]");
269    }
270    if let Some(pipeline_id) = pp_log.pipeline_id() {
271        let _ = write!(line, " [pipeline_id={pipeline_id}]");
272    }
273    let _ = write!(
274        line,
275        " [element={}] [name={}] ",
276        pp_log.element(),
277        pp_log.name()
278    );
279    let _ = line.write_fmt(args);
280    line.push('\n');
281
282    let mut writer = NonBlocking::clone(&writer);
283    let _ = writer.write_all(line.as_bytes());
284}
285
286fn write_timestamp(output: &mut String, timestamp: OffsetDateTime) {
287    let offset_seconds = timestamp.offset().whole_seconds();
288    let offset_sign = if offset_seconds < 0 { '-' } else { '+' };
289    let offset_seconds = offset_seconds.unsigned_abs();
290    let offset_hours = offset_seconds / 3_600;
291    let offset_minutes = (offset_seconds % 3_600) / 60;
292
293    let _ = write!(
294        output,
295        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}{offset_sign}{offset_hours:02}:{offset_minutes:02}",
296        timestamp.year(),
297        u8::from(timestamp.month()),
298        timestamp.day(),
299        timestamp.hour(),
300        timestamp.minute(),
301        timestamp.second(),
302        timestamp.millisecond(),
303    );
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use time::{Date, Month, Time, UtcOffset};
310
311    #[test]
312    fn timestamp_is_iso_8601_with_milliseconds_and_numeric_offset() {
313        let timestamp = Date::from_calendar_date(2026, Month::August, 15)
314            .unwrap()
315            .with_time(Time::from_hms_milli(15, 52, 24, 68).unwrap())
316            .assume_offset(UtcOffset::from_hms(9, 0, 0).unwrap());
317        let mut output = String::new();
318
319        write_timestamp(&mut output, timestamp);
320
321        assert_eq!(output, "2026-08-15T15:52:24.068+09:00");
322    }
323}