1use crate::{config::Config, errors::ProxyError};
7
8pub 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
41pub fn validate_log_overrides(config: &Config) -> Result<(), ProxyError> {
67 build_env_filter(config)?;
68 Ok(())
69}
70
71pub(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
95fn 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
134fn 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
151fn 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#[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 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}