Skip to main content

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}