Skip to main content

nautilus_common/logging/
writer.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::{
17    collections::VecDeque,
18    fs::{File, create_dir_all},
19    io::{self, BufWriter, Stderr, Stdout, Write},
20    path::PathBuf,
21    sync::OnceLock,
22};
23
24use chrono::{NaiveDate, Utc};
25use log::LevelFilter;
26use nautilus_core::consts::NAUTILUS_PREFIX;
27use regex::Regex;
28
29use crate::logging::logger::LogLine;
30
31static ANSI_RE: OnceLock<Regex> = OnceLock::new();
32
33pub trait LogWriter {
34    /// Writes a log line.
35    fn write(&mut self, line: &str);
36    /// Flushes buffered logs.
37    fn flush(&mut self);
38    /// Checks if a line needs to be written to the writer or not.
39    fn enabled(&self, line: &LogLine) -> bool;
40}
41
42#[derive(Debug)]
43pub struct StdoutWriter {
44    pub is_colored: bool,
45    io: Stdout,
46    level: LevelFilter,
47}
48
49impl StdoutWriter {
50    /// Creates a new [`StdoutWriter`] instance.
51    #[must_use]
52    pub fn new(level: LevelFilter, is_colored: bool) -> Self {
53        Self {
54            io: io::stdout(),
55            level,
56            is_colored,
57        }
58    }
59}
60
61impl LogWriter for StdoutWriter {
62    fn write(&mut self, line: &str) {
63        match self.io.write_all(line.as_bytes()) {
64            Ok(()) => {}
65            Err(e) => eprintln!("Error writing to stdout: {e:?}"),
66        }
67    }
68
69    fn flush(&mut self) {
70        match self.io.flush() {
71            Ok(()) => {}
72            Err(e) => eprintln!("Error flushing stdout: {e:?}"),
73        }
74    }
75
76    fn enabled(&self, line: &LogLine) -> bool {
77        // Prevent error logs also writing to stdout (they go to stderr)
78        line.level > LevelFilter::Error && line.level <= self.level
79    }
80}
81
82#[derive(Debug)]
83pub struct StderrWriter {
84    pub is_colored: bool,
85    io: Stderr,
86}
87
88impl StderrWriter {
89    /// Creates a new [`StderrWriter`] instance.
90    #[must_use]
91    pub fn new(is_colored: bool) -> Self {
92        Self {
93            io: io::stderr(),
94            is_colored,
95        }
96    }
97}
98
99impl LogWriter for StderrWriter {
100    fn write(&mut self, line: &str) {
101        match self.io.write_all(line.as_bytes()) {
102            Ok(()) => {}
103            Err(e) => eprintln!("Error writing to stderr: {e:?}"),
104        }
105    }
106
107    fn flush(&mut self) {
108        match self.io.flush() {
109            Ok(()) => {}
110            Err(e) => eprintln!("Error flushing stderr: {e:?}"),
111        }
112    }
113
114    fn enabled(&self, line: &LogLine) -> bool {
115        line.level == LevelFilter::Error
116    }
117}
118
119/// File rotation config.
120#[derive(Debug, Clone)]
121pub struct FileRotateConfig {
122    /// Maximum file size in bytes before rotating.
123    pub max_file_size: u64,
124    /// Maximum number of backup files to keep.
125    pub max_backup_count: u32,
126    /// Current file size tracking.
127    cur_file_size: u64,
128    /// Current file creation date.
129    cur_file_creation_date: NaiveDate,
130    /// Queue of backup file paths (oldest first).
131    backup_files: VecDeque<PathBuf>,
132}
133
134impl PartialEq for FileRotateConfig {
135    fn eq(&self, other: &Self) -> bool {
136        self.max_file_size == other.max_file_size && self.max_backup_count == other.max_backup_count
137    }
138}
139
140impl Eq for FileRotateConfig {}
141
142impl Default for FileRotateConfig {
143    fn default() -> Self {
144        Self {
145            max_file_size: 100 * 1024 * 1024, // 100MB default
146            max_backup_count: 5,
147            cur_file_size: 0,
148            cur_file_creation_date: Utc::now().date_naive(),
149            backup_files: VecDeque::new(),
150        }
151    }
152}
153
154impl From<(u64, u32)> for FileRotateConfig {
155    fn from(value: (u64, u32)) -> Self {
156        let (max_file_size, max_backup_count) = value;
157        Self {
158            max_file_size,
159            max_backup_count,
160            cur_file_size: 0,
161            cur_file_creation_date: Utc::now().date_naive(),
162            backup_files: VecDeque::new(),
163        }
164    }
165}
166
167#[cfg_attr(
168    feature = "python",
169    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.common", from_py_object)
170)]
171#[cfg_attr(
172    feature = "python",
173    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.common")
174)]
175#[derive(Debug, Clone, Default, PartialEq, Eq)]
176pub struct FileWriterConfig {
177    pub directory: Option<String>,
178    pub file_name: Option<String>,
179    pub file_format: Option<String>,
180    pub file_rotate: Option<FileRotateConfig>,
181}
182
183impl FileWriterConfig {
184    /// Creates a new [`FileWriterConfig`] instance.
185    #[must_use]
186    pub fn new(
187        directory: Option<String>,
188        file_name: Option<String>,
189        file_format: Option<String>,
190        file_rotate: Option<(u64, u32)>,
191    ) -> Self {
192        let file_rotate = file_rotate.map(FileRotateConfig::from);
193        Self {
194            directory,
195            file_name,
196            file_format,
197            file_rotate,
198        }
199    }
200}
201
202#[derive(Debug)]
203pub struct FileWriter {
204    pub json_format: bool,
205    buf: BufWriter<File>,
206    path: PathBuf,
207    file_config: FileWriterConfig,
208    trader_id: String,
209    instance_id: String,
210    level: LevelFilter,
211    cur_file_date: NaiveDate,
212}
213
214impl FileWriter {
215    /// Creates a new [`FileWriter`] instance.
216    pub fn new(
217        trader_id: String,
218        instance_id: String,
219        file_config: FileWriterConfig,
220        fileout_level: LevelFilter,
221    ) -> Option<Self> {
222        // Set up log file
223        let json_format = match file_config.file_format.as_ref().map(|s| s.to_lowercase()) {
224            Some(ref format) if format == "json" => true,
225            None => false,
226            Some(ref unrecognized) => {
227                eprintln!(
228                    "{NAUTILUS_PREFIX} Unrecognized log file format: {unrecognized}. Using plain text format as default."
229                );
230                false
231            }
232        };
233
234        let file_path =
235            match Self::create_log_file_path(&file_config, &trader_id, &instance_id, json_format) {
236                Ok(path) => path,
237                Err(e) => {
238                    eprintln!("{NAUTILUS_PREFIX} Error creating log directory: {e}");
239                    return None;
240                }
241            };
242
243        match File::options()
244            .create(true)
245            .append(true)
246            .open(file_path.clone())
247        {
248            Ok(file) => {
249                // Seed cur_file_size from existing file length if rotation is enabled
250                let mut file_config = file_config;
251                if let Some(ref mut rotate_config) = file_config.file_rotate
252                    && let Ok(metadata) = file.metadata()
253                {
254                    rotate_config.cur_file_size = metadata.len();
255                }
256
257                Some(Self {
258                    json_format,
259                    buf: BufWriter::new(file),
260                    path: file_path,
261                    file_config,
262                    trader_id,
263                    instance_id,
264                    level: fileout_level,
265                    cur_file_date: Utc::now().date_naive(),
266                })
267            }
268            Err(e) => {
269                eprintln!("{NAUTILUS_PREFIX} Error creating log file: {e}");
270                None
271            }
272        }
273    }
274
275    fn create_log_file_path(
276        file_config: &FileWriterConfig,
277        trader_id: &str,
278        instance_id: &str,
279        is_json_format: bool,
280    ) -> Result<PathBuf, io::Error> {
281        let utc_now = Utc::now();
282
283        let basename = match file_config.file_name.as_ref() {
284            Some(file_name) => {
285                if file_config.file_rotate.is_some() {
286                    let utc_datetime = utc_now.format("%Y-%m-%d_%H%M%S:%3f");
287                    format!("{file_name}_{utc_datetime}")
288                } else {
289                    file_name.clone()
290                }
291            }
292            None => {
293                // Default base name
294                let utc_component = if file_config.file_rotate.is_some() {
295                    utc_now.format("%Y-%m-%d_%H%M%S:%3f")
296                } else {
297                    utc_now.format("%Y-%m-%d")
298                };
299
300                format!("{trader_id}_{utc_component}_{instance_id}")
301            }
302        };
303
304        let suffix = if is_json_format { "json" } else { "log" };
305        let mut file_path = PathBuf::new();
306
307        if let Some(directory) = file_config.directory.as_ref() {
308            file_path.push(directory);
309            create_dir_all(&file_path)?;
310        }
311
312        file_path.push(basename);
313        file_path.set_extension(suffix);
314        Ok(file_path)
315    }
316
317    #[must_use]
318    fn should_rotate_file(&self, next_line_size: u64) -> bool {
319        // Size-based rotation takes priority when configured
320        if let Some(ref rotate_config) = self.file_config.file_rotate {
321            rotate_config.cur_file_size + next_line_size > rotate_config.max_file_size
322        // Otherwise, for default-named logs, rotate on UTC date change
323        } else if self.file_config.file_name.is_none() {
324            let today = Utc::now().date_naive();
325            self.cur_file_date != today
326        // No rotation for custom-named logs without size-based rotation
327        } else {
328            false
329        }
330    }
331
332    fn rotate_file(&mut self) {
333        self.flush();
334
335        let new_path = match Self::create_log_file_path(
336            &self.file_config,
337            &self.trader_id,
338            &self.instance_id,
339            self.json_format,
340        ) {
341            Ok(path) => path,
342            Err(e) => {
343                eprintln!("{NAUTILUS_PREFIX} Error creating log directory for rotation: {e}");
344                return;
345            }
346        };
347
348        match File::options().create(true).append(true).open(&new_path) {
349            Ok(new_file) => {
350                // Rotate existing file
351                if let Some(rotate_config) = &mut self.file_config.file_rotate {
352                    // Add current file to backup queue
353                    rotate_config.backup_files.push_back(self.path.clone());
354                    rotate_config.cur_file_size = 0;
355                    rotate_config.cur_file_creation_date = Utc::now().date_naive();
356                    cleanup_backups(rotate_config);
357                } else {
358                    // Update creation date for date-based rotation
359                    self.cur_file_date = Utc::now().date_naive();
360                }
361
362                self.buf = BufWriter::new(new_file);
363                self.path = new_path.clone();
364                eprintln!(
365                    "{NAUTILUS_PREFIX} Rotated log file, now logging to: {}",
366                    new_path.display()
367                );
368            }
369            Err(e) => eprintln!("{NAUTILUS_PREFIX} Error creating log file: {e}"),
370        }
371    }
372}
373
374/// Clean up old backup files if we exceed the max backup count.
375///
376/// TODO: Minor consider using a more specific version to pop a single file
377/// since normal execution will not create more than 1 excess file
378fn cleanup_backups(rotate_config: &mut FileRotateConfig) {
379    // Remove oldest backup files until we are at or below max_backup_count
380    let excess = rotate_config
381        .backup_files
382        .len()
383        .saturating_sub(rotate_config.max_backup_count as usize);
384    for _ in 0..excess {
385        if let Some(path) = rotate_config.backup_files.pop_front() {
386            if path.exists()
387                && let Err(e) = std::fs::remove_file(&path)
388            {
389                eprintln!(
390                    "{NAUTILUS_PREFIX} Failed to remove old log file {}: {e}",
391                    path.display()
392                );
393            }
394        } else {
395            break;
396        }
397    }
398}
399
400impl LogWriter for FileWriter {
401    fn write(&mut self, line: &str) {
402        let line = strip_ansi_codes(line);
403        let line_size = line.len() as u64;
404
405        // Rotate file if needed (size-based or date-based depending on configuration)
406        if self.should_rotate_file(line_size) {
407            self.rotate_file();
408        }
409
410        match self.buf.write_all(line.as_bytes()) {
411            Ok(()) => {
412                // Update current file size
413                if let Some(rotate_config) = &mut self.file_config.file_rotate {
414                    rotate_config.cur_file_size += line_size;
415                }
416            }
417            Err(e) => eprintln!("{NAUTILUS_PREFIX} Error writing to file: {e:?}"),
418        }
419    }
420
421    fn flush(&mut self) {
422        match self.buf.flush() {
423            Ok(()) => {}
424            Err(e) => eprintln!("{NAUTILUS_PREFIX} Error flushing file: {e:?}"),
425        }
426
427        match self.buf.get_ref().sync_all() {
428            Ok(()) => {}
429            Err(e) => eprintln!("{NAUTILUS_PREFIX} Error syncing file: {e:?}"),
430        }
431    }
432
433    fn enabled(&self, line: &LogLine) -> bool {
434        line.level <= self.level
435    }
436}
437
438fn strip_nonprinting_except_newline(s: &str) -> String {
439    s.chars()
440        .filter(|&c| c == '\n' || (!c.is_control() && c != '\u{7F}'))
441        .collect()
442}
443
444fn strip_ansi_codes(s: &str) -> String {
445    let re = ANSI_RE.get_or_init(|| Regex::new(r"\x1B\[[0-9;?=]*[A-Za-z]|\x1B\].*?\x07").unwrap());
446    // Strip ANSI codes first (while \x1B is still present), then remove other control chars
447    let no_ansi = re.replace_all(s, "");
448    strip_nonprinting_except_newline(&no_ansi)
449}
450
451#[cfg(test)]
452mod tests {
453    use log::LevelFilter;
454    use rstest::rstest;
455    use tempfile::tempdir;
456
457    use super::*;
458
459    #[rstest]
460    fn test_file_writer_with_rotation_creates_new_timestamped_file() {
461        let temp_dir = tempdir().unwrap();
462
463        let config = FileWriterConfig {
464            directory: Some(temp_dir.path().to_str().unwrap().to_string()),
465            file_name: Some("test".to_string()),
466            file_format: None,
467            file_rotate: Some(FileRotateConfig::from((2000, 5))),
468        };
469
470        let writer = FileWriter::new(
471            "TRADER-001".to_string(),
472            "instance-123".to_string(),
473            config,
474            LevelFilter::Info,
475        )
476        .unwrap();
477
478        assert_eq!(
479            writer
480                .file_config
481                .file_rotate
482                .as_ref()
483                .unwrap()
484                .cur_file_size,
485            0
486        );
487        assert!(writer.path.to_str().unwrap().contains("test_"));
488    }
489
490    #[rstest]
491    #[case("Hello, World!", "Hello, World!")]
492    #[case("Line1\nLine2", "Line1\nLine2")]
493    #[case("Tab\there", "Tabhere")]
494    #[case("Null\0char", "Nullchar")]
495    #[case("DEL\u{7F}char", "DELchar")]
496    #[case("Bell\u{07}sound", "Bellsound")]
497    #[case("Mix\t\0\u{7F}ed", "Mixed")]
498    fn test_strip_nonprinting_except_newline(#[case] input: &str, #[case] expected: &str) {
499        let result = strip_nonprinting_except_newline(input);
500        assert_eq!(result, expected);
501    }
502
503    #[rstest]
504    #[case("Plain text", "Plain text")]
505    #[case("\x1B[31mRed\x1B[0m", "Red")]
506    #[case("\x1B[1;32mBold Green\x1B[0m", "Bold Green")]
507    #[case("Before\x1B[0mAfter", "BeforeAfter")]
508    #[case("\x1B]0;Title\x07Content", "Content")]
509    #[case("Text\t\x1B[31mRed\x1B[0m", "TextRed")]
510    fn test_strip_ansi_codes(#[case] input: &str, #[case] expected: &str) {
511        let result = strip_ansi_codes(input);
512        assert_eq!(result, expected);
513    }
514
515    #[rstest]
516    fn test_file_writer_unwritable_directory_returns_none() {
517        let config = FileWriterConfig {
518            directory: Some("/nonexistent/path/that/should/not/exist".to_string()),
519            file_name: Some("test".to_string()),
520            file_format: None,
521            file_rotate: None,
522        };
523
524        let writer = FileWriter::new(
525            "TRADER-001".to_string(),
526            "instance-123".to_string(),
527            config,
528            LevelFilter::Info,
529        );
530
531        assert!(writer.is_none());
532    }
533
534    #[rstest]
535    fn test_file_writer_directory_is_file_returns_none() {
536        let temp_dir = tempdir().unwrap();
537        let file_path = temp_dir.path().join("not_a_directory");
538        std::fs::write(&file_path, "I am a file").unwrap();
539
540        let config = FileWriterConfig {
541            directory: Some(file_path.to_str().unwrap().to_string()),
542            file_name: Some("test".to_string()),
543            file_format: None,
544            file_rotate: None,
545        };
546
547        let writer = FileWriter::new(
548            "TRADER-001".to_string(),
549            "instance-123".to_string(),
550            config,
551            LevelFilter::Info,
552        );
553
554        assert!(writer.is_none());
555    }
556
557    #[rstest]
558    fn test_file_writer_unrecognized_format_defaults_to_text() {
559        let temp_dir = tempdir().unwrap();
560
561        let config = FileWriterConfig {
562            directory: Some(temp_dir.path().to_str().unwrap().to_string()),
563            file_name: Some("test".to_string()),
564            file_format: Some("invalid_format".to_string()),
565            file_rotate: None,
566        };
567
568        let writer = FileWriter::new(
569            "TRADER-001".to_string(),
570            "instance-123".to_string(),
571            config,
572            LevelFilter::Info,
573        )
574        .unwrap();
575
576        assert!(!writer.json_format);
577        assert!(writer.path.extension().unwrap() == "log");
578    }
579
580    #[rstest]
581    fn test_file_writer_json_format() {
582        let temp_dir = tempdir().unwrap();
583
584        let config = FileWriterConfig {
585            directory: Some(temp_dir.path().to_str().unwrap().to_string()),
586            file_name: Some("test".to_string()),
587            file_format: Some("json".to_string()),
588            file_rotate: None,
589        };
590
591        let writer = FileWriter::new(
592            "TRADER-001".to_string(),
593            "instance-123".to_string(),
594            config,
595            LevelFilter::Info,
596        )
597        .unwrap();
598
599        assert!(writer.json_format);
600        assert!(writer.path.extension().unwrap() == "json");
601    }
602
603    #[rstest]
604    fn test_stdout_writer_filters_error_level() {
605        let writer = StdoutWriter::new(LevelFilter::Info, true);
606
607        // Error level should NOT be enabled for stdout (goes to stderr)
608        let error_line = LogLine {
609            timestamp: 0.into(),
610            level: log::Level::Error,
611            color: crate::enums::LogColor::Normal,
612            component: ustr::Ustr::from("Test"),
613            message: "error".to_string(),
614        };
615        assert!(!writer.enabled(&error_line));
616
617        // Info level should be enabled
618        let info_line = LogLine {
619            timestamp: 0.into(),
620            level: log::Level::Info,
621            color: crate::enums::LogColor::Normal,
622            component: ustr::Ustr::from("Test"),
623            message: "info".to_string(),
624        };
625        assert!(writer.enabled(&info_line));
626
627        // Debug should NOT be enabled when stdout level is Info
628        let debug_line = LogLine {
629            timestamp: 0.into(),
630            level: log::Level::Debug,
631            color: crate::enums::LogColor::Normal,
632            component: ustr::Ustr::from("Test"),
633            message: "debug".to_string(),
634        };
635        assert!(!writer.enabled(&debug_line));
636    }
637
638    #[rstest]
639    fn test_stderr_writer_only_enables_error_level() {
640        let writer = StderrWriter::new(true);
641
642        let error_line = LogLine {
643            timestamp: 0.into(),
644            level: log::Level::Error,
645            color: crate::enums::LogColor::Normal,
646            component: ustr::Ustr::from("Test"),
647            message: "error".to_string(),
648        };
649        assert!(writer.enabled(&error_line));
650
651        let warn_line = LogLine {
652            timestamp: 0.into(),
653            level: log::Level::Warn,
654            color: crate::enums::LogColor::Normal,
655            component: ustr::Ustr::from("Test"),
656            message: "warn".to_string(),
657        };
658        assert!(!writer.enabled(&warn_line));
659    }
660}