bob/logging.rs
1/*
2 * Copyright (c) 2026 Jonathan Perkin <jonathan@perkin.org.uk>
3 *
4 * Permission to use, copy, modify, and distribute this software for any
5 * purpose with or without fee is hereby granted, provided that the above
6 * copyright notice and this permission notice appear in all copies.
7 *
8 * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
9 * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
10 * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
11 * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
12 * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
13 * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
14 * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
15 */
16
17/*
18 * Logging infrastructure, outputs bunyan-style JSON to a logs directory.
19 */
20
21use anyhow::{Context, Result};
22use std::fs;
23use std::path::PathBuf;
24use std::sync::OnceLock;
25use tracing_appender::non_blocking::WorkerGuard;
26use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt};
27
28static LOG_GUARD: OnceLock<WorkerGuard> = OnceLock::new();
29
30/**
31 * Initialize stderr logging if RUST_LOG is set.
32 *
33 * For utility commands that don't need file logging but should support
34 * debug output when explicitly requested.
35 */
36pub fn init_stderr_if_enabled() {
37 if std::env::var("RUST_LOG").is_err() {
38 return;
39 }
40
41 let filter = EnvFilter::from_default_env();
42
43 let stderr_layer = fmt::layer()
44 .with_writer(std::io::stderr)
45 .with_target(false)
46 .without_time();
47
48 let _ = tracing_subscriber::registry()
49 .with(filter)
50 .with(stderr_layer)
51 .try_init();
52}
53
54/**
55 * Initialize the logging system.
56 *
57 * Creates a logs directory and writes JSON-formatted logs there.
58 */
59pub fn init(logs_dir: &PathBuf, log_level: &str) -> Result<()> {
60 // Create logs directory
61 fs::create_dir_all(logs_dir)
62 .with_context(|| format!("Failed to create logs directory {:?}", logs_dir))?;
63
64 // Create a rolling file appender that writes to logs/bob.log
65 let file_appender = tracing_appender::rolling::never(logs_dir, "bob.log");
66 let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
67
68 // Store the guard to keep the writer alive
69 LOG_GUARD
70 .set(guard)
71 .map_err(|_| anyhow::anyhow!("Logging already initialized"))?;
72
73 // Build the subscriber with JSON formatting for files
74 let file_layer = fmt::layer()
75 .json()
76 .with_writer(non_blocking)
77 .with_target(true)
78 .with_thread_ids(false)
79 .with_thread_names(false)
80 .with_file(false)
81 .with_line_number(false)
82 .with_span_list(false);
83
84 // Set up env filter - allow RUST_LOG to override
85 let default_filter = format!("bob={}", log_level);
86 let filter =
87 EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&default_filter));
88
89 tracing_subscriber::registry()
90 .with(filter)
91 .with(file_layer)
92 .init();
93
94 tracing::info!(logs_dir = %logs_dir.display(),
95 log_level = log_level,
96 "Logging initialized"
97 );
98
99 Ok(())
100}