gglib_core/telemetry.rs
1//! Unified tracing initialization for gglib.
2//!
3//! Design:
4//! - A single layered subscriber (console + daily rotating file) is installed once via [`OnceLock`].
5//! - Calls to [`init_tracing`] are idempotent — subsequent calls return `Ok(())`.
6//! - Log directory: `./logs/` in debug builds, `data_root()/logs` in release.
7//! - Filter: `RUST_LOG` env var wins; otherwise `"debug"` if verbose, else `"warn"`.
8//! - Console output goes through [`console_println`], which defaults to stderr
9//! but can be redirected via [`set_console_hook`] — see the "Console hook"
10//! section below.
11
12use std::path::PathBuf;
13use std::sync::{Arc, OnceLock, RwLock};
14
15use tracing_subscriber::{EnvFilter, Registry, layer::SubscriberExt, util::SubscriberInitExt};
16
17#[allow(unused_imports)] // only used in release builds via cfg(not(debug_assertions))
18use crate::paths::data_root;
19
20static GUARD: OnceLock<tracing_appender::non_blocking::WorkerGuard> = OnceLock::new();
21
22// ─── Console hook ────────────────────────────────────────────────────────────
23//
24// The stdout `fmt` layer below writes through this hook instead of directly
25// to a stream. Default (`None`) is a plain `eprintln!`. A CLI running an
26// indicatif `MultiProgress` installs a hook that forwards through
27// `MultiProgress::println` instead, so a log line emitted while download bars
28// are live gets erased-printed-redrawn atomically rather than landing as raw
29// bytes that corrupt the bars' redraw bookkeeping. See
30// `gglib-download/src/cli_emitter.rs` for the installing side.
31
32/// A sink for formatted console lines, e.g. one backed by
33/// `MultiProgress::println`.
34pub type ConsoleHook = Arc<dyn Fn(&str) + Send + Sync>;
35
36/// Route for formatted log lines and other CLI console output. `None` means
37/// "print straight to stderr".
38static CONSOLE_HOOK: RwLock<Option<ConsoleHook>> = RwLock::new(None);
39
40/// Install a hook that receives each formatted log line (and other console
41/// output routed via [`console_println`]) instead of stderr.
42pub fn set_console_hook(hook: ConsoleHook) {
43 *CONSOLE_HOOK.write().unwrap() = Some(hook);
44}
45
46/// Remove a previously installed hook, reverting to plain stderr.
47pub fn clear_console_hook() {
48 *CONSOLE_HOOK.write().unwrap() = None;
49}
50
51/// Print one line through the installed console hook, or to stderr if none is
52/// installed.
53///
54/// Used by the tracing `fmt` layer below, and by CLI code that prints
55/// outside of `tracing` (subprocess passthrough, setup notices) so every
56/// console write is subject to the same routing.
57pub fn console_println(line: &str) {
58 let hook = CONSOLE_HOOK.read().unwrap();
59 if let Some(hook) = hook.as_ref() {
60 hook(line);
61 } else {
62 eprintln!("{line}");
63 }
64}
65
66/// `Write` target for the tracing `fmt` layer. Buffers one formatted record
67/// and forwards it as a single line via [`console_println`] when dropped —
68/// `fmt::layer()` creates a fresh writer per event, so `Drop` is exactly
69/// "this record is complete."
70#[derive(Default)]
71struct ConsoleWriter(Vec<u8>);
72
73impl std::io::Write for ConsoleWriter {
74 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
75 self.0.extend_from_slice(buf);
76 Ok(buf.len())
77 }
78
79 fn flush(&mut self) -> std::io::Result<()> {
80 Ok(())
81 }
82}
83
84impl Drop for ConsoleWriter {
85 fn drop(&mut self) {
86 if self.0.is_empty() {
87 return;
88 }
89 let text = String::from_utf8_lossy(&self.0);
90 console_println(text.trim_end_matches('\n'));
91 }
92}
93
94fn resolve_log_dir() -> PathBuf {
95 #[cfg(debug_assertions)]
96 let dir = PathBuf::from("./logs");
97
98 #[cfg(not(debug_assertions))]
99 let dir = data_root()
100 .unwrap_or_else(|_| PathBuf::from("./logs"))
101 .join("logs");
102
103 std::fs::create_dir_all(&dir).ok();
104 dir
105}
106
107fn build_env_filter(verbose: bool) -> EnvFilter {
108 std::env::var("RUST_LOG").map_or_else(
109 |_| {
110 let level = if verbose { "debug" } else { "warn" };
111 EnvFilter::try_new(level).unwrap_or_default()
112 },
113 |log_env| EnvFilter::try_new(log_env).unwrap_or_default(),
114 )
115}
116
117/// Initialize the global tracing subscriber.
118///
119/// Safe to call multiple times; only the first call installs the subscriber.
120pub fn init_tracing(verbose: bool) -> anyhow::Result<()> {
121 // Idempotent: if already initialized, no-op
122 if GUARD.get().is_some() {
123 return Ok(());
124 }
125
126 let log_dir = resolve_log_dir();
127 let file_appender = tracing_appender::rolling::daily(&log_dir, "gglib.log");
128 let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
129
130 let env_filter = build_env_filter(verbose);
131
132 let subscriber = Registry::default()
133 .with(env_filter)
134 .with(
135 tracing_subscriber::fmt::layer()
136 .with_target(true)
137 .with_writer(ConsoleWriter::default),
138 )
139 .with(
140 tracing_subscriber::fmt::layer()
141 .with_writer(non_blocking)
142 .with_target(false),
143 );
144
145 subscriber
146 .try_init()
147 .map_err(|e| anyhow::anyhow!("failed to set global tracer: {e}"))?;
148
149 // Ignore the Result since failure just means another thread set it concurrently
150 let _ = GUARD.set(guard);
151
152 Ok(())
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158 use std::sync::Mutex;
159
160 /// `console_println` must forward to an installed hook rather than
161 /// writing to stderr directly — this is the mechanism that lets a live
162 /// `MultiProgress` intercept log lines and redraw around them instead of
163 /// having them corrupt its bookkeeping. `CONSOLE_HOOK` is the only piece
164 /// of process-global state this module touches and no other test in
165 /// this crate installs a hook, so a single test covering both install
166 /// and teardown is safe without cross-test races.
167 #[test]
168 fn console_println_routes_through_an_installed_hook() {
169 let captured: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
170 let captured_clone = Arc::clone(&captured);
171 set_console_hook(Arc::new(move |line: &str| {
172 captured_clone.lock().unwrap().push(line.to_string());
173 }));
174
175 console_println("hello from the hook");
176 clear_console_hook();
177
178 assert_eq!(captured.lock().unwrap().as_slice(), ["hello from the hook"]);
179
180 // After clearing, console_println must not still reach the old hook.
181 console_println("after clear");
182 assert_eq!(captured.lock().unwrap().len(), 1);
183 }
184}