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
64use super::writer::FileWriterConfig;
65
66/// Configuration for the Nautilus logger.
67#[cfg_attr(
68    feature = "python",
69    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.common", from_py_object)
70)]
71#[cfg_attr(
72    feature = "python",
73    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.common")
74)]
75#[derive(Debug, Clone, PartialEq, Eq, bon::Builder)]
76pub struct LoggerConfig {
77    /// Maximum log level for stdout output.
78    #[builder(default = LevelFilter::Info)]
79    pub stdout_level: LevelFilter,
80    /// Maximum log level for file output (`Off` disables file logging).
81    #[builder(default = LevelFilter::Off)]
82    pub fileout_level: LevelFilter,
83    /// Per-component log level overrides (exact match).
84    #[builder(default)]
85    pub component_level: AHashMap<Ustr, LevelFilter>,
86    /// Per-module path log level overrides (prefix match).
87    #[builder(default)]
88    pub module_level: AHashMap<Ustr, LevelFilter>,
89    /// Log only components with explicit level filters.
90    #[builder(default)]
91    pub log_components_only: bool,
92    /// Use ANSI color codes in output.
93    #[builder(default = true)]
94    pub is_colored: bool,
95    /// Print configuration to stdout at startup.
96    #[builder(default)]
97    pub print_config: bool,
98    /// Initialize the tracing subscriber for external Rust crate logs.
99    #[builder(default)]
100    pub use_tracing: bool,
101    /// If all logging should be bypassed.
102    #[builder(default)]
103    pub bypass_logging: bool,
104    /// File writer configuration for log file output.
105    pub file_config: Option<FileWriterConfig>,
106    /// If the log file should be cleared before use.
107    #[builder(default)]
108    pub clear_log_file: bool,
109}
110
111impl Default for LoggerConfig {
112    fn default() -> Self {
113        Self::builder().build()
114    }
115}
116
117impl LoggerConfig {
118    /// Creates a new [`LoggerConfig`] instance.
119    #[must_use]
120    #[expect(clippy::too_many_arguments)]
121    pub fn new(
122        stdout_level: LevelFilter,
123        fileout_level: LevelFilter,
124        component_level: AHashMap<Ustr, LevelFilter>,
125        module_level: AHashMap<Ustr, LevelFilter>,
126        log_components_only: bool,
127        is_colored: bool,
128        print_config: bool,
129        use_tracing: bool,
130        bypass_logging: bool,
131        file_config: Option<FileWriterConfig>,
132        clear_log_file: bool,
133    ) -> Self {
134        Self {
135            stdout_level,
136            fileout_level,
137            component_level,
138            module_level,
139            log_components_only,
140            is_colored,
141            print_config,
142            use_tracing,
143            bypass_logging,
144            file_config,
145            clear_log_file,
146        }
147    }
148
149    /// Parses a configuration from a spec string.
150    ///
151    /// # Format
152    ///
153    /// Semicolon-separated key-value pairs or bare flags:
154    /// ```text
155    /// stdout=Info;fileout=Debug;RiskEngine=Error;my_crate::module=Debug;is_colored
156    /// ```
157    ///
158    /// # Errors
159    ///
160    /// Returns an error if the spec string contains invalid syntax or log levels.
161    pub fn from_spec(spec: &str) -> anyhow::Result<Self> {
162        let mut config = Self::default();
163
164        for kv in spec.split(';') {
165            let kv = kv.trim();
166            if kv.is_empty() {
167                continue;
168            }
169
170            let kv_lower = kv.to_lowercase();
171
172            // Handle bare flags (without =)
173            if !kv.contains('=') {
174                match kv_lower.as_str() {
175                    "log_components_only" => config.log_components_only = true,
176                    "is_colored" => config.is_colored = true,
177                    "print_config" => config.print_config = true,
178                    "use_tracing" => config.use_tracing = true,
179                    "bypass_logging" => config.bypass_logging = true,
180                    _ => anyhow::bail!("Invalid spec pair: {kv}"),
181                }
182                continue;
183            }
184
185            let parts: Vec<&str> = kv.splitn(2, '=').collect();
186            if parts.len() != 2 {
187                anyhow::bail!("Invalid spec pair: {kv}");
188            }
189
190            let k = parts[0].trim();
191            let v = parts[1].trim();
192            let k_lower = k.to_lowercase();
193
194            match k_lower.as_str() {
195                "is_colored" => {
196                    config.is_colored = parse_bool_value(v);
197                }
198                "log_components_only" => {
199                    config.log_components_only = parse_bool_value(v);
200                }
201                "print_config" => {
202                    config.print_config = parse_bool_value(v);
203                }
204                "use_tracing" => {
205                    config.use_tracing = parse_bool_value(v);
206                }
207                "bypass_logging" => {
208                    config.bypass_logging = parse_bool_value(v);
209                }
210                "stdout" => {
211                    config.stdout_level = parse_level(v)?;
212                }
213                "fileout" => {
214                    config.fileout_level = parse_level(v)?;
215                }
216                _ => {
217                    let lvl = parse_level(v)?;
218
219                    if k.contains("::") {
220                        config.module_level.insert(Ustr::from(k), lvl);
221                    } else {
222                        config.component_level.insert(Ustr::from(k), lvl);
223                    }
224                }
225            }
226        }
227
228        Ok(config)
229    }
230
231    /// Parses configuration from the `NAUTILUS_LOG` environment variable.
232    ///
233    /// # Errors
234    ///
235    /// Returns an error if the variable is unset or contains invalid syntax.
236    pub fn from_env() -> anyhow::Result<Self> {
237        let spec = env::var("NAUTILUS_LOG")?;
238        Self::from_spec(&spec)
239    }
240}
241
242/// Parses a boolean value from a string.
243///
244/// Returns `true` unless the value is explicitly "false", "0", or "no" (case-insensitive).
245fn parse_bool_value(v: &str) -> bool {
246    !matches!(v.to_lowercase().as_str(), "false" | "0" | "no")
247}
248
249/// Parses a log level from a string.
250fn parse_level(v: &str) -> anyhow::Result<LevelFilter> {
251    LevelFilter::from_str(v).map_err(|_| anyhow::anyhow!("Invalid log level: {v}"))
252}
253
254#[cfg(test)]
255mod tests {
256    use rstest::rstest;
257
258    use super::*;
259
260    #[rstest]
261    fn test_default_config() {
262        let config = LoggerConfig::default();
263        assert_eq!(config.stdout_level, LevelFilter::Info);
264        assert_eq!(config.fileout_level, LevelFilter::Off);
265        assert!(config.component_level.is_empty());
266        assert!(!config.log_components_only);
267        assert!(config.is_colored);
268        assert!(!config.print_config);
269        assert!(!config.bypass_logging);
270        assert!(config.file_config.is_none());
271        assert!(!config.clear_log_file);
272    }
273
274    #[rstest]
275    fn test_from_spec_stdout_and_fileout() {
276        let config = LoggerConfig::from_spec("stdout=Debug;fileout=Error").unwrap();
277        assert_eq!(config.stdout_level, LevelFilter::Debug);
278        assert_eq!(config.fileout_level, LevelFilter::Error);
279    }
280
281    #[rstest]
282    fn test_from_spec_case_insensitive_levels() {
283        let config = LoggerConfig::from_spec("stdout=debug;fileout=ERROR").unwrap();
284        assert_eq!(config.stdout_level, LevelFilter::Debug);
285        assert_eq!(config.fileout_level, LevelFilter::Error);
286    }
287
288    #[rstest]
289    fn test_from_spec_case_insensitive_keys() {
290        let config = LoggerConfig::from_spec("STDOUT=Info;FILEOUT=Debug").unwrap();
291        assert_eq!(config.stdout_level, LevelFilter::Info);
292        assert_eq!(config.fileout_level, LevelFilter::Debug);
293    }
294
295    #[rstest]
296    fn test_from_spec_empty_string() {
297        let config = LoggerConfig::from_spec("").unwrap();
298        assert_eq!(config, LoggerConfig::default());
299    }
300
301    #[rstest]
302    fn test_from_spec_with_whitespace() {
303        let config = LoggerConfig::from_spec("  stdout = Info ; fileout = Debug  ").unwrap();
304        assert_eq!(config.stdout_level, LevelFilter::Info);
305        assert_eq!(config.fileout_level, LevelFilter::Debug);
306    }
307
308    #[rstest]
309    fn test_from_spec_trailing_semicolon() {
310        let config = LoggerConfig::from_spec("stdout=Warn;").unwrap();
311        assert_eq!(config.stdout_level, LevelFilter::Warn);
312    }
313
314    #[rstest]
315    fn test_from_spec_bare_is_colored() {
316        let config = LoggerConfig::from_spec("is_colored").unwrap();
317        assert!(config.is_colored);
318    }
319
320    #[rstest]
321    fn test_from_spec_is_colored_true() {
322        let config = LoggerConfig::from_spec("is_colored=true").unwrap();
323        assert!(config.is_colored);
324    }
325
326    #[rstest]
327    fn test_from_spec_is_colored_false() {
328        let config = LoggerConfig::from_spec("is_colored=false").unwrap();
329        assert!(!config.is_colored);
330    }
331
332    #[rstest]
333    fn test_from_spec_is_colored_zero() {
334        let config = LoggerConfig::from_spec("is_colored=0").unwrap();
335        assert!(!config.is_colored);
336    }
337
338    #[rstest]
339    fn test_from_spec_is_colored_no() {
340        let config = LoggerConfig::from_spec("is_colored=no").unwrap();
341        assert!(!config.is_colored);
342    }
343
344    #[rstest]
345    fn test_from_spec_is_colored_case_insensitive() {
346        let config = LoggerConfig::from_spec("IS_COLORED=FALSE").unwrap();
347        assert!(!config.is_colored);
348    }
349
350    #[rstest]
351    fn test_from_spec_print_config() {
352        let config = LoggerConfig::from_spec("print_config").unwrap();
353        assert!(config.print_config);
354    }
355
356    #[rstest]
357    fn test_from_spec_print_config_false() {
358        let config = LoggerConfig::from_spec("print_config=false").unwrap();
359        assert!(!config.print_config);
360    }
361
362    #[rstest]
363    fn test_from_spec_log_components_only() {
364        let config = LoggerConfig::from_spec("log_components_only").unwrap();
365        assert!(config.log_components_only);
366    }
367
368    #[rstest]
369    fn test_from_spec_log_components_only_false() {
370        let config = LoggerConfig::from_spec("log_components_only=false").unwrap();
371        assert!(!config.log_components_only);
372    }
373
374    #[rstest]
375    fn test_from_spec_component_level() {
376        let config = LoggerConfig::from_spec("RiskEngine=Error;DataEngine=Debug").unwrap();
377        assert_eq!(
378            config.component_level[&Ustr::from("RiskEngine")],
379            LevelFilter::Error
380        );
381        assert_eq!(
382            config.component_level[&Ustr::from("DataEngine")],
383            LevelFilter::Debug
384        );
385    }
386
387    #[rstest]
388    fn test_from_spec_component_preserves_case() {
389        // Component names should preserve their original case
390        let config = LoggerConfig::from_spec("MyComponent=Info").unwrap();
391        assert!(
392            config
393                .component_level
394                .contains_key(&Ustr::from("MyComponent"))
395        );
396        assert!(
397            !config
398                .component_level
399                .contains_key(&Ustr::from("mycomponent"))
400        );
401    }
402
403    #[rstest]
404    fn test_from_spec_full_example() {
405        let config = LoggerConfig::from_spec(
406            "stdout=Info;fileout=Debug;RiskEngine=Error;is_colored;print_config",
407        )
408        .unwrap();
409
410        assert_eq!(config.stdout_level, LevelFilter::Info);
411        assert_eq!(config.fileout_level, LevelFilter::Debug);
412        assert_eq!(
413            config.component_level[&Ustr::from("RiskEngine")],
414            LevelFilter::Error
415        );
416        assert!(config.is_colored);
417        assert!(config.print_config);
418    }
419
420    #[rstest]
421    fn test_from_spec_disabled_colors() {
422        let config = LoggerConfig::from_spec("stdout=Info;is_colored=false;fileout=Debug").unwrap();
423        assert!(!config.is_colored);
424        assert_eq!(config.stdout_level, LevelFilter::Info);
425        assert_eq!(config.fileout_level, LevelFilter::Debug);
426    }
427
428    #[rstest]
429    fn test_from_spec_invalid_level() {
430        let result = LoggerConfig::from_spec("stdout=InvalidLevel");
431        assert!(result.is_err());
432        assert!(
433            result
434                .unwrap_err()
435                .to_string()
436                .contains("Invalid log level")
437        );
438    }
439
440    #[rstest]
441    fn test_from_spec_invalid_bare_flag() {
442        let result = LoggerConfig::from_spec("unknown_flag");
443        assert!(result.is_err());
444        assert!(
445            result
446                .unwrap_err()
447                .to_string()
448                .contains("Invalid spec pair")
449        );
450    }
451
452    #[rstest]
453    fn test_from_spec_missing_value() {
454        // "stdout=" with no value is technically valid empty string, which is invalid level
455        let result = LoggerConfig::from_spec("stdout=");
456        assert!(result.is_err());
457    }
458
459    #[rstest]
460    #[case("Off", LevelFilter::Off)]
461    #[case("Error", LevelFilter::Error)]
462    #[case("Warn", LevelFilter::Warn)]
463    #[case("Info", LevelFilter::Info)]
464    #[case("Debug", LevelFilter::Debug)]
465    #[case("Trace", LevelFilter::Trace)]
466    fn test_all_log_levels(#[case] level_str: &str, #[case] expected: LevelFilter) {
467        let config = LoggerConfig::from_spec(&format!("stdout={level_str}")).unwrap();
468        assert_eq!(config.stdout_level, expected);
469    }
470
471    #[rstest]
472    fn test_from_spec_single_module_path() {
473        let config = LoggerConfig::from_spec("nautilus_okx::websocket=Debug").unwrap();
474        assert_eq!(
475            config.module_level[&Ustr::from("nautilus_okx::websocket")],
476            LevelFilter::Debug
477        );
478        assert!(config.component_level.is_empty());
479    }
480
481    #[rstest]
482    fn test_from_spec_multiple_module_paths() {
483        let config =
484            LoggerConfig::from_spec("nautilus_okx::websocket=Debug;nautilus_binance::data=Trace")
485                .unwrap();
486        assert_eq!(
487            config.module_level[&Ustr::from("nautilus_okx::websocket")],
488            LevelFilter::Debug
489        );
490        assert_eq!(
491            config.module_level[&Ustr::from("nautilus_binance::data")],
492            LevelFilter::Trace
493        );
494        assert!(config.component_level.is_empty());
495    }
496
497    #[rstest]
498    fn test_from_spec_mixed_module_and_component() {
499        let config = LoggerConfig::from_spec(
500            "nautilus_okx::websocket=Debug;RiskEngine=Error;nautilus_network::data=Trace",
501        )
502        .unwrap();
503
504        assert_eq!(
505            config.module_level[&Ustr::from("nautilus_okx::websocket")],
506            LevelFilter::Debug
507        );
508        assert_eq!(
509            config.module_level[&Ustr::from("nautilus_network::data")],
510            LevelFilter::Trace
511        );
512        assert_eq!(config.module_level.len(), 2);
513        assert_eq!(
514            config.component_level[&Ustr::from("RiskEngine")],
515            LevelFilter::Error
516        );
517        assert_eq!(config.component_level.len(), 1);
518    }
519
520    #[rstest]
521    fn test_from_spec_deeply_nested_module_path() {
522        let config =
523            LoggerConfig::from_spec("nautilus_okx::websocket::handler::auth=Trace").unwrap();
524        assert_eq!(
525            config.module_level[&Ustr::from("nautilus_okx::websocket::handler::auth")],
526            LevelFilter::Trace
527        );
528    }
529
530    #[rstest]
531    fn test_from_spec_module_path_with_underscores() {
532        let config =
533            LoggerConfig::from_spec("nautilus_trader::adapters::interactive_brokers=Debug")
534                .unwrap();
535        assert_eq!(
536            config.module_level[&Ustr::from("nautilus_trader::adapters::interactive_brokers")],
537            LevelFilter::Debug
538        );
539    }
540
541    #[rstest]
542    fn test_from_spec_full_example_with_modules() {
543        let config = LoggerConfig::from_spec(
544            "stdout=Info;fileout=Debug;RiskEngine=Error;nautilus_okx::websocket=Trace;is_colored",
545        )
546        .unwrap();
547
548        assert_eq!(config.stdout_level, LevelFilter::Info);
549        assert_eq!(config.fileout_level, LevelFilter::Debug);
550        assert_eq!(
551            config.component_level[&Ustr::from("RiskEngine")],
552            LevelFilter::Error
553        );
554        assert_eq!(
555            config.module_level[&Ustr::from("nautilus_okx::websocket")],
556            LevelFilter::Trace
557        );
558        assert!(config.is_colored);
559    }
560
561    #[rstest]
562    fn test_from_spec_module_path_preserves_case() {
563        let config = LoggerConfig::from_spec("MyModule::SubModule=Info").unwrap();
564        assert!(
565            config
566                .module_level
567                .contains_key(&Ustr::from("MyModule::SubModule"))
568        );
569    }
570
571    #[rstest]
572    fn test_from_spec_single_colon_is_component() {
573        // Single colon is NOT a module path separator in Rust
574        let config = LoggerConfig::from_spec("Component:Name=Info").unwrap();
575        assert!(config.module_level.is_empty());
576        assert!(
577            config
578                .component_level
579                .contains_key(&Ustr::from("Component:Name"))
580        );
581    }
582
583    #[rstest]
584    fn test_default_module_level_is_empty() {
585        let config = LoggerConfig::default();
586        assert!(config.module_level.is_empty());
587    }
588
589    #[rstest]
590    fn test_from_spec_bypass_logging_bare() {
591        let config = LoggerConfig::from_spec("bypass_logging").unwrap();
592        assert!(config.bypass_logging);
593    }
594
595    #[rstest]
596    fn test_from_spec_bypass_logging_true() {
597        let config = LoggerConfig::from_spec("bypass_logging=true").unwrap();
598        assert!(config.bypass_logging);
599    }
600
601    #[rstest]
602    fn test_from_spec_bypass_logging_false() {
603        let config = LoggerConfig::from_spec("bypass_logging=false").unwrap();
604        assert!(!config.bypass_logging);
605    }
606}