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