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}