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}