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}