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/// A thin wrapper around the global Rust logger which exposes ergonomic
425/// logging helpers for Python code.
426///
427/// It mirrors the familiar Python `logging` interface while forwarding
428/// all records through the Nautilus logging infrastructure so that log levels
429/// and formatting remain consistent across Rust and Python.
430#[pyclass(
431    module = "nautilus_trader.common",
432    name = "Logger",
433    unsendable,
434    from_py_object
435)]
436#[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.common")]
437#[derive(Debug, Clone)]
438pub struct PyLogger {
439    name: Ustr,
440}
441
442impl PyLogger {
443    pub fn new(name: &str) -> Self {
444        Self {
445            name: Ustr::from(name),
446        }
447    }
448
449    fn log_message(&self, level: LogLevel, color: Option<LogColor>, message: &str) {
450        let color = color.unwrap_or(LogColor::Normal);
451        logger::log(level, color, self.name, message);
452    }
453}
454
455#[pymethods]
456#[pyo3_stub_gen::derive::gen_stub_pymethods]
457impl PyLogger {
458    /// Create a new `Logger` instance.
459    #[new]
460    #[pyo3(signature = (name="Python"))]
461    fn py_new(name: &str) -> Self {
462        Self::new(name)
463    }
464
465    /// The component identifier carried by this logger.
466    #[getter]
467    fn name(&self) -> &str {
468        &self.name
469    }
470
471    /// Emit a TRACE level record.
472    #[pyo3(name = "trace")]
473    #[pyo3(signature = (message, color=None))]
474    fn py_trace(&self, message: &str, color: Option<LogColor>) {
475        self.log_message(LogLevel::Trace, color, message);
476    }
477
478    /// Emit a DEBUG level record.
479    #[pyo3(name = "debug")]
480    #[pyo3(signature = (message, color=None))]
481    fn py_debug(&self, message: &str, color: Option<LogColor>) {
482        self.log_message(LogLevel::Debug, color, message);
483    }
484
485    /// Emit an INFO level record.
486    #[pyo3(name = "info")]
487    #[pyo3(signature = (message, color=None))]
488    fn py_info(&self, message: &str, color: Option<LogColor>) {
489        self.log_message(LogLevel::Info, color, message);
490    }
491
492    /// Emit a WARNING level record.
493    #[pyo3(name = "warning")]
494    #[pyo3(signature = (message, color=None))]
495    fn py_warning(&self, message: &str, color: Option<LogColor>) {
496        self.log_message(LogLevel::Warning, color, message);
497    }
498
499    /// Emit an ERROR level record.
500    #[pyo3(name = "error")]
501    #[pyo3(signature = (message, color=None))]
502    fn py_error(&self, message: &str, color: Option<LogColor>) {
503        self.log_message(LogLevel::Error, color, message);
504    }
505
506    /// Emit an ERROR level record with the active Python exception info.
507    #[pyo3(name = "exception")]
508    #[pyo3(signature = (message="", color=None))]
509    fn py_exception(&self, py: Python, message: &str, color: Option<LogColor>) {
510        let mut full_msg = message.to_owned();
511
512        if pyo3::PyErr::occurred(py) {
513            let err = PyErr::fetch(py);
514            let err_str = err.to_string();
515
516            if full_msg.is_empty() {
517                full_msg = err_str;
518            } else {
519                full_msg = format!("{full_msg}: {err_str}");
520            }
521        }
522
523        self.log_message(LogLevel::Error, color, &full_msg);
524    }
525
526    /// Flush buffered log records.
527    #[pyo3(name = "flush")]
528    fn py_flush(&self) {
529        log::logger().flush();
530    }
531
532    /// Emit a log record at the given level (Python-facing helper).
533    #[pyo3(name = "_log")]
534    #[pyo3(signature = (level, color=None, message=""))]
535    fn py_log(&self, level: LogLevel, color: Option<LogColor>, message: &str) {
536        self.log_message(level, color, message);
537    }
538}