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}