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