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    if config.bypass_logging {
285        logging_set_bypass();
286    }
287
288    logging::init_logging(trader_id, instance_id, config, file_config).map_err(to_pyvalue_err)
289}
290
291#[pyfunction()]
292#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.common")]
293#[pyo3(name = "logger_flush")]
294pub fn py_logger_flush() {
295    log::logger().flush();
296}
297
298/// Flushes and syncs file logs to disk.
299///
300/// This is a no-op when logging is not initialized or file logging is disabled.
301///
302/// # Errors
303///
304/// Returns an error if the sync request cannot be delivered or acknowledged.
305#[pyfunction()]
306#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.common")]
307#[pyo3(name = "logging_sync_to_disk")]
308pub fn py_logging_sync_to_disk() -> PyResult<bool> {
309    logging::logging_sync_to_disk()
310        .map(|()| true)
311        .map_err(to_pyvalue_err)
312}
313
314fn parse_component_levels(
315    original_map: Option<std::collections::HashMap<String, String>>,
316) -> anyhow::Result<AHashMap<Ustr, LevelFilter>> {
317    match original_map {
318        Some(map) => {
319            let mut new_map = AHashMap::new();
320
321            for (key, value) in map {
322                let ustr_key = Ustr::from(&key);
323                let level = parse_level_filter_str(&value)?;
324                new_map.insert(ustr_key, level);
325            }
326            Ok(new_map)
327        }
328        None => Ok(AHashMap::new()),
329    }
330}
331
332const fn level_filter_to_log_level(level: LevelFilter) -> LogLevel {
333    match level {
334        LevelFilter::Off => LogLevel::Off,
335        LevelFilter::Error => LogLevel::Error,
336        LevelFilter::Warn => LogLevel::Warning,
337        LevelFilter::Info => LogLevel::Info,
338        LevelFilter::Debug => LogLevel::Debug,
339        LevelFilter::Trace => LogLevel::Trace,
340    }
341}
342
343/// Create a new log event.
344#[pyfunction]
345#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.common")]
346#[pyo3(name = "logger_log")]
347pub fn py_logger_log(level: LogLevel, color: LogColor, component: &str, message: &str) {
348    logger::log(level, color, Ustr::from(component), message);
349}
350
351/// Logs the Nautilus startup header with system, identifier, and version details.
352#[pyfunction]
353#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.common")]
354#[pyo3(name = "log_header")]
355pub fn py_log_header(trader_id: TraderId, machine_id: &str, instance_id: UUID4, component: &str) {
356    headers::log_header(trader_id, machine_id, instance_id, Ustr::from(component));
357}
358
359/// Logs current memory and swap usage.
360#[pyfunction]
361#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.common")]
362#[pyo3(name = "log_sysinfo")]
363pub fn py_log_sysinfo(component: &str) {
364    headers::log_sysinfo(Ustr::from(component));
365}
366
367/// Sets the global logging clock to static mode.
368#[pyfunction]
369#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.common")]
370#[pyo3(name = "logging_clock_set_static_mode")]
371pub fn py_logging_clock_set_static_mode() {
372    logging_clock_set_static_mode();
373}
374
375/// Sets the global logging clock to real-time mode.
376#[pyfunction]
377#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.common")]
378#[pyo3(name = "logging_clock_set_realtime_mode")]
379pub fn py_logging_clock_set_realtime_mode() {
380    logging_clock_set_realtime_mode();
381}
382
383/// Sets the global logging clock static time with the given UNIX timestamp (nanoseconds).
384#[pyfunction]
385#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.common")]
386#[pyo3(name = "logging_clock_set_static_time")]
387pub fn py_logging_clock_set_static_time(time_ns: u64) {
388    logging_clock_set_static_time(time_ns);
389}
390
391/// Returns whether the tracing subscriber has been initialized.
392#[cfg(feature = "tracing-bridge")]
393#[pyfunction]
394#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.common")]
395#[pyo3(name = "tracing_is_initialized")]
396#[must_use]
397pub fn py_tracing_is_initialized() -> bool {
398    crate::logging::bridge::tracing_is_initialized()
399}
400
401/// Initializes a tracing subscriber for external Rust crate logging.
402///
403/// This sets up a standard tracing subscriber that outputs to stdout with
404/// the format controlled by `RUST_LOG` environment variable. The output
405/// format uses nanosecond timestamps to align with Nautilus logging.
406///
407/// # Environment Variables
408///
409/// - `RUST_LOG`: Controls which modules emit tracing events and at what level.
410///   - Example: `RUST_LOG=hyper=debug,tokio=warn`.
411///   - Default: `warn` (if not set).
412///
413/// # Errors
414///
415/// Returns an error if the tracing subscriber has already been initialized.
416#[cfg(feature = "tracing-bridge")]
417#[pyfunction]
418#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.common")]
419#[pyo3(name = "init_tracing")]
420pub fn py_init_tracing() -> PyResult<()> {
421    crate::logging::bridge::init_tracing().map_err(to_pyvalue_err)
422}
423
424/// Python wrapper around the global Rust logger.
425///
426/// It mirrors the familiar Python `logging` interface while forwarding
427/// all records through the Nautilus logging infrastructure so that log levels
428/// and formatting remain consistent across Rust and Python.
429#[pyclass(
430    module = "nautilus_trader.common",
431    name = "Logger",
432    unsendable,
433    from_py_object
434)]
435#[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.common")]
436#[derive(Debug, Clone)]
437pub struct PyLogger {
438    name: Ustr,
439}
440
441impl PyLogger {
442    pub fn new(name: &str) -> Self {
443        Self {
444            name: Ustr::from(name),
445        }
446    }
447
448    /// Logs a failed Python callback with its traceback and component identity.
449    pub fn log_callback_error(&self, method: &str, result: PyResult<()>) {
450        if let Err(e) = result {
451            let exception = format_exception(&e);
452            self.log_message(
453                LogLevel::Error,
454                Some(LogColor::Red),
455                &format!("Python {method} failed:\n{exception}"),
456            );
457        }
458    }
459
460    fn log_message(&self, level: LogLevel, color: Option<LogColor>, message: &str) {
461        let color = color.unwrap_or(LogColor::Normal);
462        logger::log(level, color, self.name, message);
463    }
464}
465
466#[pymethods]
467#[pyo3_stub_gen::derive::gen_stub_pymethods]
468impl PyLogger {
469    /// Create a new `Logger` instance.
470    #[new]
471    #[pyo3(signature = (name="Python"))]
472    fn py_new(name: &str) -> Self {
473        Self::new(name)
474    }
475
476    /// The component identifier carried by this logger.
477    #[getter]
478    fn name(&self) -> &str {
479        &self.name
480    }
481
482    /// Emit a TRACE level record.
483    #[pyo3(name = "trace")]
484    #[pyo3(signature = (message, color=None))]
485    fn py_trace(&self, message: &str, color: Option<LogColor>) {
486        self.log_message(LogLevel::Trace, color, message);
487    }
488
489    /// Emit a DEBUG level record.
490    #[pyo3(name = "debug")]
491    #[pyo3(signature = (message, color=None))]
492    fn py_debug(&self, message: &str, color: Option<LogColor>) {
493        self.log_message(LogLevel::Debug, color, message);
494    }
495
496    /// Emit an INFO level record.
497    #[pyo3(name = "info")]
498    #[pyo3(signature = (message, color=None))]
499    fn py_info(&self, message: &str, color: Option<LogColor>) {
500        self.log_message(LogLevel::Info, color, message);
501    }
502
503    /// Emit a WARNING level record.
504    #[pyo3(name = "warning")]
505    #[pyo3(signature = (message, color=None))]
506    fn py_warning(&self, message: &str, color: Option<LogColor>) {
507        self.log_message(LogLevel::Warning, color, message);
508    }
509
510    /// Emit an ERROR level record.
511    #[pyo3(name = "error")]
512    #[pyo3(signature = (message, color=None))]
513    fn py_error(&self, message: &str, color: Option<LogColor>) {
514        self.log_message(LogLevel::Error, color, message);
515    }
516
517    /// Emit an ERROR level record with the active Python exception info.
518    #[pyo3(name = "exception")]
519    #[pyo3(signature = (message="", color=None))]
520    fn py_exception(&self, py: Python, message: &str, color: Option<LogColor>) {
521        let mut full_msg = message.to_owned();
522
523        if pyo3::PyErr::occurred(py) {
524            let err = PyErr::fetch(py);
525            let err_str = err.to_string();
526
527            if full_msg.is_empty() {
528                full_msg = err_str;
529            } else {
530                full_msg = format!("{full_msg}: {err_str}");
531            }
532        }
533
534        self.log_message(LogLevel::Error, color, &full_msg);
535    }
536
537    /// Flush buffered log records.
538    #[pyo3(name = "flush")]
539    fn py_flush(&self) {
540        log::logger().flush();
541    }
542
543    /// Emits a log record at the given level for Python callers.
544    #[pyo3(name = "_log")]
545    #[pyo3(signature = (level, color=None, message=""))]
546    fn py_log(&self, level: LogLevel, color: Option<LogColor>, message: &str) {
547        self.log_message(level, color, message);
548    }
549}
550
551/// Formats a Python exception, including its traceback and chained exceptions.
552///
553/// Falls back to the exception type and message if traceback formatting fails.
554#[must_use]
555pub fn format_exception(e: &PyErr) -> String {
556    Python::attach(|py| {
557        py.import("traceback")
558            .and_then(|module| {
559                module.call_method1(
560                    "format_exception",
561                    (e.get_type(py), e.value(py), e.traceback(py)),
562                )
563            })
564            .and_then(|lines| lines.extract::<Vec<String>>())
565            .map_or_else(|_| e.to_string(), |lines| lines.concat())
566    })
567}
568
569#[cfg(test)]
570mod tests {
571    use nautilus_core::python::to_pyruntime_err;
572    use pyo3::ffi::c_str;
573    use rstest::rstest;
574
575    use super::*;
576
577    #[rstest]
578    fn test_format_exception_traceback_and_cause() {
579        Python::initialize();
580        Python::attach(|py| {
581            let module = PyModule::from_code(
582                py,
583                c_str!(
584                    r#"
585def callback():
586    try:
587        fail()
588    except ValueError as e:
589        raise RuntimeError("callback failure") from e
590
591def fail():
592    raise ValueError("original failure")
593"#
594                ),
595                c_str!("strategy_callback.py"),
596                c_str!("strategy_callback"),
597            )
598            .unwrap();
599            let e = module.call_method0("callback").unwrap_err();
600            let formatted = format_exception(&e);
601
602            assert!(formatted.contains("File \"strategy_callback.py\", line 9, in fail"));
603            assert!(formatted.contains("File \"strategy_callback.py\", line 6, in callback"));
604            assert!(formatted.contains("ValueError: original failure"));
605            assert!(formatted.contains("The above exception was the direct cause"));
606            assert!(formatted.ends_with("RuntimeError: callback failure\n"));
607        });
608    }
609
610    #[rstest]
611    fn test_format_exception_without_traceback() {
612        Python::initialize();
613        let e = to_pyruntime_err("callback failure");
614        assert_eq!(format_exception(&e), "RuntimeError: callback failure\n");
615    }
616
617    #[rstest]
618    fn test_format_exception_fallback() {
619        Python::initialize();
620        Python::attach(|py| {
621            let traceback = py.import("traceback").unwrap();
622            let original = traceback.getattr("format_exception").unwrap();
623            traceback.setattr("format_exception", py.None()).unwrap();
624            let e = to_pyruntime_err("callback failure");
625            let formatted = format_exception(&e);
626            traceback.setattr("format_exception", original).unwrap();
627
628            assert_eq!(formatted, "RuntimeError: callback failure");
629        });
630    }
631}