Skip to main content

rusty_stack/
logging.rs

1//! Logging infrastructure for Rusty Stack.
2//!
3//! Provides a dual-layer logging setup:
4//! - **File layer**: Structured JSON logs at `~/.mlstack/logs/` with daily rotation
5//!   (max 3 files retained for storage efficiency).
6//! - **Stdout layer**: Compact human-readable progress for CLI updates.
7//!
8//! # Usage
9//!
10//! ```ignore
11//! use rusty_stack::logging::init_logging;
12//!
13//! let _guard = init_logging("update");
14//! // tracing::info!, tracing::error!, etc. now work
15//! ```
16
17use std::path::PathBuf;
18use tracing_subscriber::{
19    filter::EnvFilter,
20    fmt::{self, time::LocalTime},
21    layer::SubscriberExt,
22    util::SubscriberInitExt,
23    Layer,
24};
25
26/// Log directory under `~/.mlstack/logs/` (canonical, sudo-aware path).
27pub fn log_dir() -> PathBuf {
28    crate::platform::environment::mlstack_logs_dir()
29}
30
31/// Initialize the dual-layer logging system.
32///
33/// Returns a guard that must be kept alive for the file logger to flush on drop.
34/// The `context` string is embedded in log entries to identify the operation
35/// (e.g., "update", "verify", "bench").
36///
37/// # File format
38///
39/// JSON lines with fields: `timestamp`, `level`, `span`, `message`, plus any
40/// structured fields from spans.
41///
42/// # Storage efficiency
43///
44/// Daily rotation with max 3 log files retained. Older files are pruned automatically
45/// by `tracing-appender`.
46pub fn init_logging(context: &str) -> Option<tracing_appender::non_blocking::WorkerGuard> {
47    let log_path = log_dir();
48    if let Err(e) = std::fs::create_dir_all(&log_path) {
49        eprintln!(
50            "[WARN] Could not create log directory {}: {}",
51            log_path.display(),
52            e
53        );
54        // Fall back to stdout-only
55        let filter = EnvFilter::try_from_default_env()
56            .unwrap_or_else(|_| EnvFilter::new("rusty_stack=info,warn"));
57        let stdout_layer = fmt::layer().with_target(false).with_filter(filter);
58        tracing_subscriber::registry().with(stdout_layer).init();
59        return None;
60    }
61
62    // File appender: daily rotation, max 3 files, prefix with context
63    let file_appender =
64        tracing_appender::rolling::daily(&log_path, format!("{}-rusty-stack", context));
65    let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
66
67    // JSON file layer — structured for machine parsing
68    let file_layer = fmt::layer()
69        .json()
70        .with_timer(LocalTime::rfc_3339())
71        .with_writer(non_blocking)
72        .with_target(true)
73        .with_span_events(fmt::format::FmtSpan::CLOSE)
74        .with_filter(
75            EnvFilter::try_from_default_env()
76                .unwrap_or_else(|_| EnvFilter::new("rusty_stack=debug,info")),
77        );
78
79    // Stdout layer — compact human-readable for CLI progress
80    let stdout_layer = fmt::layer()
81        .with_target(false)
82        .with_timer(fmt::time::LocalTime::new(
83            time::format_description::well_known::iso8601::Iso8601::DEFAULT,
84        ))
85        .with_filter(
86            EnvFilter::try_from_default_env()
87                .unwrap_or_else(|_| EnvFilter::new("rusty_stack=info,warn")),
88        );
89
90    tracing_subscriber::registry()
91        .with(file_layer)
92        .with(stdout_layer)
93        .init();
94
95    tracing::info!(
96        context = context,
97        log_dir = %log_path.display(),
98        "Logging initialized"
99    );
100
101    Some(guard)
102}
103
104/// Initialize logging for non-interactive/batch contexts.
105///
106/// Uses a simpler configuration: file logging + stderr (not stdout) so that
107/// machine-readable stdout output (JSON) is not polluted with log lines.
108pub fn init_batch_logging(context: &str) -> Option<tracing_appender::non_blocking::WorkerGuard> {
109    let log_path = log_dir();
110    if let Err(e) = std::fs::create_dir_all(&log_path) {
111        eprintln!(
112            "[WARN] Could not create log directory {}: {}",
113            log_path.display(),
114            e
115        );
116        return None;
117    }
118
119    let file_appender =
120        tracing_appender::rolling::daily(&log_path, format!("{}-rusty-stack", context));
121    let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
122
123    // File layer — full debug detail
124    let file_layer = fmt::layer()
125        .json()
126        .with_timer(LocalTime::rfc_3339())
127        .with_writer(non_blocking)
128        .with_target(true)
129        .with_span_events(fmt::format::FmtSpan::CLOSE)
130        .with_filter(
131            EnvFilter::try_from_default_env()
132                .unwrap_or_else(|_| EnvFilter::new("rusty_stack=debug")),
133        );
134
135    // Stderr layer — warnings and errors only (not stdout)
136    let stderr_layer = fmt::layer()
137        .with_target(false)
138        .with_writer(std::io::stderr)
139        .with_filter(
140            EnvFilter::try_from_default_env()
141                .unwrap_or_else(|_| EnvFilter::new("rusty_stack=warn")),
142        );
143
144    tracing_subscriber::registry()
145        .with(file_layer)
146        .with(stderr_layer)
147        .init();
148
149    tracing::info!(
150        context = context,
151        log_dir = %log_path.display(),
152        "Batch logging initialized"
153    );
154
155    Some(guard)
156}
157
158/// Get the path to the most recent log file for a given context.
159///
160/// Returns the log directory path if it exists, for user-facing messages.
161pub fn log_file_path(context: &str) -> PathBuf {
162    log_dir().join(format!("{}-rusty-stack", context))
163}
164
165/// Time format module for tracing-subscriber.
166mod time {
167    pub use time::format_description;
168}
169
170// ===========================================================================
171// Tests
172// ===========================================================================
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn test_log_dir_is_under_mlstack() {
180        let dir = log_dir();
181        assert!(dir.to_string_lossy().contains(".mlstack"));
182        assert!(dir.to_string_lossy().contains("logs"));
183    }
184
185    #[test]
186    fn test_log_file_path_contains_context() {
187        let path = log_file_path("update");
188        assert!(path.to_string_lossy().contains("update-rusty-stack"));
189    }
190}