Skip to main content

browser_automation_cli/
telemetry.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Local-only tracing init for the one-shot CLI (rules_rust_logs_com_tracing_e_rotacao).
3//!
4//! # Product law
5//!
6//! - **No remote telemetry**: no OpenTelemetry, OTLP, Sentry, or log shipping.
7//! - **stderr by default**: agent pipelines keep stdout for JSON envelopes.
8//! - **Optional XDG file**: `config set log_to_file true` writes rotated JSON under
9//!   `$XDG_STATE_HOME/browser-automation-cli/log/` (never cloud).
10//! - **No `RUST_LOG` product path**: filter comes from argv (`-q`/`-v`/`--debug`) or
11//!   XDG `log_level` (default `error`).
12//! - **Daemon-only rules N/A**: `reload::Layer` admin HTTP, OTEL sampling, journald,
13//!   Lambda flush, encrypted-at-rest pipelines — not applicable to BORN→DIE.
14//!
15//! # Targets emitted
16//!
17//! Prefer `browser_automation_cli::<module>` (crate name with underscores). The
18//! telemetry module itself logs `browser_automation_cli::telemetry` on successful init.
19//!
20//! # Lifecycle
21//!
22//! [`init_telemetry`] installs the global subscriber **once** (from [`crate::run`]) and
23//! returns a [`TelemetryGuard`]. When file logging is enabled, the guard owns a
24//! `tracing_appender` [`WorkerGuard`] so buffered lines flush on drop at process end.
25//! Hold the guard until FINALIZE completes — do not `mem::forget` it.
26
27use std::io::{self, IsTerminal};
28use std::path::PathBuf;
29
30use tracing_appender::non_blocking::WorkerGuard;
31use tracing_subscriber::layer::SubscriberExt;
32use tracing_subscriber::util::SubscriberInitExt;
33use tracing_subscriber::{fmt, EnvFilter};
34
35/// Maximum retained daily log files under XDG state (retention ≈ 2 weeks).
36pub const MAX_LOG_FILES: usize = 14;
37
38/// Filename prefix for rotated logs (`browser-automation-cli.YYYY-MM-DD`).
39pub const LOG_FILE_PREFIX: &str = "browser-automation-cli";
40
41/// Process-scoped telemetry handle. Drop flushes the optional non-blocking file worker.
42///
43/// Named field (not bare `_`) so the guard is never discarded by accident.
44#[derive(Debug, Default)]
45pub struct TelemetryGuard {
46    /// When `Some`, keeps the appender worker alive and flushes on drop.
47    _file_worker: Option<WorkerGuard>,
48}
49
50impl TelemetryGuard {
51    /// Empty guard (stderr-only path, or subscriber already installed).
52    #[must_use]
53    pub fn none() -> Self {
54        Self {
55            _file_worker: None,
56        }
57    }
58}
59
60/// Inputs for local tracing (mirrors CLI globals + XDG).
61#[derive(Debug, Clone, Copy)]
62pub struct TelemetryOpts {
63    /// `--quiet` / `-q` → force `error` only.
64    pub quiet: bool,
65    /// `--verbose` / `-v` → `info`.
66    pub verbose: bool,
67    /// `--debug` → `debug`.
68    pub debug: bool,
69    /// `--plain` / NO_COLOR / agent plain: disable ANSI on stderr.
70    pub plain: bool,
71}
72
73/// Resolve the EnvFilter directive string (testable pure function).
74///
75/// Priority: quiet > debug > verbose > non-empty XDG `log_level` > `error`.
76#[must_use]
77pub fn resolve_filter_directive(
78    quiet: bool,
79    verbose: bool,
80    debug: bool,
81    xdg_level: Option<&str>,
82) -> String {
83    if quiet {
84        return "error".to_string();
85    }
86    if debug {
87        return "debug".to_string();
88    }
89    if verbose {
90        return "info".to_string();
91    }
92    if let Some(level) = xdg_level.map(str::trim).filter(|s| !s.is_empty()) {
93        return level.to_string();
94    }
95    "error".to_string()
96}
97
98/// Install the global tracing subscriber (once) and return a process-lifetime guard.
99///
100/// Safe to call when a subscriber is already installed (tests / re-entry): returns
101/// [`TelemetryGuard::none`] without replacing the existing subscriber.
102///
103/// # Panic hook
104///
105/// After a successful install, chains a hook that emits a `tracing` `error` event
106/// (target `panic`) then calls the previous hook (e.g. `human_panic` from `main`).
107#[must_use]
108pub fn init_telemetry(opts: TelemetryOpts) -> TelemetryGuard {
109    let xdg_level = crate::xdg::load_config()
110        .ok()
111        .and_then(|c| c.log_level)
112        .filter(|s| !s.trim().is_empty());
113    let directive = resolve_filter_directive(
114        opts.quiet,
115        opts.verbose,
116        opts.debug,
117        xdg_level.as_deref(),
118    );
119    let log_to_file = crate::xdg::load_config()
120        .ok()
121        .and_then(|c| c.log_to_file)
122        .unwrap_or(false);
123
124    let use_ansi = !opts.plain && crate::color::is_enabled() && io::stderr().is_terminal();
125
126    // Invalid XDG directive must not abort the CLI; fall back to safe default.
127    let (filter, effective) = match EnvFilter::try_new(&directive) {
128        Ok(f) => (f, directive),
129        Err(_) => (EnvFilter::new("error"), "error".to_string()),
130    };
131
132    let error_layer = tracing_error::ErrorLayer::default();
133
134    let stderr_layer = fmt::layer()
135        .with_writer(io::stderr)
136        .with_ansi(use_ansi)
137        .with_target(true)
138        .with_thread_names(false)
139        .with_level(true);
140
141    let mut file_guard: Option<WorkerGuard> = None;
142    let mut file_path: Option<PathBuf> = None;
143
144    let init_result = if log_to_file {
145        match build_file_appender() {
146            Ok((appender, path)) => {
147                file_path = Some(path);
148                let (non_blocking, guard) = tracing_appender::non_blocking(appender);
149                file_guard = Some(guard);
150                // File: structured JSON, no ANSI, non-blocking writer (rules: production file).
151                let file_layer = fmt::layer()
152                    .json()
153                    .with_writer(non_blocking)
154                    .with_ansi(false)
155                    .with_target(true)
156                    .with_thread_names(false)
157                    .with_current_span(true)
158                    .with_span_list(false);
159                // Dual sink: stderr (human/agent diagnostics) + rotated JSON file.
160                tracing_subscriber::registry()
161                    .with(filter)
162                    .with(error_layer)
163                    .with(stderr_layer)
164                    .with(file_layer)
165                    .try_init()
166            }
167            Err(_) => {
168                // Fallback: stderr only if XDG state / open failed.
169                file_guard = None;
170                tracing_subscriber::registry()
171                    .with(filter)
172                    .with(error_layer)
173                    .with(stderr_layer)
174                    .try_init()
175            }
176        }
177    } else {
178        tracing_subscriber::registry()
179            .with(filter)
180            .with(error_layer)
181            .with(stderr_layer)
182            .try_init()
183    };
184
185    match init_result {
186        Ok(()) => {
187            install_panic_tracing_bridge();
188            tracing::info!(
189                target: "browser_automation_cli::telemetry",
190                filter = %effective,
191                log_to_file,
192                file = ?file_path.as_ref().map(|p| p.display().to_string()),
193                ansi = use_ansi,
194                "tracing initialized (local only; no remote export)"
195            );
196            TelemetryGuard {
197                _file_worker: file_guard,
198            }
199        }
200        Err(_) => {
201            // Subscriber already set (integration tests calling run more than once, etc.).
202            // Do not drop a new WorkerGuard onto a dead writer path: discard file worker.
203            drop(file_guard);
204            TelemetryGuard::none()
205        }
206    }
207}
208
209/// Build daily rolling appender with retention cap under XDG state.
210fn build_file_appender() -> io::Result<(tracing_appender::rolling::RollingFileAppender, PathBuf)> {
211    let state = crate::xdg::state_dir().map_err(|e| io::Error::other(e.to_string()))?;
212    let log_dir = state.join("log");
213    create_log_dir(&log_dir)?;
214    let appender = tracing_appender::rolling::RollingFileAppender::builder()
215        .rotation(tracing_appender::rolling::Rotation::DAILY)
216        .filename_prefix(LOG_FILE_PREFIX)
217        .max_log_files(MAX_LOG_FILES)
218        .build(&log_dir)
219        .map_err(|e| io::Error::other(e.to_string()))?;
220    Ok((appender, log_dir))
221}
222
223/// Create log directory with restricted mode on Unix (owner-only).
224fn create_log_dir(log_dir: &std::path::Path) -> io::Result<()> {
225    #[cfg(unix)]
226    {
227        use std::os::unix::fs::DirBuilderExt;
228        std::fs::DirBuilder::new()
229            .recursive(true)
230            .mode(0o700)
231            .create(log_dir)?;
232    }
233    #[cfg(not(unix))]
234    {
235        std::fs::create_dir_all(log_dir)?;
236    }
237    Ok(())
238}
239
240/// After the subscriber is live: log panics as structured events, then chain prior hook.
241fn install_panic_tracing_bridge() {
242    let previous = std::panic::take_hook();
243    std::panic::set_hook(Box::new(move |info| {
244        let location = info
245            .location()
246            .map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
247            .unwrap_or_else(|| "unknown".to_string());
248        let message = if let Some(s) = info.payload().downcast_ref::<&str>() {
249            (*s).to_string()
250        } else if let Some(s) = info.payload().downcast_ref::<String>() {
251            s.clone()
252        } else {
253            "Box<dyn Any>".to_string()
254        };
255        // Best-effort: may not reach disk if the panic is inside the writer thread.
256        tracing::error!(
257            target: "panic",
258            %location,
259            %message,
260            "process panic"
261        );
262        previous(info);
263    }));
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn filter_priority_quiet_wins() {
272        assert_eq!(
273            resolve_filter_directive(true, true, true, Some("trace")),
274            "error"
275        );
276    }
277
278    #[test]
279    fn filter_debug_before_verbose() {
280        assert_eq!(
281            resolve_filter_directive(false, true, true, None),
282            "debug"
283        );
284    }
285
286    #[test]
287    fn filter_verbose_info() {
288        assert_eq!(
289            resolve_filter_directive(false, true, false, None),
290            "info"
291        );
292    }
293
294    #[test]
295    fn filter_xdg_when_no_flags() {
296        assert_eq!(
297            resolve_filter_directive(false, false, false, Some("warn")),
298            "warn"
299        );
300    }
301
302    #[test]
303    fn filter_default_error() {
304        assert_eq!(
305            resolve_filter_directive(false, false, false, Some("  ")),
306            "error"
307        );
308        assert_eq!(
309            resolve_filter_directive(false, false, false, None),
310            "error"
311        );
312    }
313
314    #[test]
315    fn max_log_files_is_positive_retention() {
316        assert!(MAX_LOG_FILES >= 7);
317        assert!(MAX_LOG_FILES <= 90);
318    }
319}