Skip to main content

axon/
logging.rs

1//! Logging — production-grade structured logging for AxonServer.
2//!
3//! Built on `tracing` + `tracing-subscriber` with:
4//!   - JSON or human-readable (pretty) output to stdout
5//!   - Optional daily-rotated file logging via `tracing-appender`
6//!   - Configurable log level via `AXON_LOG` env var or `--log-level` CLI arg
7//!   - Request correlation via tracing spans (request_id propagation)
8//!
9//! Designed for production SaaS workloads — structured JSON output is the default
10//! for machine consumption (ELK, Datadog, CloudWatch, etc.).
11//!
12//! Usage:
13//!   let _guard = axon::logging::init("info", "json", None);
14//!   // guard must be held for program lifetime to ensure non-blocking writes flush
15
16use tracing_appender::non_blocking::WorkerGuard;
17use tracing_subscriber::fmt;
18use tracing_subscriber::prelude::*;
19use tracing_subscriber::EnvFilter;
20
21/// Logging format selection.
22#[derive(Debug, Clone, Copy, PartialEq)]
23pub enum LogFormat {
24    /// JSON structured output — default for production.
25    Json,
26    /// Human-readable pretty output — for local development.
27    Pretty,
28}
29
30impl LogFormat {
31    pub fn from_str(s: &str) -> Self {
32        match s.to_lowercase().as_str() {
33            "pretty" | "text" | "human" => LogFormat::Pretty,
34            _ => LogFormat::Json,
35        }
36    }
37}
38
39/// Initialize the global tracing subscriber.
40///
41/// Returns a `LogGuard` that must be held for the program's lifetime.
42/// Dropping the guard flushes and closes the non-blocking writer(s).
43///
44/// # Parameters
45/// - `log_level`: default filter level (e.g., "info", "debug", "trace").
46///   Overridden by `AXON_LOG` env var if set.
47/// - `format`: "json" (default) or "pretty"
48/// - `log_file_dir`: optional directory for daily-rotated log files.
49///   If `Some`, a file writer layer is added alongside stdout.
50pub fn init(log_level: &str, format: &str, log_file_dir: Option<&str>) -> LogGuard {
51    let format = LogFormat::from_str(format);
52
53    // Build env filter: AXON_LOG env takes precedence, then CLI arg, then default "info"
54    let filter = EnvFilter::try_from_env("AXON_LOG")
55        .unwrap_or_else(|_| {
56            EnvFilter::try_new(log_level)
57                .unwrap_or_else(|_| EnvFilter::new("info"))
58        });
59
60    // Stdout non-blocking writer
61    let (stdout_writer, stdout_guard) = tracing_appender::non_blocking(std::io::stdout());
62
63    // Build per-format to avoid type mismatches between json/pretty layer generics
64    let file_guard = match format {
65        LogFormat::Json => {
66            let stdout_layer = fmt::layer()
67                .json()
68                .with_writer(stdout_writer)
69                .with_target(true)
70                .with_thread_ids(false)
71                .with_file(true)
72                .with_line_number(true)
73                .with_span_list(true);
74
75            match log_file_dir {
76                Some(dir) => {
77                    let file_appender = tracing_appender::rolling::daily(dir, "axon-server.log");
78                    let (file_writer, fguard) = tracing_appender::non_blocking(file_appender);
79                    let file_layer = fmt::layer()
80                        .json()
81                        .with_writer(file_writer)
82                        .with_target(true)
83                        .with_thread_ids(true)
84                        .with_thread_names(true)
85                        .with_file(true)
86                        .with_line_number(true)
87                        .with_span_list(true);
88
89                    let subscriber = tracing_subscriber::registry()
90                        .with(filter)
91                        .with(stdout_layer)
92                        .with(file_layer);
93                    tracing::subscriber::set_global_default(subscriber)
94                        .expect("Failed to set global tracing subscriber");
95                    Some(fguard)
96                }
97                None => {
98                    let subscriber = tracing_subscriber::registry()
99                        .with(filter)
100                        .with(stdout_layer);
101                    tracing::subscriber::set_global_default(subscriber)
102                        .expect("Failed to set global tracing subscriber");
103                    None
104                }
105            }
106        }
107        LogFormat::Pretty => {
108            let stdout_layer = fmt::layer()
109                .pretty()
110                .with_writer(stdout_writer)
111                .with_target(true)
112                .with_thread_ids(false)
113                .with_file(true)
114                .with_line_number(true);
115
116            match log_file_dir {
117                Some(dir) => {
118                    let file_appender = tracing_appender::rolling::daily(dir, "axon-server.log");
119                    let (file_writer, fguard) = tracing_appender::non_blocking(file_appender);
120                    let file_layer = fmt::layer()
121                        .json()
122                        .with_writer(file_writer)
123                        .with_target(true)
124                        .with_thread_ids(true)
125                        .with_thread_names(true)
126                        .with_file(true)
127                        .with_line_number(true)
128                        .with_span_list(true);
129
130                    let subscriber = tracing_subscriber::registry()
131                        .with(filter)
132                        .with(stdout_layer)
133                        .with(file_layer);
134                    tracing::subscriber::set_global_default(subscriber)
135                        .expect("Failed to set global tracing subscriber");
136                    Some(fguard)
137                }
138                None => {
139                    let subscriber = tracing_subscriber::registry()
140                        .with(filter)
141                        .with(stdout_layer);
142                    tracing::subscriber::set_global_default(subscriber)
143                        .expect("Failed to set global tracing subscriber");
144                    None
145                }
146            }
147        }
148    };
149
150    LogGuard {
151        _stdout_guard: stdout_guard,
152        _file_guard: file_guard,
153    }
154}
155
156/// Guard that must be held for the program's lifetime.
157/// Dropping it flushes and closes non-blocking writers.
158pub struct LogGuard {
159    _stdout_guard: WorkerGuard,
160    _file_guard: Option<WorkerGuard>,
161}
162
163// ── Tests ──────────────────────────────────────────────────────────────────
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn test_log_format_from_str() {
171        assert_eq!(LogFormat::from_str("json"), LogFormat::Json);
172        assert_eq!(LogFormat::from_str("JSON"), LogFormat::Json);
173        assert_eq!(LogFormat::from_str("pretty"), LogFormat::Pretty);
174        assert_eq!(LogFormat::from_str("text"), LogFormat::Pretty);
175        assert_eq!(LogFormat::from_str("human"), LogFormat::Pretty);
176        assert_eq!(LogFormat::from_str("unknown"), LogFormat::Json);
177    }
178
179    #[test]
180    fn test_log_format_default_is_json() {
181        assert_eq!(LogFormat::from_str(""), LogFormat::Json);
182    }
183}