Skip to main content

nautilus_common/logging/
config.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Logging configuration types and parsing.
17//!
18//! This module provides configuration for the Nautilus logging subsystem via
19//! the `LoggerConfig` and `FileWriterConfig` types.
20//!
21//! # Spec String Format
22//!
23//! The `NAUTILUS_LOG` environment variable uses a semicolon-separated format:
24//!
25//! ```text
26//! stdout=Info;fileout=Debug;RiskEngine=Error;my_crate::module=Debug;is_colored
27//! ```
28//!
29//! ## Supported Keys
30//!
31//! | Key                   | Type      | Description                                  |
32//! |-----------------------|-----------|----------------------------------------------|
33//! | `stdout`              | Log level | Maximum level for stdout output.             |
34//! | `fileout`             | Log level | Maximum level for file output.               |
35//! | `is_colored`          | Boolean   | Enable ANSI colors (default: true).          |
36//! | `print_config`        | Boolean   | Print config to stdout at startup.           |
37//! | `log_components_only` | Boolean   | Only log components with explicit filters.   |
38//! | `use_tracing`         | Boolean   | Enable tracing subscriber for external libs. |
39//! | `<component>`         | Log level | Component-specific log level (exact match).  |
40//! | `<module::path>`      | Log level | Module-specific log level (prefix match).    |
41//!
42//! ## Log Levels
43//!
44//! All log levels are case-insensitive.
45//!
46//! - `Off`
47//! - `Error`
48//! - `Warn`
49//! - `Info`
50//! - `Debug`
51//! - `Trace`
52//!
53//! ## Boolean Values
54//!
55//! - Bare flag: `is_colored` → true
56//! - Explicit: `is_colored=true`, `is_colored=false`, `is_colored=0`, `is_colored=no`
57
58use std::{env, str::FromStr};
59
60use ahash::AHashMap;
61use log::LevelFilter;
62use ustr::Ustr;
63
64/// Configuration for the Nautilus logger.
65#[cfg_attr(
66    feature = "python",
67    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.common")
68)]
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct LoggerConfig {
71    /// Maximum log level for stdout output.
72    pub stdout_level: LevelFilter,
73    /// Maximum log level for file output (`Off` disables file logging).
74    pub fileout_level: LevelFilter,
75    /// Per-component log level overrides (exact match).
76    pub component_level: AHashMap<Ustr, LevelFilter>,
77    /// Per-module path log level overrides (prefix match).
78    pub module_level: AHashMap<Ustr, LevelFilter>,
79    /// Log only components with explicit level filters.
80    pub log_components_only: bool,
81    /// Use ANSI color codes in output.
82    pub is_colored: bool,
83    /// Print configuration to stdout at startup.
84    pub print_config: bool,
85    /// Initialize the tracing subscriber for external Rust crate logs.
86    pub use_tracing: bool,
87}
88
89impl Default for LoggerConfig {
90    /// Creates a new default [`LoggerConfig`] instance.
91    fn default() -> Self {
92        Self {
93            stdout_level: LevelFilter::Info,
94            fileout_level: LevelFilter::Off,
95            component_level: AHashMap::new(),
96            module_level: AHashMap::new(),
97            log_components_only: false,
98            is_colored: true,
99            print_config: false,
100            use_tracing: false,
101        }
102    }
103}
104
105impl LoggerConfig {
106    /// Creates a new [`LoggerConfig`] instance.
107    #[must_use]
108    #[allow(clippy::too_many_arguments)]
109    pub fn new(
110        stdout_level: LevelFilter,
111        fileout_level: LevelFilter,
112        component_level: AHashMap<Ustr, LevelFilter>,
113        module_level: AHashMap<Ustr, LevelFilter>,
114        log_components_only: bool,
115        is_colored: bool,
116        print_config: bool,
117        use_tracing: bool,
118    ) -> Self {
119        Self {
120            stdout_level,
121            fileout_level,
122            component_level,
123            module_level,
124            log_components_only,
125            is_colored,
126            print_config,
127            use_tracing,
128        }
129    }
130
131    /// Parses a configuration from a spec string.
132    ///
133    /// # Format
134    ///
135    /// Semicolon-separated key-value pairs or bare flags:
136    /// ```text
137    /// stdout=Info;fileout=Debug;RiskEngine=Error;my_crate::module=Debug;is_colored
138    /// ```
139    ///
140    /// # Errors
141    ///
142    /// Returns an error if the spec string contains invalid syntax or log levels.
143    pub fn from_spec(spec: &str) -> anyhow::Result<Self> {
144        let mut config = Self::default();
145
146        for kv in spec.split(';') {
147            let kv = kv.trim();
148            if kv.is_empty() {
149                continue;
150            }
151
152            let kv_lower = kv.to_lowercase();
153
154            // Handle bare flags (without =)
155            if !kv.contains('=') {
156                match kv_lower.as_str() {
157                    "log_components_only" => config.log_components_only = true,
158                    "is_colored" => config.is_colored = true,
159                    "print_config" => config.print_config = true,
160                    "use_tracing" => config.use_tracing = true,
161                    _ => anyhow::bail!("Invalid spec pair: {kv}"),
162                }
163                continue;
164            }
165
166            let parts: Vec<&str> = kv.splitn(2, '=').collect();
167            if parts.len() != 2 {
168                anyhow::bail!("Invalid spec pair: {kv}");
169            }
170
171            let k = parts[0].trim();
172            let v = parts[1].trim();
173            let k_lower = k.to_lowercase();
174
175            match k_lower.as_str() {
176                "is_colored" => {
177                    config.is_colored = parse_bool_value(v);
178                }
179                "log_components_only" => {
180                    config.log_components_only = parse_bool_value(v);
181                }
182                "print_config" => {
183                    config.print_config = parse_bool_value(v);
184                }
185                "use_tracing" => {
186                    config.use_tracing = parse_bool_value(v);
187                }
188                "stdout" => {
189                    config.stdout_level = parse_level(v)?;
190                }
191                "fileout" => {
192                    config.fileout_level = parse_level(v)?;
193                }
194                _ => {
195                    let lvl = parse_level(v)?;
196                    if k.contains("::") {
197                        config.module_level.insert(Ustr::from(k), lvl);
198                    } else {
199                        config.component_level.insert(Ustr::from(k), lvl);
200                    }
201                }
202            }
203        }
204
205        Ok(config)
206    }
207
208    /// Parses configuration from the `NAUTILUS_LOG` environment variable.
209    ///
210    /// # Errors
211    ///
212    /// Returns an error if the variable is unset or contains invalid syntax.
213    pub fn from_env() -> anyhow::Result<Self> {
214        let spec = env::var("NAUTILUS_LOG")?;
215        Self::from_spec(&spec)
216    }
217}
218
219/// Parses a boolean value from a string.
220///
221/// Returns `true` unless the value is explicitly "false", "0", or "no" (case-insensitive).
222fn parse_bool_value(v: &str) -> bool {
223    !matches!(v.to_lowercase().as_str(), "false" | "0" | "no")
224}
225
226/// Parses a log level from a string.
227fn parse_level(v: &str) -> anyhow::Result<LevelFilter> {
228    LevelFilter::from_str(v).map_err(|_| anyhow::anyhow!("Invalid log level: {v}"))
229}
230
231#[cfg(test)]
232mod tests {
233    use rstest::rstest;
234
235    use super::*;
236
237    #[rstest]
238    fn test_default_config() {
239        let config = LoggerConfig::default();
240        assert_eq!(config.stdout_level, LevelFilter::Info);
241        assert_eq!(config.fileout_level, LevelFilter::Off);
242        assert!(config.component_level.is_empty());
243        assert!(!config.log_components_only);
244        assert!(config.is_colored);
245        assert!(!config.print_config);
246    }
247
248    #[rstest]
249    fn test_from_spec_stdout_and_fileout() {
250        let config = LoggerConfig::from_spec("stdout=Debug;fileout=Error").unwrap();
251        assert_eq!(config.stdout_level, LevelFilter::Debug);
252        assert_eq!(config.fileout_level, LevelFilter::Error);
253    }
254
255    #[rstest]
256    fn test_from_spec_case_insensitive_levels() {
257        let config = LoggerConfig::from_spec("stdout=debug;fileout=ERROR").unwrap();
258        assert_eq!(config.stdout_level, LevelFilter::Debug);
259        assert_eq!(config.fileout_level, LevelFilter::Error);
260    }
261
262    #[rstest]
263    fn test_from_spec_case_insensitive_keys() {
264        let config = LoggerConfig::from_spec("STDOUT=Info;FILEOUT=Debug").unwrap();
265        assert_eq!(config.stdout_level, LevelFilter::Info);
266        assert_eq!(config.fileout_level, LevelFilter::Debug);
267    }
268
269    #[rstest]
270    fn test_from_spec_empty_string() {
271        let config = LoggerConfig::from_spec("").unwrap();
272        assert_eq!(config, LoggerConfig::default());
273    }
274
275    #[rstest]
276    fn test_from_spec_with_whitespace() {
277        let config = LoggerConfig::from_spec("  stdout = Info ; fileout = Debug  ").unwrap();
278        assert_eq!(config.stdout_level, LevelFilter::Info);
279        assert_eq!(config.fileout_level, LevelFilter::Debug);
280    }
281
282    #[rstest]
283    fn test_from_spec_trailing_semicolon() {
284        let config = LoggerConfig::from_spec("stdout=Warn;").unwrap();
285        assert_eq!(config.stdout_level, LevelFilter::Warn);
286    }
287
288    #[rstest]
289    fn test_from_spec_bare_is_colored() {
290        let config = LoggerConfig::from_spec("is_colored").unwrap();
291        assert!(config.is_colored);
292    }
293
294    #[rstest]
295    fn test_from_spec_is_colored_true() {
296        let config = LoggerConfig::from_spec("is_colored=true").unwrap();
297        assert!(config.is_colored);
298    }
299
300    #[rstest]
301    fn test_from_spec_is_colored_false() {
302        let config = LoggerConfig::from_spec("is_colored=false").unwrap();
303        assert!(!config.is_colored);
304    }
305
306    #[rstest]
307    fn test_from_spec_is_colored_zero() {
308        let config = LoggerConfig::from_spec("is_colored=0").unwrap();
309        assert!(!config.is_colored);
310    }
311
312    #[rstest]
313    fn test_from_spec_is_colored_no() {
314        let config = LoggerConfig::from_spec("is_colored=no").unwrap();
315        assert!(!config.is_colored);
316    }
317
318    #[rstest]
319    fn test_from_spec_is_colored_case_insensitive() {
320        let config = LoggerConfig::from_spec("IS_COLORED=FALSE").unwrap();
321        assert!(!config.is_colored);
322    }
323
324    #[rstest]
325    fn test_from_spec_print_config() {
326        let config = LoggerConfig::from_spec("print_config").unwrap();
327        assert!(config.print_config);
328    }
329
330    #[rstest]
331    fn test_from_spec_print_config_false() {
332        let config = LoggerConfig::from_spec("print_config=false").unwrap();
333        assert!(!config.print_config);
334    }
335
336    #[rstest]
337    fn test_from_spec_log_components_only() {
338        let config = LoggerConfig::from_spec("log_components_only").unwrap();
339        assert!(config.log_components_only);
340    }
341
342    #[rstest]
343    fn test_from_spec_log_components_only_false() {
344        let config = LoggerConfig::from_spec("log_components_only=false").unwrap();
345        assert!(!config.log_components_only);
346    }
347
348    #[rstest]
349    fn test_from_spec_component_level() {
350        let config = LoggerConfig::from_spec("RiskEngine=Error;DataEngine=Debug").unwrap();
351        assert_eq!(
352            config.component_level[&Ustr::from("RiskEngine")],
353            LevelFilter::Error
354        );
355        assert_eq!(
356            config.component_level[&Ustr::from("DataEngine")],
357            LevelFilter::Debug
358        );
359    }
360
361    #[rstest]
362    fn test_from_spec_component_preserves_case() {
363        // Component names should preserve their original case
364        let config = LoggerConfig::from_spec("MyComponent=Info").unwrap();
365        assert!(
366            config
367                .component_level
368                .contains_key(&Ustr::from("MyComponent"))
369        );
370        assert!(
371            !config
372                .component_level
373                .contains_key(&Ustr::from("mycomponent"))
374        );
375    }
376
377    #[rstest]
378    fn test_from_spec_full_example() {
379        let config = LoggerConfig::from_spec(
380            "stdout=Info;fileout=Debug;RiskEngine=Error;is_colored;print_config",
381        )
382        .unwrap();
383
384        assert_eq!(config.stdout_level, LevelFilter::Info);
385        assert_eq!(config.fileout_level, LevelFilter::Debug);
386        assert_eq!(
387            config.component_level[&Ustr::from("RiskEngine")],
388            LevelFilter::Error
389        );
390        assert!(config.is_colored);
391        assert!(config.print_config);
392    }
393
394    #[rstest]
395    fn test_from_spec_disabled_colors() {
396        let config = LoggerConfig::from_spec("stdout=Info;is_colored=false;fileout=Debug").unwrap();
397        assert!(!config.is_colored);
398        assert_eq!(config.stdout_level, LevelFilter::Info);
399        assert_eq!(config.fileout_level, LevelFilter::Debug);
400    }
401
402    #[rstest]
403    fn test_from_spec_invalid_level() {
404        let result = LoggerConfig::from_spec("stdout=InvalidLevel");
405        assert!(result.is_err());
406        assert!(
407            result
408                .unwrap_err()
409                .to_string()
410                .contains("Invalid log level")
411        );
412    }
413
414    #[rstest]
415    fn test_from_spec_invalid_bare_flag() {
416        let result = LoggerConfig::from_spec("unknown_flag");
417        assert!(result.is_err());
418        assert!(
419            result
420                .unwrap_err()
421                .to_string()
422                .contains("Invalid spec pair")
423        );
424    }
425
426    #[rstest]
427    fn test_from_spec_missing_value() {
428        // "stdout=" with no value is technically valid empty string, which is invalid level
429        let result = LoggerConfig::from_spec("stdout=");
430        assert!(result.is_err());
431    }
432
433    #[rstest]
434    #[case("Off", LevelFilter::Off)]
435    #[case("Error", LevelFilter::Error)]
436    #[case("Warn", LevelFilter::Warn)]
437    #[case("Info", LevelFilter::Info)]
438    #[case("Debug", LevelFilter::Debug)]
439    #[case("Trace", LevelFilter::Trace)]
440    fn test_all_log_levels(#[case] level_str: &str, #[case] expected: LevelFilter) {
441        let config = LoggerConfig::from_spec(&format!("stdout={level_str}")).unwrap();
442        assert_eq!(config.stdout_level, expected);
443    }
444
445    #[rstest]
446    fn test_from_spec_single_module_path() {
447        let config = LoggerConfig::from_spec("nautilus_okx::websocket=Debug").unwrap();
448        assert_eq!(
449            config.module_level[&Ustr::from("nautilus_okx::websocket")],
450            LevelFilter::Debug
451        );
452        assert!(config.component_level.is_empty());
453    }
454
455    #[rstest]
456    fn test_from_spec_multiple_module_paths() {
457        let config =
458            LoggerConfig::from_spec("nautilus_okx::websocket=Debug;nautilus_binance::data=Trace")
459                .unwrap();
460        assert_eq!(
461            config.module_level[&Ustr::from("nautilus_okx::websocket")],
462            LevelFilter::Debug
463        );
464        assert_eq!(
465            config.module_level[&Ustr::from("nautilus_binance::data")],
466            LevelFilter::Trace
467        );
468        assert!(config.component_level.is_empty());
469    }
470
471    #[rstest]
472    fn test_from_spec_mixed_module_and_component() {
473        let config = LoggerConfig::from_spec(
474            "nautilus_okx::websocket=Debug;RiskEngine=Error;nautilus_network::data=Trace",
475        )
476        .unwrap();
477
478        assert_eq!(
479            config.module_level[&Ustr::from("nautilus_okx::websocket")],
480            LevelFilter::Debug
481        );
482        assert_eq!(
483            config.module_level[&Ustr::from("nautilus_network::data")],
484            LevelFilter::Trace
485        );
486        assert_eq!(config.module_level.len(), 2);
487        assert_eq!(
488            config.component_level[&Ustr::from("RiskEngine")],
489            LevelFilter::Error
490        );
491        assert_eq!(config.component_level.len(), 1);
492    }
493
494    #[rstest]
495    fn test_from_spec_deeply_nested_module_path() {
496        let config =
497            LoggerConfig::from_spec("nautilus_okx::websocket::handler::auth=Trace").unwrap();
498        assert_eq!(
499            config.module_level[&Ustr::from("nautilus_okx::websocket::handler::auth")],
500            LevelFilter::Trace
501        );
502    }
503
504    #[rstest]
505    fn test_from_spec_module_path_with_underscores() {
506        let config =
507            LoggerConfig::from_spec("nautilus_trader::adapters::interactive_brokers=Debug")
508                .unwrap();
509        assert_eq!(
510            config.module_level[&Ustr::from("nautilus_trader::adapters::interactive_brokers")],
511            LevelFilter::Debug
512        );
513    }
514
515    #[rstest]
516    fn test_from_spec_full_example_with_modules() {
517        let config = LoggerConfig::from_spec(
518            "stdout=Info;fileout=Debug;RiskEngine=Error;nautilus_okx::websocket=Trace;is_colored",
519        )
520        .unwrap();
521
522        assert_eq!(config.stdout_level, LevelFilter::Info);
523        assert_eq!(config.fileout_level, LevelFilter::Debug);
524        assert_eq!(
525            config.component_level[&Ustr::from("RiskEngine")],
526            LevelFilter::Error
527        );
528        assert_eq!(
529            config.module_level[&Ustr::from("nautilus_okx::websocket")],
530            LevelFilter::Trace
531        );
532        assert!(config.is_colored);
533    }
534
535    #[rstest]
536    fn test_from_spec_module_path_preserves_case() {
537        let config = LoggerConfig::from_spec("MyModule::SubModule=Info").unwrap();
538        assert!(
539            config
540                .module_level
541                .contains_key(&Ustr::from("MyModule::SubModule"))
542        );
543    }
544
545    #[rstest]
546    fn test_from_spec_single_colon_is_component() {
547        // Single colon is NOT a module path separator in Rust
548        let config = LoggerConfig::from_spec("Component:Name=Info").unwrap();
549        assert!(config.module_level.is_empty());
550        assert!(
551            config
552                .component_level
553                .contains_key(&Ustr::from("Component:Name"))
554        );
555    }
556
557    #[rstest]
558    fn test_default_module_level_is_empty() {
559        let config = LoggerConfig::default();
560        assert!(config.module_level.is_empty());
561    }
562}