Skip to main content

praxis_core/
logging.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2024 Praxis Contributors
3
4//! Tracing subscriber setup shared by all Praxis binaries.
5
6use crate::{config::Config, errors::ProxyError};
7
8// -----------------------------------------------------------------------------
9// Tracing
10// -----------------------------------------------------------------------------
11
12/// Initialize the global tracing subscriber.
13///
14/// Set `PRAXIS_LOG_FORMAT=json` for structured JSON output.
15/// Per-module overrides come from `runtime.log_overrides` in
16/// the config YAML.
17///
18/// # Errors
19///
20/// Returns [`ProxyError::Config`] if any `log_overrides` entry is invalid.
21///
22/// ```no_run
23/// let config = praxis_core::config::Config::load(None, "listeners: []").unwrap();
24/// praxis_core::logging::init_tracing(&config).unwrap();
25/// ```
26///
27/// [`ProxyError::Config`]: crate::errors::ProxyError::Config
28pub fn init_tracing(config: &Config) -> Result<(), ProxyError> {
29    let env_filter = build_env_filter(config)?;
30    let json = std::env::var("PRAXIS_LOG_FORMAT").is_ok_and(|v| v.eq_ignore_ascii_case("json"));
31
32    if json {
33        tracing_subscriber::fmt().json().with_env_filter(env_filter).init();
34    } else {
35        tracing_subscriber::fmt().with_env_filter(env_filter).init();
36    }
37
38    Ok(())
39}
40
41/// Validate log overrides from config without initializing the global subscriber.
42///
43/// Useful for configuration validation that needs to check log override
44/// syntax without affecting the global tracing state.
45///
46/// # Errors
47///
48/// Returns [`ProxyError::Config`] if any `log_overrides` entry is invalid.
49///
50/// ```
51/// let yaml = r#"
52/// listeners:
53///   - name: test
54///     address: "127.0.0.1:8080"
55///     filter_chains: [main]
56/// filter_chains:
57///   - name: main
58///     filters:
59///       - filter: static_response
60/// "#;
61/// let config = praxis_core::config::Config::from_yaml(yaml).unwrap();
62/// praxis_core::logging::validate_log_overrides(&config).unwrap();
63/// ```
64///
65/// [`ProxyError::Config`]: crate::errors::ProxyError::Config
66pub fn validate_log_overrides(config: &Config) -> Result<(), ProxyError> {
67    build_env_filter(config)?;
68    Ok(())
69}
70
71// -----------------------------------------------------------------------------
72// EnvFilter Factory
73// -----------------------------------------------------------------------------
74
75/// Build an [`EnvFilter`] from `RUST_LOG` (or the given default) merged with any `log_overrides` from the config.
76///
77/// # Errors
78///
79/// Returns [`ProxyError::Config`] listing every invalid log override entry.
80///
81/// [`EnvFilter`]: tracing_subscriber::EnvFilter
82/// [`ProxyError::Config`]: crate::errors::ProxyError::Config
83pub(crate) fn build_env_filter(config: &Config) -> Result<tracing_subscriber::EnvFilter, ProxyError> {
84    let base = tracing_subscriber::EnvFilter::try_from_default_env()
85        .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
86
87    if config.runtime.log_overrides.is_empty() {
88        return Ok(base);
89    }
90
91    let directives = validate_and_build_directives(&base, &config.runtime.log_overrides)?;
92    Ok(tracing_subscriber::EnvFilter::new(directives))
93}
94
95/// Validate all log override entries and build the combined directive string.
96fn validate_and_build_directives(
97    base: &tracing_subscriber::EnvFilter,
98    overrides: &std::collections::HashMap<String, String>,
99) -> Result<String, ProxyError> {
100    let mut errors: Vec<String> = Vec::new();
101
102    for (module, level) in overrides {
103        if !is_valid_module_path(module) {
104            errors.push(format!(
105                "invalid module path '{module}' (must be alphanumeric, '_', or '::')"
106            ));
107        }
108        if !is_valid_log_level(level) {
109            errors.push(format!(
110                "invalid level '{level}' for module '{module}' \
111                 (must be error, warn, info, debug, or trace)"
112            ));
113        }
114    }
115
116    if !errors.is_empty() {
117        return Err(ProxyError::Config(format!(
118            "invalid log_overrides: {}",
119            errors.join("; ")
120        )));
121    }
122
123    let mut directives = base.to_string();
124    for (module, level) in overrides {
125        directives.push(',');
126        directives.push_str(module);
127        directives.push('=');
128        directives.push_str(level);
129    }
130
131    Ok(directives)
132}
133
134// -----------------------------------------------------------------------------
135// Utility Functions
136// -----------------------------------------------------------------------------
137
138/// Returns `true` if `s` is a valid Rust module path and is non-empty.
139fn is_valid_module_path(s: &str) -> bool {
140    !s.is_empty()
141        && s.split("::").all(|segment| {
142            !segment.is_empty()
143                && segment
144                    .bytes()
145                    .next()
146                    .is_some_and(|b| b.is_ascii_alphabetic() || b == b'_')
147                && segment.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
148        })
149}
150
151/// Returns `true` if `s` is one of the five tracing levels (case-insensitive).
152fn is_valid_log_level(s: &str) -> bool {
153    matches!(
154        s.to_ascii_lowercase().as_str(),
155        "error" | "warn" | "info" | "debug" | "trace"
156    )
157}
158
159// -----------------------------------------------------------------------------
160// Tests
161// -----------------------------------------------------------------------------
162
163#[cfg(test)]
164#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
165#[allow(
166    clippy::unwrap_used,
167    clippy::expect_used,
168    clippy::indexing_slicing,
169    clippy::needless_raw_strings,
170    clippy::needless_raw_string_hashes,
171    reason = "tests use unwrap/expect/indexing/raw strings for brevity"
172)]
173mod tests {
174    use std::collections::HashMap;
175
176    use super::*;
177    use crate::config::Config;
178
179    #[test]
180    fn empty_log_overrides_produces_valid_filter() {
181        let config = config_with_overrides(HashMap::new());
182        let filter = build_env_filter(&config).expect("empty overrides should succeed");
183        let filter_str = filter.to_string();
184        assert!(
185            !filter_str.is_empty(),
186            "filter with no overrides should still produce a non-empty directive string"
187        );
188    }
189
190    #[test]
191    fn log_overrides_appended_to_filter_string() {
192        let mut overrides = HashMap::new();
193        overrides.insert("praxis_filter".to_owned(), "trace".to_owned());
194        overrides.insert("praxis_protocol".to_owned(), "debug".to_owned());
195
196        let config = config_with_overrides(overrides);
197        let filter = build_env_filter(&config).expect("valid overrides should succeed");
198        let filter_str = filter.to_string();
199
200        assert!(
201            filter_str.contains("praxis_filter=trace"),
202            "filter should contain praxis_filter=trace, got: {filter_str}"
203        );
204        assert!(
205            filter_str.contains("praxis_protocol=debug"),
206            "filter should contain praxis_protocol=debug, got: {filter_str}"
207        );
208    }
209
210    #[test]
211    fn invalid_module_path_is_rejected() {
212        let mut overrides = HashMap::new();
213        overrides.insert("trace,h2=off".to_owned(), "debug".to_owned());
214        overrides.insert("praxis_core".to_owned(), "trace".to_owned());
215
216        let config = config_with_overrides(overrides);
217        let err = build_env_filter(&config).unwrap_err();
218        let msg = err.to_string();
219
220        assert!(
221            msg.contains("invalid module path 'trace,h2=off'"),
222            "error should identify the bad module path, got: {msg}"
223        );
224    }
225
226    #[test]
227    fn invalid_level_is_rejected() {
228        let mut overrides = HashMap::new();
229        overrides.insert("praxis_filter".to_owned(), "trace,h2=off".to_owned());
230        overrides.insert("praxis_core".to_owned(), "debug".to_owned());
231
232        let config = config_with_overrides(overrides);
233        let err = build_env_filter(&config).unwrap_err();
234        let msg = err.to_string();
235
236        assert!(
237            msg.contains("invalid level 'trace,h2=off'"),
238            "error should identify the bad level, got: {msg}"
239        );
240    }
241
242    #[test]
243    fn multiple_invalid_overrides_reported_together() {
244        let mut overrides = HashMap::new();
245        overrides.insert("bad module".to_owned(), "info".to_owned());
246        overrides.insert("praxis_core".to_owned(), "bogus".to_owned());
247
248        let config = config_with_overrides(overrides);
249        let err = build_env_filter(&config).unwrap_err();
250        let msg = err.to_string();
251
252        assert!(
253            msg.contains("invalid module path 'bad module'"),
254            "error should report bad module path, got: {msg}"
255        );
256        assert!(
257            msg.contains("invalid level 'bogus'"),
258            "error should report bad level, got: {msg}"
259        );
260    }
261
262    #[test]
263    fn empty_module_path_is_rejected() {
264        assert!(!is_valid_module_path(""), "empty string should be invalid");
265    }
266
267    #[test]
268    fn module_path_with_spaces_is_rejected() {
269        assert!(!is_valid_module_path("praxis core"), "spaces should be invalid");
270    }
271
272    #[test]
273    fn module_path_with_double_colon_segments() {
274        assert!(
275            is_valid_module_path("praxis_filter::pipeline"),
276            "nested module path should be valid"
277        );
278    }
279
280    #[test]
281    fn module_path_with_empty_segment_is_rejected() {
282        assert!(!is_valid_module_path("praxis::"), "trailing :: should be invalid");
283        assert!(!is_valid_module_path("::praxis"), "leading :: should be invalid");
284    }
285
286    #[test]
287    fn valid_log_levels_accepted() {
288        for level in &["error", "warn", "info", "debug", "trace", "TRACE", "Info"] {
289            assert!(is_valid_log_level(level), "{level} should be a valid log level");
290        }
291    }
292
293    #[test]
294    fn invalid_log_levels_rejected() {
295        for level in &["off", "critical", "trace,h2=off", ""] {
296            assert!(!is_valid_log_level(level), "{level} should be rejected as log level");
297        }
298    }
299
300    // ---------------------------------------------------------------------------
301    // Test Utilities
302    // ---------------------------------------------------------------------------
303
304    /// Build a minimal [`Config`] with the given log overrides.
305    fn config_with_overrides(overrides: HashMap<String, String>) -> Config {
306        let yaml = r#"
307listeners:
308  - name: test
309    address: "127.0.0.1:8080"
310    filter_chains: [main]
311filter_chains:
312  - name: main
313    filters:
314      - filter: static_response
315"#;
316        let mut config = Config::from_yaml(yaml).expect("test config should parse");
317        config.runtime.log_overrides = overrides;
318        config
319    }
320}