Skip to main content

nautilus_common/python/
logging.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
16use std::collections::HashMap;
17
18use ahash::AHashMap;
19use log::LevelFilter;
20use nautilus_core::{UUID4, python::to_pyvalue_err};
21use nautilus_model::identifiers::TraderId;
22use pyo3::prelude::*;
23use ustr::Ustr;
24
25use crate::{
26    enums::{LogColor, LogLevel},
27    logging::{
28        self, headers,
29        logger::{self, LogGuard, LoggerConfig},
30        logging_clock_set_realtime_mode, logging_clock_set_static_mode,
31        logging_clock_set_static_time, logging_set_bypass, map_log_level_to_filter,
32        parse_level_filter_str,
33        writer::FileWriterConfig,
34    },
35    python::config_error_to_pyvalue_err,
36};
37
38#[pyo3_stub_gen::derive::gen_stub_pymethods]
39#[pymethods]
40impl LoggerConfig {
41    /// Configuration for the Nautilus logger.
42    #[new]
43    #[pyo3(signature = (
44        stdout_level=None,
45        fileout_level=None,
46        component_levels=None,
47        is_colored=None,
48        print_config=None,
49        bypass_logging=None,
50        log_components_only=None,
51        file_config=None,
52        clear_log_file=None,
53        fileout_sync_on_flush=None,
54        buffered_stdout=None,
55    ))]
56    #[expect(
57        clippy::too_many_arguments,
58        reason = "PyO3 constructor mirrors LoggerConfig keyword arguments"
59    )]
60    fn py_new(
61        stdout_level: Option<LogLevel>,
62        fileout_level: Option<LogLevel>,
63        component_levels: Option<std::collections::HashMap<String, String>>,
64        is_colored: Option<bool>,
65        print_config: Option<bool>,
66        bypass_logging: Option<bool>,
67        log_components_only: Option<bool>,
68        file_config: Option<FileWriterConfig>,
69        clear_log_file: Option<bool>,
70        fileout_sync_on_flush: Option<bool>,
71        buffered_stdout: Option<bool>,
72    ) -> PyResult<Self> {
73        let component_levels = parse_component_levels(component_levels).map_err(to_pyvalue_err)?;
74        let mut config = Self::new(
75            stdout_level.map_or(LevelFilter::Info, map_log_level_to_filter),
76            fileout_level.map_or(LevelFilter::Off, map_log_level_to_filter),
77            component_levels,
78            AHashMap::new(),
79            log_components_only.unwrap_or(false),
80            is_colored.unwrap_or(true),
81            print_config.unwrap_or(false),
82            false,
83            bypass_logging.unwrap_or(false),
84            file_config,
85            clear_log_file.unwrap_or(false),
86        );
87        config.fileout_sync_on_flush = fileout_sync_on_flush.unwrap_or(true);
88        config.buffered_stdout = buffered_stdout.unwrap_or(false);
89        config.validate().map_err(config_error_to_pyvalue_err)?;
90        Ok(config)
91    }
92
93    #[getter]
94    #[pyo3(name = "stdout_level")]
95    fn py_stdout_level(&self) -> LogLevel {
96        level_filter_to_log_level(self.stdout_level)
97    }
98
99    #[getter]
100    #[pyo3(name = "fileout_level")]
101    fn py_fileout_level(&self) -> LogLevel {
102        level_filter_to_log_level(self.fileout_level)
103    }
104
105    #[getter]
106    #[pyo3(name = "component_levels")]
107    fn py_component_levels(&self) -> HashMap<String, String> {
108        self.component_level
109            .iter()
110            .map(|(component, level)| (component.to_string(), level.to_string()))
111            .collect()
112    }
113
114    #[getter]
115    #[pyo3(name = "is_colored")]
116    const fn py_is_colored(&self) -> bool {
117        self.is_colored
118    }
119
120    #[getter]
121    #[pyo3(name = "print_config")]
122    const fn py_print_config(&self) -> bool {
123        self.print_config
124    }
125
126    #[getter]
127    #[pyo3(name = "bypass_logging")]
128    const fn py_bypass_logging(&self) -> bool {
129        self.bypass_logging
130    }
131
132    #[getter]
133    #[pyo3(name = "log_components_only")]
134    const fn py_log_components_only(&self) -> bool {
135        self.log_components_only
136    }
137
138    #[getter]
139    #[pyo3(name = "file_config")]
140    fn py_file_config(&self) -> Option<FileWriterConfig> {
141        self.file_config.clone()
142    }
143
144    #[getter]
145    #[pyo3(name = "clear_log_file")]
146    const fn py_clear_log_file(&self) -> bool {
147        self.clear_log_file
148    }
149
150    #[getter]
151    #[pyo3(name = "fileout_sync_on_flush")]
152    const fn py_fileout_sync_on_flush(&self) -> bool {
153        self.fileout_sync_on_flush
154    }
155
156    #[getter]
157    #[pyo3(name = "buffered_stdout")]
158    const fn py_buffered_stdout(&self) -> bool {
159        self.buffered_stdout
160    }
161
162    /// Parses a configuration from a spec string.
163    ///
164    /// # Format
165    ///
166    /// Semicolon-separated key-value pairs or bare flags:
167    /// ```text
168    /// stdout=Info;fileout=Debug;RiskEngine=Error;my_crate::module=Debug;is_colored
169    /// ```
170    ///
171    /// # Errors
172    ///
173    /// Returns an error if the spec string contains invalid syntax or log levels.
174    #[staticmethod]
175    #[pyo3(name = "from_spec")]
176    pub fn py_from_spec(spec: &str) -> PyResult<Self> {
177        Self::from_spec(spec).map_err(to_pyvalue_err)
178    }
179}
180
181#[pyo3_stub_gen::derive::gen_stub_pymethods]
182#[pymethods]
183impl FileWriterConfig {
184    /// Configures file log output.
185    #[new]
186    #[pyo3(signature = (directory=None, file_name=None, file_format=None, file_rotate=None))]
187    #[must_use]
188    pub fn py_new(
189        directory: Option<String>,
190        file_name: Option<String>,
191        file_format: Option<String>,
192        file_rotate: Option<(u64, u32)>,
193    ) -> Self {
194        Self::new(directory, file_name, file_format, file_rotate)
195    }
196
197    #[getter]
198    #[pyo3(name = "directory")]
199    fn py_directory(&self) -> Option<&str> {
200        self.directory.as_deref()
201    }
202
203    #[getter]
204    #[pyo3(name = "file_name")]
205    fn py_file_name(&self) -> Option<&str> {
206        self.file_name.as_deref()
207    }
208
209    #[getter]
210    #[pyo3(name = "file_format")]
211    fn py_file_format(&self) -> Option<&str> {
212        self.file_format.as_deref()
213    }
214
215    #[getter]
216    #[pyo3(name = "file_rotate")]
217    fn py_file_rotate(&self) -> Option<(u64, u32)> {
218        self.file_rotate
219            .as_ref()
220            .map(|rotate| (rotate.max_file_size, rotate.max_backup_count))
221    }
222}
223
224/// Initialize logging.
225///
226/// Logging should be used for Python and sync Rust logic which is most of
227/// the components in the [nautilus_trader](https://pypi.org/project/nautilus_trader) package.
228/// Logging can be configured to filter components and write up to a specific level only
229/// by passing a configuration using the `NAUTILUS_LOG` environment variable.
230///
231/// Should only be called once during an applications run, ideally at the
232/// beginning of the run.
233///
234/// # Errors
235///
236/// Returns an error if the logging subsystem fails to initialize.
237#[pyfunction]
238#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.common")]
239#[pyo3(name = "init_logging")]
240#[expect(clippy::too_many_arguments)]
241#[pyo3(signature = (trader_id, instance_id, level_stdout, level_file=None, component_levels=None, directory=None, file_name=None, file_format=None, file_rotate=None, is_colored=None, is_bypassed=None, print_config=None, log_components_only=None, fileout_sync_on_flush=None, buffered_stdout=None))]
242pub fn py_init_logging(
243    trader_id: TraderId,
244    instance_id: UUID4,
245    level_stdout: LogLevel,
246    level_file: Option<LogLevel>,
247    component_levels: Option<std::collections::HashMap<String, String>>,
248    directory: Option<String>,
249    file_name: Option<String>,
250    file_format: Option<String>,
251    file_rotate: Option<(u64, u32)>,
252    is_colored: Option<bool>,
253    is_bypassed: Option<bool>,
254    print_config: Option<bool>,
255    log_components_only: Option<bool>,
256    fileout_sync_on_flush: Option<bool>,
257    buffered_stdout: Option<bool>,
258) -> PyResult<LogGuard> {
259    let level_file = level_file.map_or(LevelFilter::Off, map_log_level_to_filter);
260
261    let component_levels = parse_component_levels(component_levels).map_err(to_pyvalue_err)?;
262
263    let file_config = FileWriterConfig::new(directory, file_name, file_format, file_rotate);
264    file_config
265        .validate()
266        .map_err(config_error_to_pyvalue_err)?;
267
268    let mut config = LoggerConfig::new(
269        map_log_level_to_filter(level_stdout),
270        level_file,
271        component_levels,
272        AHashMap::new(), // module_level - not exposed to Python
273        log_components_only.unwrap_or(false),
274        is_colored.unwrap_or(true),
275        print_config.unwrap_or(false),
276        false,                        // use_tracing - Python handles this separately in kernel
277        is_bypassed.unwrap_or(false), // bypass_logging
278        None,                         // file_config - passed separately to init_logging
279        false,                        // clear_log_file
280    );
281    config.fileout_sync_on_flush = fileout_sync_on_flush.unwrap_or(true);
282    config.buffered_stdout = buffered_stdout.unwrap_or(false);
283
284    let is_bypassed = config.bypass_logging;
285    let guard = logging::init_logging(trader_id, instance_id, config, file_config)
286        .map_err(to_pyvalue_err)?;
287
288    // Set after init succeeds so a failed attempt leaves no global bypass behind
289    if is_bypassed {
290        logging_set_bypass();
291    }
292
293    Ok(guard)
294}
295
296#[pyfunction()]
297#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.common")]
298#[pyo3(name = "logger_flush")]
299pub fn py_logger_flush() {
300    log::logger().flush();
301}
302
303/// Flushes and syncs file logs to disk.
304///
305/// This is a no-op when logging is not initialized or file logging is disabled.
306///
307/// # Errors
308///
309/// Returns an error if the sync request cannot be delivered or acknowledged.
310#[pyfunction()]
311#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.common")]
312#[pyo3(name = "logging_sync_to_disk")]
313pub fn py_logging_sync_to_disk() -> PyResult<bool> {
314    logging::logging_sync_to_disk()
315        .map(|()| true)
316        .map_err(to_pyvalue_err)
317}
318
319fn parse_component_levels(
320    original_map: Option<std::collections::HashMap<String, String>>,
321) -> anyhow::Result<AHashMap<Ustr, LevelFilter>> {
322    match original_map {
323        Some(map) => {
324            let mut new_map = AHashMap::new();
325
326            for (key, value) in map {
327                let ustr_key = Ustr::from(&key);
328                let level = parse_level_filter_str(&value)?;
329                new_map.insert(ustr_key, level);
330            }
331            Ok(new_map)
332        }
333        None => Ok(AHashMap::new()),
334    }
335}
336
337const fn level_filter_to_log_level(level: LevelFilter) -> LogLevel {
338    match level {
339        LevelFilter::Off => LogLevel::Off,
340        LevelFilter::Error => LogLevel::Error,
341        LevelFilter::Warn => LogLevel::Warning,
342        LevelFilter::Info => LogLevel::Info,
343        LevelFilter::Debug => LogLevel::Debug,
344        LevelFilter::Trace => LogLevel::Trace,
345    }
346}
347
348/// Create a new log event.
349#[pyfunction]
350#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.common")]
351#[pyo3(name = "logger_log")]
352pub fn py_logger_log(level: LogLevel, color: LogColor, component: &str, message: &str) {
353    logger::log(level, color, Ustr::from(component), message);
354}
355
356/// Logs the Nautilus startup header with system, identifier, and version details.
357#[pyfunction]
358#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.common")]
359#[pyo3(name = "log_header")]
360pub fn py_log_header(trader_id: TraderId, machine_id: &str, instance_id: UUID4, component: &str) {
361    headers::log_header(trader_id, machine_id, instance_id, Ustr::from(component));
362}
363
364/// Logs current memory and swap usage.
365#[pyfunction]
366#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.common")]
367#[pyo3(name = "log_sysinfo")]
368pub fn py_log_sysinfo(component: &str) {
369    headers::log_sysinfo(Ustr::from(component));
370}
371
372/// Sets the global logging clock to static mode.
373#[pyfunction]
374#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.common")]
375#[pyo3(name = "logging_clock_set_static_mode")]
376pub fn py_logging_clock_set_static_mode() {
377    logging_clock_set_static_mode();
378}
379
380/// Sets the global logging clock to real-time mode.
381#[pyfunction]
382#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.common")]
383#[pyo3(name = "logging_clock_set_realtime_mode")]
384pub fn py_logging_clock_set_realtime_mode() {
385    logging_clock_set_realtime_mode();
386}
387
388/// Sets the global logging clock static time with the given UNIX timestamp (nanoseconds).
389#[pyfunction]
390#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.common")]
391#[pyo3(name = "logging_clock_set_static_time")]
392pub fn py_logging_clock_set_static_time(time_ns: u64) {
393    logging_clock_set_static_time(time_ns);
394}
395
396/// Returns whether the tracing subscriber has been initialized.
397#[cfg(feature = "tracing-bridge")]
398#[pyfunction]
399#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.common")]
400#[pyo3(name = "tracing_is_initialized")]
401#[must_use]
402pub fn py_tracing_is_initialized() -> bool {
403    crate::logging::bridge::tracing_is_initialized()
404}
405
406/// Initializes a tracing subscriber for external Rust crate logging.
407///
408/// This sets up a standard tracing subscriber that outputs to stdout with
409/// the format controlled by `RUST_LOG` environment variable. The output
410/// format uses nanosecond timestamps to align with Nautilus logging.
411///
412/// # Environment Variables
413///
414/// - `RUST_LOG`: Controls which modules emit tracing events and at what level.
415///   - Example: `RUST_LOG=hyper=debug,tokio=warn`.
416///   - Default: `warn` (if not set).
417///
418/// # Errors
419///
420/// Returns an error if the tracing subscriber has already been initialized.
421#[cfg(feature = "tracing-bridge")]
422#[pyfunction]
423#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.common")]
424#[pyo3(name = "init_tracing")]
425pub fn py_init_tracing() -> PyResult<()> {
426    crate::logging::bridge::init_tracing().map_err(to_pyvalue_err)
427}
428
429/// Python wrapper around the global Rust logger.
430///
431/// It mirrors the familiar Python `logging` interface while forwarding
432/// all records through the Nautilus logging infrastructure so that log levels
433/// and formatting remain consistent across Rust and Python.
434#[pyclass(
435    module = "nautilus_trader.common",
436    name = "Logger",
437    unsendable,
438    from_py_object
439)]
440#[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.common")]
441#[derive(Debug, Clone)]
442pub struct PyLogger {
443    name: Ustr,
444}
445
446impl PyLogger {
447    pub fn new(name: &str) -> Self {
448        Self {
449            name: Ustr::from(name),
450        }
451    }
452
453    /// Logs a failed Python callback with its traceback and component identity.
454    pub fn log_callback_error(&self, method: &str, result: PyResult<()>) {
455        if let Err(e) = result {
456            let exception = format_exception(&e);
457            self.log_message(
458                LogLevel::Error,
459                Some(LogColor::Red),
460                &format!("Python {method} failed:\n{exception}"),
461            );
462        }
463    }
464
465    fn log_message(&self, level: LogLevel, color: Option<LogColor>, message: &str) {
466        let color = color.unwrap_or(LogColor::Normal);
467        logger::log(level, color, self.name, message);
468    }
469}
470
471#[pymethods]
472#[pyo3_stub_gen::derive::gen_stub_pymethods]
473impl PyLogger {
474    /// Create a new `Logger` instance.
475    #[new]
476    #[pyo3(signature = (name="Python"))]
477    fn py_new(name: &str) -> Self {
478        Self::new(name)
479    }
480
481    /// The component identifier carried by this logger.
482    #[getter]
483    fn name(&self) -> &str {
484        &self.name
485    }
486
487    /// Emit a TRACE level record.
488    #[pyo3(name = "trace")]
489    #[pyo3(signature = (message, color=None))]
490    fn py_trace(&self, message: &str, color: Option<LogColor>) {
491        self.log_message(LogLevel::Trace, color, message);
492    }
493
494    /// Emit a DEBUG level record.
495    #[pyo3(name = "debug")]
496    #[pyo3(signature = (message, color=None))]
497    fn py_debug(&self, message: &str, color: Option<LogColor>) {
498        self.log_message(LogLevel::Debug, color, message);
499    }
500
501    /// Emit an INFO level record.
502    #[pyo3(name = "info")]
503    #[pyo3(signature = (message, color=None))]
504    fn py_info(&self, message: &str, color: Option<LogColor>) {
505        self.log_message(LogLevel::Info, color, message);
506    }
507
508    /// Emit a WARNING level record.
509    #[pyo3(name = "warning")]
510    #[pyo3(signature = (message, color=None))]
511    fn py_warning(&self, message: &str, color: Option<LogColor>) {
512        self.log_message(LogLevel::Warning, color, message);
513    }
514
515    /// Emit an ERROR level record.
516    #[pyo3(name = "error")]
517    #[pyo3(signature = (message, color=None))]
518    fn py_error(&self, message: &str, color: Option<LogColor>) {
519        self.log_message(LogLevel::Error, color, message);
520    }
521
522    /// Emit an ERROR level record with the active Python exception info.
523    #[pyo3(name = "exception")]
524    #[pyo3(signature = (message="", color=None))]
525    fn py_exception(&self, py: Python, message: &str, color: Option<LogColor>) {
526        let mut full_msg = message.to_owned();
527
528        if pyo3::PyErr::occurred(py) {
529            let err = PyErr::fetch(py);
530            let err_str = err.to_string();
531
532            if full_msg.is_empty() {
533                full_msg = err_str;
534            } else {
535                full_msg = format!("{full_msg}: {err_str}");
536            }
537        }
538
539        self.log_message(LogLevel::Error, color, &full_msg);
540    }
541
542    /// Flush buffered log records.
543    #[pyo3(name = "flush")]
544    fn py_flush(&self) {
545        log::logger().flush();
546    }
547
548    /// Emits a log record at the given level for Python callers.
549    #[pyo3(name = "_log")]
550    #[pyo3(signature = (level, color=None, message=""))]
551    fn py_log(&self, level: LogLevel, color: Option<LogColor>, message: &str) {
552        self.log_message(level, color, message);
553    }
554}
555
556/// Formats a Python exception, including its traceback and chained exceptions.
557///
558/// Falls back to the exception type and message if traceback formatting fails.
559#[must_use]
560pub fn format_exception(e: &PyErr) -> String {
561    Python::attach(|py| {
562        py.import("traceback")
563            .and_then(|module| {
564                module.call_method1(
565                    "format_exception",
566                    (e.get_type(py), e.value(py), e.traceback(py)),
567                )
568            })
569            .and_then(|lines| lines.extract::<Vec<String>>())
570            .map_or_else(|_| e.to_string(), |lines| lines.concat())
571    })
572}
573
574#[cfg(test)]
575mod tests {
576    use nautilus_core::python::to_pyruntime_err;
577    use pyo3::ffi::c_str;
578    use rstest::rstest;
579
580    use super::*;
581
582    // Uses the process-global logger, so it relies on nextest running each test in its own process
583    #[rstest]
584    fn test_init_logging_failure_leaves_no_bypass_for_retry() {
585        let temp_dir = tempfile::tempdir().unwrap();
586        let blocking_file = temp_dir.path().join("not_a_directory");
587        std::fs::write(&blocking_file, "").unwrap();
588
589        let init = |directory: &std::path::Path, is_bypassed: bool| {
590            py_init_logging(
591                TraderId::from("TRADER-001"),
592                UUID4::new(),
593                LogLevel::Off,
594                Some(LogLevel::Info),
595                None,
596                Some(directory.to_str().unwrap().to_string()),
597                None,
598                None,
599                None,
600                None,
601                Some(is_bypassed),
602                None,
603                None,
604                None,
605                None,
606            )
607        };
608
609        let failed = init(&blocking_file, true);
610        let guard = init(temp_dir.path(), false).unwrap();
611        log::info!(component = "BypassTest"; "logged after retry");
612        crate::logging::logging_sync_to_disk().unwrap();
613
614        assert!(failed.is_err());
615        let log_path = std::fs::read_dir(temp_dir.path())
616            .unwrap()
617            .map(|entry| entry.unwrap().path())
618            .find(|path| path.extension().is_some_and(|extension| extension == "log"))
619            .unwrap();
620        let contents = std::fs::read_to_string(log_path).unwrap();
621        assert!(
622            contents.contains("logged after retry"),
623            "log file: {contents:?}"
624        );
625        drop(guard);
626    }
627
628    #[rstest]
629    fn test_format_exception_traceback_and_cause() {
630        Python::initialize();
631        Python::attach(|py| {
632            let module = PyModule::from_code(
633                py,
634                c_str!(
635                    r#"
636def callback():
637    try:
638        fail()
639    except ValueError as e:
640        raise RuntimeError("callback failure") from e
641
642def fail():
643    raise ValueError("original failure")
644"#
645                ),
646                c_str!("strategy_callback.py"),
647                c_str!("strategy_callback"),
648            )
649            .unwrap();
650            let e = module.call_method0("callback").unwrap_err();
651            let formatted = format_exception(&e);
652
653            assert!(formatted.contains("File \"strategy_callback.py\", line 9, in fail"));
654            assert!(formatted.contains("File \"strategy_callback.py\", line 6, in callback"));
655            assert!(formatted.contains("ValueError: original failure"));
656            assert!(formatted.contains("The above exception was the direct cause"));
657            assert!(formatted.ends_with("RuntimeError: callback failure\n"));
658        });
659    }
660
661    #[rstest]
662    fn test_format_exception_without_traceback() {
663        Python::initialize();
664        let e = to_pyruntime_err("callback failure");
665        assert_eq!(format_exception(&e), "RuntimeError: callback failure\n");
666    }
667
668    #[rstest]
669    fn test_format_exception_fallback() {
670        Python::initialize();
671        Python::attach(|py| {
672            let traceback = py.import("traceback").unwrap();
673            let original = traceback.getattr("format_exception").unwrap();
674            traceback.setattr("format_exception", py.None()).unwrap();
675            let e = to_pyruntime_err("callback failure");
676            let formatted = format_exception(&e);
677            traceback.setattr("format_exception", original).unwrap();
678
679            assert_eq!(formatted, "RuntimeError: callback failure");
680        });
681    }
682}