Skip to main content

bamboo_infrastructure/
logging.rs

1//! Centralized logging/tracing initialization.
2//!
3//! This lives in the infrastructure layer (next to `config::paths`) so every
4//! consumer shares one logging policy: the standalone `bamboo serve` binary, the
5//! CLI/TUI, and embedded hosts such as the Bodhi Tauri app. The policy fixes the
6//! problems the old per-app setups had:
7//!
8//! - **Logs survive restarts.** Output goes to a date-stamped file under
9//!   `{home}/logs` that is appended to, not truncated, so a restart on the same
10//!   day continues the same file and earlier days are left intact.
11//! - **Rotation is by date, not size.** Files roll once per day (`Rotation::DAILY`),
12//!   so a single run's logs are never split mid-stream on a byte threshold.
13//! - **Old files are purged.** At most [`DEFAULT_MAX_LOG_FILES`] dated files are
14//!   kept; the appender deletes the oldest beyond that on rollover.
15//! - **Server logs are quiet by default.** `bamboo serve` defaults to `info` in
16//!   every build profile. Explicit `RUST_LOG` directives still override that
17//!   policy, while embedding APIs may opt into a build-profile-derived level.
18//!
19//! All initializers are best-effort and idempotent: they use `try_init`, so a
20//! second call (or a call after some other subscriber is installed) is a no-op
21//! rather than a panic. Because the global subscriber is a process-wide side
22//! effect, call these once from a binary's entry point — not from library code.
23//!
24//! `tracing-subscriber`'s default `tracing-log` feature installs a `log` →
25//! `tracing` bridge as part of `try_init`, so existing `log::info!`-style calls
26//! (which Bodhi uses heavily) are captured without any code changes.
27
28use std::path::{Path, PathBuf};
29
30use tracing_appender::rolling::{RollingFileAppender, Rotation};
31use tracing_subscriber::{fmt, prelude::*, EnvFilter};
32
33/// Number of dated log files to retain before the oldest are purged on rollover.
34/// With daily rotation this is roughly two weeks of history.
35pub const DEFAULT_MAX_LOG_FILES: usize = 14;
36
37/// Tuning knobs for [`init_logging_with_options`].
38#[derive(Debug, Clone)]
39pub struct LogOptions {
40    /// Directory the log files are written to (created if missing).
41    pub dir: PathBuf,
42    /// Filename prefix; the date and a `.log` suffix are appended by the appender
43    /// (e.g. `bamboo.2026-05-31.log`). Lets co-located apps keep separate files.
44    pub file_name_prefix: String,
45    /// Maximum number of dated files to keep; older ones are deleted on rollover.
46    pub max_files: usize,
47    /// Level filter used when `RUST_LOG` is not set (e.g. `"info"` or `"debug"`).
48    pub default_level: String,
49}
50
51impl LogOptions {
52    /// Options writing to `dir` with the shared defaults (`bamboo` prefix,
53    /// [`DEFAULT_MAX_LOG_FILES`] retention, `info` level).
54    pub fn new(dir: impl Into<PathBuf>) -> Self {
55        Self {
56            dir: dir.into(),
57            file_name_prefix: "bamboo".to_string(),
58            max_files: DEFAULT_MAX_LOG_FILES,
59            default_level: "info".to_string(),
60        }
61    }
62}
63
64/// Initialize file + stdout logging for a process rooted at `home`.
65///
66/// Logs are written under `{home}/logs`. Pass `debug = true` (typically
67/// `cfg!(debug_assertions)`) to default to the `debug` level; otherwise `info`.
68/// This is the entry point both the `bamboo` binary and the Bodhi app call.
69pub fn init_logging_with_home(home: &Path, debug: bool) {
70    init_logging_with_options(options_for_home(home, debug));
71}
72
73/// Initialize file + stdout logging for the standalone `bamboo serve` process.
74///
75/// Server operation defaults to `info` in every build profile. A debug binary is
76/// an implementation detail of the development toolchain, not an operator
77/// request for dependency-wide debug logs. Callers can still opt in explicitly
78/// through `RUST_LOG`, `--log-level`, or `-v` before this initializer runs.
79pub fn init_server_logging_with_home(home: &Path) {
80    init_logging_with_options(options_for_server_home(home, cfg!(debug_assertions)));
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84enum LogContext {
85    Server,
86    BuildProfile,
87}
88
89/// Build the [`LogOptions`] used by [`init_logging_with_home`]: logs under
90/// `{home}/logs`, level by build profile, shared defaults otherwise.
91///
92/// Split out from the initializer so the path/level composition can be unit
93/// tested without installing a process-global subscriber.
94fn options_for_home(home: &Path, debug: bool) -> LogOptions {
95    let mut opts = LogOptions::new(home.join("logs"));
96    opts.default_level = level_for(LogContext::BuildProfile, debug).to_string();
97    opts
98}
99
100/// Build server options without installing a process-global subscriber.
101/// Keeping the build-profile input explicit makes the invariant directly
102/// testable: both debug and release servers must resolve to `info`.
103fn options_for_server_home(home: &Path, debug_build: bool) -> LogOptions {
104    let mut opts = LogOptions::new(home.join("logs"));
105    opts.default_level = level_for(LogContext::Server, debug_build).to_string();
106    opts
107}
108
109/// Create the log directory and a daily-rotating file appender for `opts`.
110///
111/// Separated from [`init_logging_with_options`] so the file-side behavior
112/// (directory creation, filename shape, rotation/retention config) is testable
113/// without touching the global subscriber, which can only be set once per process.
114fn build_appender(
115    opts: &LogOptions,
116) -> Result<RollingFileAppender, tracing_appender::rolling::InitError> {
117    // Best-effort: a missing directory shouldn't abort startup. If creation
118    // fails we still try the appender (and the caller falls back to stdout).
119    if let Err(e) = std::fs::create_dir_all(&opts.dir) {
120        eprintln!(
121            "warning: could not create log directory {}: {e}",
122            opts.dir.display()
123        );
124    }
125
126    RollingFileAppender::builder()
127        .rotation(Rotation::DAILY)
128        .filename_prefix(&opts.file_name_prefix)
129        .filename_suffix("log")
130        .max_log_files(opts.max_files)
131        .build(&opts.dir)
132}
133
134/// Initialize file + stdout logging from explicit [`LogOptions`].
135pub fn init_logging_with_options(opts: LogOptions) {
136    // EnvFilter is not `Clone`, so build a fresh one wherever it's needed.
137    let default_level = opts.default_level.clone();
138    let make_filter = move || {
139        EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_level.clone()))
140    };
141
142    match build_appender(&opts) {
143        Ok(file_writer) => {
144            // `RollingFileAppender` implements `MakeWriter`, so it drives the file
145            // layer directly — no background worker, hence no guard to keep alive.
146            let stdout_layer = fmt::layer().with_target(true);
147            let file_layer = fmt::layer()
148                .with_target(true)
149                .with_ansi(false)
150                .with_writer(file_writer);
151            let _ = tracing_subscriber::registry()
152                .with(make_filter())
153                .with(stdout_layer)
154                .with(file_layer)
155                .try_init();
156        }
157        Err(e) => {
158            eprintln!("warning: file logging disabled ({e}); using stdout only");
159            let _ = fmt()
160                .with_target(true)
161                .with_env_filter(make_filter())
162                .try_init();
163        }
164    }
165}
166
167/// Initialize stdout-only logging.
168///
169/// For contexts without a stable data directory (e.g. the `bamboo config`
170/// subcommand). Prefer [`init_logging_with_home`] when a `{home}/logs` dir exists.
171pub fn init_logging(debug: bool) {
172    let _ = fmt()
173        .with_target(true)
174        .with_env_filter(
175            EnvFilter::try_from_default_env()
176                .unwrap_or_else(|_| EnvFilter::new(level_for(LogContext::BuildProfile, debug))),
177        )
178        .try_init();
179}
180
181/// Default level string for a logging context when `RUST_LOG` is unset.
182fn level_for(context: LogContext, debug_build: bool) -> &'static str {
183    match (context, debug_build) {
184        (LogContext::Server, _) | (LogContext::BuildProfile, false) => "info",
185        (LogContext::BuildProfile, true) => "debug",
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use std::io::Write;
193    use tempfile::tempdir;
194    use tracing_subscriber::fmt::MakeWriter;
195
196    #[test]
197    fn level_for_maps_build_profile_for_embedding_apis() {
198        assert_eq!(level_for(LogContext::BuildProfile, true), "debug");
199        assert_eq!(level_for(LogContext::BuildProfile, false), "info");
200    }
201
202    #[test]
203    fn server_level_is_info_in_debug_and_release_builds() {
204        assert_eq!(level_for(LogContext::Server, true), "info");
205        assert_eq!(level_for(LogContext::Server, false), "info");
206    }
207
208    #[test]
209    fn log_options_new_uses_shared_defaults() {
210        let opts = LogOptions::new("/tmp/example");
211        assert_eq!(opts.dir, PathBuf::from("/tmp/example"));
212        assert_eq!(opts.file_name_prefix, "bamboo");
213        assert_eq!(opts.max_files, DEFAULT_MAX_LOG_FILES);
214        assert_eq!(opts.default_level, "info");
215    }
216
217    #[test]
218    fn options_for_home_places_logs_under_home_and_sets_level() {
219        let debug = options_for_home(Path::new("/srv/data"), true);
220        assert_eq!(debug.dir, PathBuf::from("/srv/data/logs"));
221        assert_eq!(debug.default_level, "debug");
222
223        let release = options_for_home(Path::new("/srv/data"), false);
224        assert_eq!(release.default_level, "info");
225    }
226
227    #[test]
228    fn server_options_are_info_in_debug_and_release_builds() {
229        for debug_build in [true, false] {
230            let opts = options_for_server_home(Path::new("/srv/data"), debug_build);
231            assert_eq!(opts.dir, PathBuf::from("/srv/data/logs"));
232            assert_eq!(opts.default_level, "info");
233        }
234    }
235
236    #[test]
237    fn build_appender_creates_dir_and_writes_dated_file() {
238        let tmp = tempdir().expect("tempdir");
239        // Nested path that does not exist yet, to prove directories are created.
240        let dir = tmp.path().join("nested").join("logs");
241        let opts = LogOptions {
242            dir: dir.clone(),
243            file_name_prefix: "unit-test".to_string(),
244            max_files: 5,
245            default_level: "info".to_string(),
246        };
247
248        let appender = build_appender(&opts).expect("appender builds");
249        assert!(dir.exists(), "log directory should be created");
250
251        // Write through the appender the same way the fmt layer does.
252        {
253            let mut writer = appender.make_writer();
254            writeln!(writer, "hello-from-test").expect("write line");
255            writer.flush().expect("flush");
256        }
257        drop(appender); // ensure the file handle is released before reading
258
259        let entries: Vec<_> = std::fs::read_dir(&dir)
260            .expect("read log dir")
261            .filter_map(Result::ok)
262            .map(|e| e.file_name().to_string_lossy().into_owned())
263            .collect();
264
265        assert_eq!(entries.len(), 1, "exactly one log file, got {entries:?}");
266        let name = &entries[0];
267        assert!(
268            name.starts_with("unit-test.") && name.ends_with(".log"),
269            "filename should be `<prefix>.<date>.log`, got {name}"
270        );
271
272        let contents =
273            std::fs::read_to_string(dir.join(name)).expect("read back log file contents");
274        assert!(
275            contents.contains("hello-from-test"),
276            "log file should contain the written line, got: {contents:?}"
277        );
278    }
279
280    #[test]
281    fn init_logging_with_options_creates_dir_and_is_idempotent() {
282        // Exercises the real entry point. The global subscriber can only be set
283        // once per test binary, so we assert only on the deterministic side
284        // effect (directory creation) and that a repeat call does not panic.
285        let tmp = tempdir().expect("tempdir");
286        let dir = tmp.path().join("logs");
287        let opts = LogOptions {
288            dir: dir.clone(),
289            file_name_prefix: "idem".to_string(),
290            max_files: 2,
291            default_level: "info".to_string(),
292        };
293
294        init_logging_with_options(opts.clone());
295        init_logging_with_options(opts); // must be a no-op, not a panic
296
297        assert!(dir.exists());
298    }
299}