browser_automation_cli/
telemetry.rs1use 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
35pub const MAX_LOG_FILES: usize = 14;
37
38pub const LOG_FILE_PREFIX: &str = "browser-automation-cli";
40
41#[derive(Debug, Default)]
45pub struct TelemetryGuard {
46 _file_worker: Option<WorkerGuard>,
48}
49
50impl TelemetryGuard {
51 #[must_use]
53 pub fn none() -> Self {
54 Self {
55 _file_worker: None,
56 }
57 }
58}
59
60#[derive(Debug, Clone, Copy)]
62pub struct TelemetryOpts {
63 pub quiet: bool,
65 pub verbose: bool,
67 pub debug: bool,
69 pub plain: bool,
71}
72
73#[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#[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 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 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 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 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 drop(file_guard);
204 TelemetryGuard::none()
205 }
206 }
207}
208
209fn 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
223fn 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
240fn 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 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}