Skip to main content

rustyclaw_core/
logging.rs

1//! Structured logging configuration for RustyClaw.
2//!
3//! Uses `tracing` with `tracing-subscriber` for configurable log levels
4//! and structured output. Supports JSON output for production environments.
5//!
6//! ## Environment Variables
7//!
8//! - `RUSTYCLAW_LOG` or `RUST_LOG`: Set log level (e.g., `debug`, `rustyclaw=debug,hyper=warn`)
9//! - `RUSTYCLAW_LOG_FORMAT`: Set output format (`pretty`, `compact`, `json`)
10//!
11//! ## Examples
12//!
13//! ```bash
14//! # Debug logging for RustyClaw, warn for everything else
15//! RUSTYCLAW_LOG=rustyclaw=debug,warn rustyclaw gateway run
16//!
17//! # JSON output for production
18//! RUSTYCLAW_LOG_FORMAT=json rustyclaw gateway run
19//! ```
20
21use tracing_subscriber::{
22    EnvFilter,
23    fmt::{self, format::FmtSpan},
24    prelude::*,
25};
26
27/// Log output format
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
29pub enum LogFormat {
30    /// Human-readable with colors and indentation
31    #[default]
32    Pretty,
33    /// Compact single-line output
34    Compact,
35    /// JSON output for log aggregation
36    Json,
37}
38
39impl LogFormat {
40    /// Parse from string (case-insensitive)
41    pub fn from_str(s: &str) -> Self {
42        match s.to_lowercase().as_str() {
43            "json" => Self::Json,
44            "compact" => Self::Compact,
45            "pretty" | _ => Self::Pretty,
46        }
47    }
48}
49
50/// Logging configuration
51#[derive(Debug, Clone)]
52pub struct LogConfig {
53    /// Log filter directive (e.g., "debug", "rustyclaw=debug,hyper=warn")
54    pub filter: String,
55    /// Output format
56    pub format: LogFormat,
57    /// Include span events (enter/exit)
58    pub with_spans: bool,
59    /// Include file/line in logs
60    pub with_file: bool,
61    /// Include thread IDs
62    pub with_thread_ids: bool,
63    /// Include target (module path)
64    pub with_target: bool,
65}
66
67impl Default for LogConfig {
68    fn default() -> Self {
69        Self {
70            filter: "rustyclaw=info,warn".to_string(),
71            format: LogFormat::Pretty,
72            with_spans: false,
73            with_file: false,
74            with_thread_ids: false,
75            with_target: true,
76        }
77    }
78}
79
80impl LogConfig {
81    /// Create config from environment variables
82    pub fn from_env() -> Self {
83        let filter = std::env::var("RUSTYCLAW_LOG")
84            .or_else(|_| std::env::var("RUST_LOG"))
85            .unwrap_or_else(|_| "rustyclaw=info,warn".to_string());
86
87        let format = std::env::var("RUSTYCLAW_LOG_FORMAT")
88            .map(|s| LogFormat::from_str(&s))
89            .unwrap_or_default();
90
91        Self {
92            filter,
93            format,
94            ..Default::default()
95        }
96    }
97
98    /// Create a debug configuration
99    pub fn debug() -> Self {
100        Self {
101            filter: "rustyclaw=debug,info".to_string(),
102            with_file: true,
103            ..Default::default()
104        }
105    }
106
107    /// Create a production configuration with JSON output
108    pub fn production() -> Self {
109        Self {
110            filter: "rustyclaw=info,warn".to_string(),
111            format: LogFormat::Json,
112            with_spans: true,
113            with_target: true,
114            ..Default::default()
115        }
116    }
117}
118
119/// Initialize the global tracing subscriber.
120///
121/// This should be called once at the start of the program.
122/// Subsequent calls will be ignored.
123///
124/// # Examples
125///
126/// ```rust,ignore
127/// use rustyclaw::logging::{init, LogConfig};
128///
129/// // Use environment-based configuration
130/// init(LogConfig::from_env());
131///
132/// // Or use explicit configuration
133/// init(LogConfig::debug());
134/// ```
135pub fn init(config: LogConfig) {
136    let env_filter = EnvFilter::try_new(&config.filter)
137        .unwrap_or_else(|_| EnvFilter::new("rustyclaw=info,warn"));
138
139    let span_events = if config.with_spans {
140        FmtSpan::NEW | FmtSpan::CLOSE
141    } else {
142        FmtSpan::NONE
143    };
144
145    match config.format {
146        LogFormat::Json => {
147            let subscriber = tracing_subscriber::registry().with(env_filter).with(
148                fmt::layer()
149                    .json()
150                    .with_span_events(span_events)
151                    .with_file(config.with_file)
152                    .with_line_number(config.with_file)
153                    .with_thread_ids(config.with_thread_ids)
154                    .with_target(config.with_target),
155            );
156            let _ = tracing::subscriber::set_global_default(subscriber);
157        }
158        LogFormat::Compact => {
159            let subscriber = tracing_subscriber::registry().with(env_filter).with(
160                fmt::layer()
161                    .compact()
162                    .with_span_events(span_events)
163                    .with_file(config.with_file)
164                    .with_line_number(config.with_file)
165                    .with_thread_ids(config.with_thread_ids)
166                    .with_target(config.with_target),
167            );
168            let _ = tracing::subscriber::set_global_default(subscriber);
169        }
170        LogFormat::Pretty => {
171            let subscriber = tracing_subscriber::registry().with(env_filter).with(
172                fmt::layer()
173                    .pretty()
174                    .with_span_events(span_events)
175                    .with_file(config.with_file)
176                    .with_line_number(config.with_file)
177                    .with_thread_ids(config.with_thread_ids)
178                    .with_target(config.with_target),
179            );
180            let _ = tracing::subscriber::set_global_default(subscriber);
181        }
182    }
183}
184
185/// Initialize logging with environment-based configuration.
186///
187/// Convenience function that calls `init(LogConfig::from_env())`.
188pub fn init_from_env() {
189    init(LogConfig::from_env());
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn test_log_format_parsing() {
198        assert_eq!(LogFormat::from_str("json"), LogFormat::Json);
199        assert_eq!(LogFormat::from_str("JSON"), LogFormat::Json);
200        assert_eq!(LogFormat::from_str("compact"), LogFormat::Compact);
201        assert_eq!(LogFormat::from_str("pretty"), LogFormat::Pretty);
202        assert_eq!(LogFormat::from_str("unknown"), LogFormat::Pretty);
203    }
204
205    #[test]
206    fn test_config_from_env() {
207        // Clear any existing env vars for test isolation
208        // SAFETY: This test runs serially and these env vars are only read by LogConfig::from_env
209        unsafe {
210            std::env::remove_var("RUSTYCLAW_LOG");
211            std::env::remove_var("RUST_LOG");
212            std::env::remove_var("RUSTYCLAW_LOG_FORMAT");
213        }
214
215        let config = LogConfig::from_env();
216        assert_eq!(config.filter, "rustyclaw=info,warn");
217        assert_eq!(config.format, LogFormat::Pretty);
218    }
219
220    #[test]
221    fn test_debug_config() {
222        let config = LogConfig::debug();
223        assert!(config.filter.contains("debug"));
224        assert!(config.with_file);
225    }
226
227    #[test]
228    fn test_production_config() {
229        let config = LogConfig::production();
230        assert_eq!(config.format, LogFormat::Json);
231        assert!(config.with_spans);
232    }
233}