elara_runtime/observability/logging.rs
1//! Structured logging system for ELARA Protocol
2//!
3//! This module provides structured, queryable logging with support for:
4//! - Multiple log levels (Trace, Debug, Info, Warn, Error)
5//! - Multiple output formats (JSON, Pretty, Compact)
6//! - Multiple output destinations (Stdout, Stderr, File)
7//! - Per-module log level configuration via RUST_LOG environment variable
8//! - Contextual fields (node_id, session_id, peer_id) in all log entries
9//!
10//! # Per-Module Log Levels
11//!
12//! You can configure different log levels for different modules using the `RUST_LOG`
13//! environment variable. This is useful for debugging specific components without
14//! overwhelming logs from other parts of the system.
15//!
16//! ## Examples
17//!
18//! ```bash
19//! # Set all modules to info, but elara_wire to debug
20//! RUST_LOG=info,elara_wire=debug
21//!
22//! # Set elara_crypto to trace, everything else to warn
23//! RUST_LOG=warn,elara_crypto=trace
24//!
25//! # Multiple module overrides
26//! RUST_LOG=info,elara_wire=debug,elara_state=trace,elara_transport=warn
27//! ```
28//!
29//! # Contextual Fields
30//!
31//! The logging system supports attaching contextual fields to log entries. These fields
32//! provide additional context about the operation being logged:
33//!
34//! - `node_id`: Identifier of the node generating the log
35//! - `session_id`: Current session identifier
36//! - `peer_id`: Identifier of the peer involved in the operation
37//!
38//! Use the `tracing` macros with field syntax to add contextual information:
39//!
40//! ```no_run
41//! use tracing::info;
42//!
43//! info!(
44//! node_id = "node-1",
45//! session_id = "session-abc",
46//! peer_id = "peer-xyz",
47//! "Connection established"
48//! );
49//! ```
50//!
51//! # Basic Example
52//!
53//! ```no_run
54//! use elara_runtime::observability::logging::{LoggingConfig, LogLevel, LogFormat, LogOutput, init_logging};
55//!
56//! let config = LoggingConfig {
57//! level: LogLevel::Info,
58//! format: LogFormat::Json,
59//! output: LogOutput::Stdout,
60//! };
61//!
62//! init_logging(config).expect("Failed to initialize logging");
63//! ```
64
65use std::path::PathBuf;
66use std::sync::atomic::{AtomicBool, Ordering};
67use thiserror::Error;
68use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
69
70/// Global flag to track if logging has been initialized
71static LOGGING_INITIALIZED: AtomicBool = AtomicBool::new(false);
72
73/// Reset the logging initialization flag.
74///
75/// **WARNING**: This function is only for testing purposes and should never be used
76/// in production code. It allows tests to re-initialize the logging system.
77///
78/// # Safety
79///
80/// This function is only available when running tests. Using this in production
81/// could lead to undefined behavior as it allows multiple initializations of global state.
82#[doc(hidden)]
83pub fn reset_logging_for_testing() {
84 LOGGING_INITIALIZED.store(false, Ordering::SeqCst);
85}
86
87/// Configuration for the logging system
88#[derive(Debug, Clone)]
89pub struct LoggingConfig {
90 /// Log level threshold
91 pub level: LogLevel,
92 /// Output format
93 pub format: LogFormat,
94 /// Output destination
95 pub output: LogOutput,
96}
97
98impl Default for LoggingConfig {
99 fn default() -> Self {
100 Self {
101 level: LogLevel::Info,
102 format: LogFormat::Pretty,
103 output: LogOutput::Stdout,
104 }
105 }
106}
107
108/// Log level enumeration
109#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
110pub enum LogLevel {
111 /// Trace level - most verbose
112 Trace,
113 /// Debug level - detailed information
114 Debug,
115 /// Info level - general information
116 Info,
117 /// Warn level - warnings
118 Warn,
119 /// Error level - errors only
120 Error,
121}
122
123impl LogLevel {
124 /// Convert to filter directive string for EnvFilter
125 fn to_filter_directive(&self) -> &'static str {
126 match self {
127 LogLevel::Trace => "trace",
128 LogLevel::Debug => "debug",
129 LogLevel::Info => "info",
130 LogLevel::Warn => "warn",
131 LogLevel::Error => "error",
132 }
133 }
134}
135
136/// Log output format
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum LogFormat {
139 /// Human-readable format for development
140 Pretty,
141 /// JSON format for production log aggregation
142 Json,
143 /// Compact format for high-throughput scenarios
144 Compact,
145}
146
147/// Log output destination
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub enum LogOutput {
150 /// Write to stdout
151 Stdout,
152 /// Write to stderr
153 Stderr,
154 /// Write to a file
155 File(PathBuf),
156}
157
158/// Errors that can occur during logging initialization
159#[derive(Debug, Error)]
160pub enum LoggingError {
161 /// Logging has already been initialized
162 #[error("Logging system has already been initialized")]
163 AlreadyInitialized,
164
165 /// Failed to set global default subscriber
166 #[error("Failed to set global default subscriber: {0}")]
167 SetGlobalDefaultFailed(String),
168
169 /// Failed to open log file
170 #[error("Failed to open log file: {0}")]
171 FileOpenFailed(#[from] std::io::Error),
172}
173
174/// Initialize the logging system with the given configuration
175///
176/// This function sets up the tracing subscriber with the specified configuration.
177/// It can only be called once - subsequent calls will return `LoggingError::AlreadyInitialized`.
178///
179/// # Per-Module Log Levels
180///
181/// The logging system respects the `RUST_LOG` environment variable for per-module
182/// log level configuration. If `RUST_LOG` is set, it takes precedence over the
183/// `config.level` parameter for fine-grained control.
184///
185/// The `config.level` serves as the default/fallback level when `RUST_LOG` is not set.
186///
187/// # Arguments
188///
189/// * `config` - Logging configuration specifying level, format, and output
190///
191/// # Returns
192///
193/// * `Ok(())` - Logging initialized successfully
194/// * `Err(LoggingError)` - Initialization failed
195///
196/// # Example
197///
198/// ```no_run
199/// use elara_runtime::observability::logging::{LoggingConfig, LogLevel, LogFormat, LogOutput, init_logging};
200///
201/// let config = LoggingConfig {
202/// level: LogLevel::Info,
203/// format: LogFormat::Json,
204/// output: LogOutput::Stdout,
205/// };
206///
207/// init_logging(config).expect("Failed to initialize logging");
208/// ```
209///
210/// # Idempotency
211///
212/// This function is idempotent in the sense that calling it multiple times will not
213/// reinitialize the logging system. The second and subsequent calls will return
214/// `Err(LoggingError::AlreadyInitialized)`.
215pub fn init_logging(config: LoggingConfig) -> Result<(), LoggingError> {
216 // Check if already initialized (atomic operation)
217 if LOGGING_INITIALIZED.swap(true, Ordering::SeqCst) {
218 return Err(LoggingError::AlreadyInitialized);
219 }
220
221 // Build EnvFilter that respects RUST_LOG environment variable
222 // Falls back to config.level if RUST_LOG is not set
223 let env_filter = EnvFilter::try_from_default_env()
224 .or_else(|_| {
225 // If RUST_LOG is not set, use the configured level as default
226 EnvFilter::try_new(format!("{}", config.level.to_filter_directive()))
227 })
228 .map_err(|e| LoggingError::SetGlobalDefaultFailed(format!("Failed to create EnvFilter: {}", e)))?;
229
230 // Build the fmt layer based on format and output configuration
231 let result = match (config.format, config.output) {
232 // JSON format to stdout
233 (LogFormat::Json, LogOutput::Stdout) => {
234 let fmt_layer = tracing_subscriber::fmt::layer()
235 .json()
236 .with_target(true)
237 .with_thread_ids(true)
238 .with_line_number(true)
239 .with_file(true);
240
241 tracing_subscriber::registry()
242 .with(env_filter)
243 .with(fmt_layer)
244 .try_init()
245 }
246
247 // JSON format to stderr
248 (LogFormat::Json, LogOutput::Stderr) => {
249 let fmt_layer = tracing_subscriber::fmt::layer()
250 .json()
251 .with_writer(std::io::stderr)
252 .with_target(true)
253 .with_thread_ids(true)
254 .with_line_number(true)
255 .with_file(true);
256
257 tracing_subscriber::registry()
258 .with(env_filter)
259 .with(fmt_layer)
260 .try_init()
261 }
262
263 // JSON format to file
264 (LogFormat::Json, LogOutput::File(path)) => {
265 let file = std::fs::OpenOptions::new()
266 .create(true)
267 .append(true)
268 .open(&path)?;
269
270 let fmt_layer = tracing_subscriber::fmt::layer()
271 .json()
272 .with_writer(file)
273 .with_target(true)
274 .with_thread_ids(true)
275 .with_line_number(true)
276 .with_file(true);
277
278 tracing_subscriber::registry()
279 .with(env_filter)
280 .with(fmt_layer)
281 .try_init()
282 }
283
284 // Pretty format to stdout
285 (LogFormat::Pretty, LogOutput::Stdout) => {
286 let fmt_layer = tracing_subscriber::fmt::layer()
287 .pretty()
288 .with_target(true)
289 .with_thread_ids(true)
290 .with_line_number(true)
291 .with_file(true);
292
293 tracing_subscriber::registry()
294 .with(env_filter)
295 .with(fmt_layer)
296 .try_init()
297 }
298
299 // Pretty format to stderr
300 (LogFormat::Pretty, LogOutput::Stderr) => {
301 let fmt_layer = tracing_subscriber::fmt::layer()
302 .pretty()
303 .with_writer(std::io::stderr)
304 .with_target(true)
305 .with_thread_ids(true)
306 .with_line_number(true)
307 .with_file(true);
308
309 tracing_subscriber::registry()
310 .with(env_filter)
311 .with(fmt_layer)
312 .try_init()
313 }
314
315 // Pretty format to file
316 (LogFormat::Pretty, LogOutput::File(path)) => {
317 let file = std::fs::OpenOptions::new()
318 .create(true)
319 .append(true)
320 .open(&path)?;
321
322 let fmt_layer = tracing_subscriber::fmt::layer()
323 .pretty()
324 .with_writer(file)
325 .with_target(true)
326 .with_thread_ids(true)
327 .with_line_number(true)
328 .with_file(true);
329
330 tracing_subscriber::registry()
331 .with(env_filter)
332 .with(fmt_layer)
333 .try_init()
334 }
335
336 // Compact format to stdout
337 (LogFormat::Compact, LogOutput::Stdout) => {
338 let fmt_layer = tracing_subscriber::fmt::layer()
339 .compact()
340 .with_target(true)
341 .with_thread_ids(true)
342 .with_line_number(true);
343
344 tracing_subscriber::registry()
345 .with(env_filter)
346 .with(fmt_layer)
347 .try_init()
348 }
349
350 // Compact format to stderr
351 (LogFormat::Compact, LogOutput::Stderr) => {
352 let fmt_layer = tracing_subscriber::fmt::layer()
353 .compact()
354 .with_writer(std::io::stderr)
355 .with_target(true)
356 .with_thread_ids(true)
357 .with_line_number(true);
358
359 tracing_subscriber::registry()
360 .with(env_filter)
361 .with(fmt_layer)
362 .try_init()
363 }
364
365 // Compact format to file
366 (LogFormat::Compact, LogOutput::File(path)) => {
367 let file = std::fs::OpenOptions::new()
368 .create(true)
369 .append(true)
370 .open(&path)?;
371
372 let fmt_layer = tracing_subscriber::fmt::layer()
373 .compact()
374 .with_writer(file)
375 .with_target(true)
376 .with_thread_ids(true)
377 .with_line_number(true);
378
379 tracing_subscriber::registry()
380 .with(env_filter)
381 .with(fmt_layer)
382 .try_init()
383 }
384 };
385
386 result.map_err(|e| {
387 let err_msg = e.to_string();
388 // If the error is that a global default is already set, and we're in test mode,
389 // we can safely ignore it since tests run serially with #[serial]
390 if err_msg.contains("global default trace dispatcher has already been set") {
391 // Don't reset the flag - logging is effectively initialized
392 return LoggingError::AlreadyInitialized;
393 }
394 // For other errors, reset flag
395 LOGGING_INITIALIZED.store(false, Ordering::SeqCst);
396 LoggingError::SetGlobalDefaultFailed(err_msg)
397 })?;
398
399 Ok(())
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405
406 #[test]
407 fn test_log_level_ordering() {
408 assert!(LogLevel::Trace < LogLevel::Debug);
409 assert!(LogLevel::Debug < LogLevel::Info);
410 assert!(LogLevel::Info < LogLevel::Warn);
411 assert!(LogLevel::Warn < LogLevel::Error);
412 }
413
414 #[test]
415 fn test_default_config() {
416 let config = LoggingConfig::default();
417 assert_eq!(config.level, LogLevel::Info);
418 assert_eq!(config.format, LogFormat::Pretty);
419 assert_eq!(config.output, LogOutput::Stdout);
420 }
421}