Skip to main content

rust_ai_core/
logging.rs

1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tyler Zervas
3
4//! Unified logging and observability for the rust-ai ecosystem.
5//!
6//! ## Why This Module Exists
7//!
8//! ML training and inference generate massive amounts of diagnostic data:
9//! - Training progress (loss, learning rate, throughput)
10//! - Memory usage and allocation patterns
11//! - Device information and kernel timings
12//! - Errors and warnings
13//!
14//! Without consistent logging configuration, each crate would implement its own
15//! approach, leading to inconsistent output formats and configuration mechanisms.
16//!
17//! This module provides:
18//!
19//! 1. **Unified log initialization**: Single function to configure logging for all crates
20//! 2. **Structured logging helpers**: Consistent field names for metrics and events
21//! 3. **Progress tracking**: Training loop progress bars and ETA estimation
22//!
23//! ## Design Decisions
24//!
25//! - **tracing-based**: Uses the `tracing` ecosystem for structured logging with spans
26//! - **Environment-driven**: Log levels configured via `RUST_LOG` environment variable
27//! - **Zero-cost when disabled**: All logging compiles to no-ops when level is filtered
28
29use std::sync::Once;
30
31/// Configuration for logging initialization.
32///
33/// ## Why This Struct
34///
35/// Logging configuration often needs to vary between development and production:
36/// - Development: verbose, colored output to terminal
37/// - Production: JSON structured logs to file/stdout
38/// - Testing: minimal output, captured by test harness
39///
40/// This struct captures these variations.
41#[derive(Debug, Clone)]
42pub struct LogConfig {
43    /// Default log level when `RUST_LOG` is not set.
44    pub default_level: LogLevel,
45    /// Include timestamps in log output.
46    pub with_timestamps: bool,
47    /// Include target (module path) in log output.
48    pub with_target: bool,
49    /// Include source file and line numbers.
50    pub with_file_line: bool,
51    /// Use ANSI colors (disable for file output).
52    pub with_ansi: bool,
53}
54
55impl Default for LogConfig {
56    fn default() -> Self {
57        Self {
58            default_level: LogLevel::Info,
59            with_timestamps: true,
60            with_target: true,
61            with_file_line: false,
62            with_ansi: true,
63        }
64    }
65}
66
67impl LogConfig {
68    /// Create a new logging configuration with defaults.
69    #[must_use]
70    pub fn new() -> Self {
71        Self::default()
72    }
73
74    /// Set the default log level.
75    #[must_use]
76    pub fn with_level(mut self, level: LogLevel) -> Self {
77        self.default_level = level;
78        self
79    }
80
81    /// Enable or disable timestamps.
82    #[must_use]
83    pub fn with_timestamps(mut self, enable: bool) -> Self {
84        self.with_timestamps = enable;
85        self
86    }
87
88    /// Enable or disable ANSI colors.
89    #[must_use]
90    pub fn with_ansi(mut self, enable: bool) -> Self {
91        self.with_ansi = enable;
92        self
93    }
94
95    /// Configuration preset for development.
96    ///
97    /// Verbose output with colors, file/line info for debugging.
98    #[must_use]
99    pub fn development() -> Self {
100        Self {
101            default_level: LogLevel::Debug,
102            with_timestamps: true,
103            with_target: true,
104            with_file_line: true,
105            with_ansi: true,
106        }
107    }
108
109    /// Configuration preset for production.
110    ///
111    /// Clean output without colors (for structured log ingestion).
112    #[must_use]
113    pub fn production() -> Self {
114        Self {
115            default_level: LogLevel::Info,
116            with_timestamps: true,
117            with_target: false,
118            with_file_line: false,
119            with_ansi: false,
120        }
121    }
122
123    /// Configuration preset for testing.
124    ///
125    /// Minimal output, captured by test harness.
126    #[must_use]
127    pub fn testing() -> Self {
128        Self {
129            default_level: LogLevel::Warn,
130            with_timestamps: false,
131            with_target: false,
132            with_file_line: false,
133            with_ansi: false,
134        }
135    }
136}
137
138/// Log level enumeration.
139///
140/// Maps to tracing levels.
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
142pub enum LogLevel {
143    /// Errors only.
144    Error,
145    /// Warnings and above.
146    Warn,
147    /// Informational messages and above.
148    #[default]
149    Info,
150    /// Debug messages and above.
151    Debug,
152    /// All messages including trace.
153    Trace,
154}
155
156impl LogLevel {
157    /// Convert to a tracing filter string.
158    fn as_filter_str(&self) -> &'static str {
159        match self {
160            Self::Error => "error",
161            Self::Warn => "warn",
162            Self::Info => "info",
163            Self::Debug => "debug",
164            Self::Trace => "trace",
165        }
166    }
167}
168
169/// Guard ensuring logging is only initialized once.
170static INIT_LOGGING: Once = Once::new();
171
172/// Initialize logging for the rust-ai ecosystem.
173///
174/// This function should be called once at application startup. It configures
175/// the global tracing subscriber based on the provided configuration.
176///
177/// ## Arguments
178///
179/// * `config` - Logging configuration
180///
181/// ## Why Single Initialization
182///
183/// The tracing subscriber is global. Multiple initialization attempts would
184/// either panic or silently fail. This function uses `Once` to ensure safe
185/// idempotent calls.
186///
187/// ## Environment Override
188///
189/// The `RUST_LOG` environment variable always takes precedence over the
190/// config's default level. This allows runtime tuning without recompilation.
191///
192/// ## Example
193///
194/// ```rust
195/// use rust_ai_core::{init_logging, LogConfig};
196///
197/// // Initialize with defaults
198/// init_logging(&LogConfig::default());
199///
200/// // Or with explicit config
201/// init_logging(&LogConfig::development());
202/// ```
203pub fn init_logging(config: &LogConfig) {
204    INIT_LOGGING.call_once(|| {
205        // Use RUST_LOG if set, otherwise fall back to config default
206        let filter = std::env::var("RUST_LOG")
207            .unwrap_or_else(|_| config.default_level.as_filter_str().to_string());
208
209        // Build the subscriber
210        let builder = tracing_subscriber::fmt()
211            .with_env_filter(filter)
212            .with_ansi(config.with_ansi)
213            .with_target(config.with_target)
214            .with_file(config.with_file_line)
215            .with_line_number(config.with_file_line);
216
217        // Apply timestamp configuration
218        if config.with_timestamps {
219            builder.init();
220        } else {
221            builder.without_time().init();
222        }
223    });
224}
225
226/// Macro to log a training metric with consistent field names.
227///
228/// ## Why This Macro
229///
230/// Training metrics need consistent field names for downstream processing
231/// (`TensorBoard`, Weights & Biases, etc.). This macro ensures all crates
232/// use the same schema.
233///
234/// ## Example
235///
236/// ```rust,ignore
237/// log_metric!(
238///     step = 1000,
239///     loss = 2.5,
240///     lr = 1e-4,
241///     throughput_tokens_sec = 50000.0
242/// );
243/// ```
244#[macro_export]
245macro_rules! log_metric {
246    ($($field:ident = $value:expr),+ $(,)?) => {
247        tracing::info!(
248            target: "rust_ai::metrics",
249            $($field = $value),+
250        );
251    };
252}
253
254/// Log a training step with standard fields.
255///
256/// ## Arguments
257///
258/// * `step` - Current training step
259/// * `total_steps` - Total steps for progress calculation
260/// * `loss` - Current loss value
261/// * `lr` - Current learning rate
262///
263/// ## Why This Function
264///
265/// Every training loop logs steps. Having a dedicated function ensures
266/// consistent formatting and field names across all crates.
267pub fn log_training_step(step: usize, total_steps: usize, loss: f64, lr: f64) {
268    let progress_pct = if total_steps > 0 {
269        (step as f64 / total_steps as f64) * 100.0
270    } else {
271        0.0
272    };
273
274    tracing::info!(
275        target: "rust_ai::training",
276        step,
277        total_steps,
278        progress_pct = format!("{progress_pct:.1}"),
279        loss = format!("{loss:.6}"),
280        lr = format!("{lr:.2e}"),
281        "Training step"
282    );
283}
284
285/// Log memory usage.
286///
287/// ## Arguments
288///
289/// * `allocated_bytes` - Currently allocated bytes
290/// * `peak_bytes` - Peak allocation
291/// * `context` - Description of what operation is being tracked
292pub fn log_memory_usage(allocated_bytes: usize, peak_bytes: usize, context: &str) {
293    let allocated_mb = allocated_bytes as f64 / (1024.0 * 1024.0);
294    let peak_mb = peak_bytes as f64 / (1024.0 * 1024.0);
295
296    tracing::debug!(
297        target: "rust_ai::memory",
298        allocated_mb = format!("{allocated_mb:.2}"),
299        peak_mb = format!("{peak_mb:.2}"),
300        context,
301        "Memory usage"
302    );
303}
304
305// Re-export tracing macros for convenience so crates don't need to depend on tracing directly
306pub use tracing::{debug, error, info, trace, warn};
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    #[test]
313    fn test_log_config_default() {
314        let config = LogConfig::default();
315        assert!(matches!(config.default_level, LogLevel::Info));
316        assert!(config.with_timestamps);
317        assert!(config.with_ansi);
318    }
319
320    #[test]
321    fn test_log_config_builder() {
322        let config = LogConfig::new()
323            .with_level(LogLevel::Debug)
324            .with_timestamps(false)
325            .with_ansi(false);
326
327        assert!(matches!(config.default_level, LogLevel::Debug));
328        assert!(!config.with_timestamps);
329        assert!(!config.with_ansi);
330    }
331
332    #[test]
333    fn test_log_config_presets() {
334        let dev = LogConfig::development();
335        assert!(matches!(dev.default_level, LogLevel::Debug));
336        assert!(dev.with_file_line);
337
338        let prod = LogConfig::production();
339        assert!(matches!(prod.default_level, LogLevel::Info));
340        assert!(!prod.with_ansi);
341
342        let test = LogConfig::testing();
343        assert!(matches!(test.default_level, LogLevel::Warn));
344        assert!(!test.with_timestamps);
345    }
346
347    #[test]
348    fn test_log_level_filter_str() {
349        assert_eq!(LogLevel::Error.as_filter_str(), "error");
350        assert_eq!(LogLevel::Warn.as_filter_str(), "warn");
351        assert_eq!(LogLevel::Info.as_filter_str(), "info");
352        assert_eq!(LogLevel::Debug.as_filter_str(), "debug");
353        assert_eq!(LogLevel::Trace.as_filter_str(), "trace");
354    }
355}