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: `./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}