|
| 1 | +// 2026 (c) Copyright Contributors to the GOSH DAO. All rights reserved. |
| 2 | +// |
| 3 | + |
| 4 | +//! Shared tracing setup for the dodex services. |
| 5 | +//! |
| 6 | +//! Every service logs to stdout, filtered by `RUST_LOG` (default `info`). |
| 7 | +//! When the `LOG_DIR` environment variable is set and non-empty, each service |
| 8 | +//! ALSO writes human-readable, daily-rotated files named `<service>.log.<date>` |
| 9 | +//! into that directory, keeping at most `LOG_MAX_FILES` (default 14) of them. |
| 10 | +//! |
| 11 | +//! Lives in its own crate — free of the heavy `dodex-infrastructure` |
| 12 | +//! dependency graph — so the standalone `market-manager` workspace can reuse |
| 13 | +//! it by path, exactly like `dodex-chain`. |
| 14 | +
|
| 15 | +use std::env; |
| 16 | + |
| 17 | +use tracing_appender::non_blocking::WorkerGuard; |
| 18 | +use tracing_appender::rolling::RollingFileAppender; |
| 19 | +use tracing_appender::rolling::Rotation; |
| 20 | +use tracing_subscriber::fmt; |
| 21 | +use tracing_subscriber::layer::SubscriberExt; |
| 22 | +use tracing_subscriber::util::SubscriberInitExt; |
| 23 | +use tracing_subscriber::EnvFilter; |
| 24 | + |
| 25 | +/// Daily log files retained before the oldest is pruned, when `LOG_MAX_FILES` |
| 26 | +/// is unset or unparseable. |
| 27 | +const DEFAULT_MAX_FILES: usize = 14; |
| 28 | + |
| 29 | +fn env_filter() -> EnvFilter { |
| 30 | + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")) |
| 31 | +} |
| 32 | + |
| 33 | +fn max_files() -> usize { |
| 34 | + env::var("LOG_MAX_FILES").ok().and_then(|v| v.parse().ok()).unwrap_or(DEFAULT_MAX_FILES) |
| 35 | +} |
| 36 | + |
| 37 | +/// Build the daily rolling-file appender for `service` under `dir`, creating |
| 38 | +/// the directory if needed. Errors out (rather than panicking) so the caller |
| 39 | +/// can fall back to stdout-only. |
| 40 | +fn file_appender(dir: &str, service: &str) -> anyhow::Result<RollingFileAppender> { |
| 41 | + std::fs::create_dir_all(dir)?; |
| 42 | + let appender = RollingFileAppender::builder() |
| 43 | + .rotation(Rotation::DAILY) |
| 44 | + .filename_prefix(format!("{service}.log")) |
| 45 | + .max_log_files(max_files()) |
| 46 | + .build(dir)?; |
| 47 | + Ok(appender) |
| 48 | +} |
| 49 | + |
| 50 | +/// Install the global tracing subscriber for `service`. |
| 51 | +/// |
| 52 | +/// Always logs to stdout. If `LOG_DIR` is set and non-empty, also writes |
| 53 | +/// daily-rotated `<service>.log.<date>` files there. On a log-dir error, warns |
| 54 | +/// loudly to the (already-installed) stdout logger and continues stdout-only. |
| 55 | +/// |
| 56 | +/// Returns the appender guard(s). The caller MUST keep them alive for the |
| 57 | +/// lifetime of the process (`let _guards = dodex_logging::init("api");`) — drop |
| 58 | +/// them and the background file writer stops flushing. |
| 59 | +#[must_use] |
| 60 | +pub fn init(service: &str) -> Vec<WorkerGuard> { |
| 61 | + let stdout_layer = fmt::layer().with_writer(std::io::stdout); |
| 62 | + |
| 63 | + let log_dir = env::var("LOG_DIR").unwrap_or_default(); |
| 64 | + if log_dir.is_empty() { |
| 65 | + tracing_subscriber::registry().with(env_filter()).with(stdout_layer).init(); |
| 66 | + return Vec::new(); |
| 67 | + } |
| 68 | + |
| 69 | + match file_appender(&log_dir, service) { |
| 70 | + Ok(appender) => { |
| 71 | + let (writer, guard) = tracing_appender::non_blocking(appender); |
| 72 | + let file_layer = fmt::layer().with_ansi(false).with_writer(writer); |
| 73 | + tracing_subscriber::registry() |
| 74 | + .with(env_filter()) |
| 75 | + .with(stdout_layer) |
| 76 | + .with(file_layer) |
| 77 | + .init(); |
| 78 | + vec![guard] |
| 79 | + } |
| 80 | + Err(err) => { |
| 81 | + tracing_subscriber::registry().with(env_filter()).with(stdout_layer).init(); |
| 82 | + tracing::warn!( |
| 83 | + log_dir = %log_dir, |
| 84 | + error = %err, |
| 85 | + "LOG_DIR set but file logging could not be initialised; continuing with stdout only" |
| 86 | + ); |
| 87 | + Vec::new() |
| 88 | + } |
| 89 | + } |
| 90 | +} |
| 91 | + |
| 92 | +#[cfg(test)] |
| 93 | +mod tests { |
| 94 | + use super::*; |
| 95 | + |
| 96 | + #[test] |
| 97 | + fn writes_a_rotated_file_when_dir_is_set() { |
| 98 | + let dir = tempfile::tempdir().expect("tempdir"); |
| 99 | + let path = dir.path().to_str().expect("utf-8 path"); |
| 100 | + |
| 101 | + let appender = file_appender(path, "testsvc").expect("appender builds"); |
| 102 | + let (writer, guard) = tracing_appender::non_blocking(appender); |
| 103 | + let subscriber = |
| 104 | + tracing_subscriber::registry().with(fmt::layer().with_ansi(false).with_writer(writer)); |
| 105 | + |
| 106 | + tracing::subscriber::with_default(subscriber, || { |
| 107 | + tracing::info!("hello-from-test"); |
| 108 | + }); |
| 109 | + |
| 110 | + // Dropping the guard flushes and joins the background writer thread. |
| 111 | + drop(guard); |
| 112 | + |
| 113 | + let log_files: Vec<_> = std::fs::read_dir(dir.path()) |
| 114 | + .expect("read tempdir") |
| 115 | + .filter_map(Result::ok) |
| 116 | + .filter(|e| e.file_name().to_string_lossy().starts_with("testsvc.log")) |
| 117 | + .collect(); |
| 118 | + assert_eq!(log_files.len(), 1, "exactly one rotated file is created"); |
| 119 | + |
| 120 | + let contents = std::fs::read_to_string(log_files[0].path()).expect("read log file"); |
| 121 | + assert!(contents.contains("hello-from-test"), "log line written, got: {contents}"); |
| 122 | + } |
| 123 | +} |
0 commit comments