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