nautilus_common/logging/
writer.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 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    fs::{create_dir_all, File},
18    io::{self, BufWriter, Stderr, Stdout, Write},
19    path::PathBuf,
20    sync::OnceLock,
21};
22
23use chrono::{DateTime, Utc};
24use log::LevelFilter;
25use regex::Regex;
26
27use crate::logging::logger::LogLine;
28
29static ANSI_RE: OnceLock<Regex> = OnceLock::new();
30
31pub trait LogWriter {
32    /// Writes a log line.
33    fn write(&mut self, line: &str);
34    /// Flushes buffered logs.
35    fn flush(&mut self);
36    /// Checks if a line needs to be written to the writer or not.
37    fn enabled(&self, line: &LogLine) -> bool;
38}
39
40#[derive(Debug)]
41pub struct StdoutWriter {
42    pub is_colored: bool,
43    io: Stdout,
44    level: LevelFilter,
45}
46
47impl StdoutWriter {
48    /// Creates a new [`StdoutWriter`] instance.
49    #[must_use]
50    pub fn new(level: LevelFilter, is_colored: bool) -> Self {
51        Self {
52            io: io::stdout(),
53            level,
54            is_colored,
55        }
56    }
57}
58
59impl LogWriter for StdoutWriter {
60    fn write(&mut self, line: &str) {
61        match self.io.write_all(line.as_bytes()) {
62            Ok(()) => {}
63            Err(e) => eprintln!("Error writing to stdout: {e:?}"),
64        }
65    }
66
67    fn flush(&mut self) {
68        match self.io.flush() {
69            Ok(()) => {}
70            Err(e) => eprintln!("Error flushing stdout: {e:?}"),
71        }
72    }
73
74    fn enabled(&self, line: &LogLine) -> bool {
75        // Prevent error logs also writing to stdout
76        line.level > LevelFilter::Error && line.level <= self.level
77    }
78}
79
80#[derive(Debug)]
81pub struct StderrWriter {
82    pub is_colored: bool,
83    io: Stderr,
84}
85
86impl StderrWriter {
87    /// Creates a new [`StderrWriter`] instance.
88    #[must_use]
89    pub fn new(is_colored: bool) -> Self {
90        Self {
91            io: io::stderr(),
92            is_colored,
93        }
94    }
95}
96
97impl LogWriter for StderrWriter {
98    fn write(&mut self, line: &str) {
99        match self.io.write_all(line.as_bytes()) {
100            Ok(()) => {}
101            Err(e) => eprintln!("Error writing to stderr: {e:?}"),
102        }
103    }
104
105    fn flush(&mut self) {
106        match self.io.flush() {
107            Ok(()) => {}
108            Err(e) => eprintln!("Error flushing stderr: {e:?}"),
109        }
110    }
111
112    fn enabled(&self, line: &LogLine) -> bool {
113        line.level == LevelFilter::Error
114    }
115}
116
117#[cfg_attr(
118    feature = "python",
119    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.common")
120)]
121#[derive(Debug, Clone, Default)]
122pub struct FileWriterConfig {
123    pub directory: Option<String>,
124    pub file_name: Option<String>,
125    pub file_format: Option<String>,
126}
127
128impl FileWriterConfig {
129    /// Creates a new [`FileWriterConfig`] instance.
130    #[must_use]
131    pub const fn new(
132        directory: Option<String>,
133        file_name: Option<String>,
134        file_format: Option<String>,
135    ) -> Self {
136        Self {
137            directory,
138            file_name,
139            file_format,
140        }
141    }
142}
143
144#[derive(Debug)]
145pub struct FileWriter {
146    pub json_format: bool,
147    buf: BufWriter<File>,
148    path: PathBuf,
149    file_config: FileWriterConfig,
150    trader_id: String,
151    instance_id: String,
152    level: LevelFilter,
153}
154
155impl FileWriter {
156    /// Creates a new [`FileWriter`] instance.
157    pub fn new(
158        trader_id: String,
159        instance_id: String,
160        file_config: FileWriterConfig,
161        fileout_level: LevelFilter,
162    ) -> Option<Self> {
163        // Set up log file
164        let json_format = match file_config.file_format.as_ref().map(|s| s.to_lowercase()) {
165            Some(ref format) if format == "json" => true,
166            None => false,
167            Some(ref unrecognized) => {
168                tracing::error!(
169                    "Unrecognized log file format: {unrecognized}. Using plain text format as default."
170                );
171                false
172            }
173        };
174
175        let file_path =
176            Self::create_log_file_path(&file_config, &trader_id, &instance_id, json_format);
177
178        match File::options()
179            .create(true)
180            .append(true)
181            .open(file_path.clone())
182        {
183            Ok(file) => Some(Self {
184                json_format,
185                buf: BufWriter::new(file),
186                path: file_path,
187                file_config,
188                trader_id,
189                instance_id,
190                level: fileout_level,
191            }),
192            Err(e) => {
193                tracing::error!("Error creating log file: {e}");
194                None
195            }
196        }
197    }
198
199    fn create_log_file_path(
200        file_config: &FileWriterConfig,
201        trader_id: &str,
202        instance_id: &str,
203        is_json_format: bool,
204    ) -> PathBuf {
205        let basename = if let Some(file_name) = file_config.file_name.as_ref() {
206            file_name.clone()
207        } else {
208            // default base name
209            let current_date_utc = Utc::now().format("%Y-%m-%d");
210            format!("{trader_id}_{current_date_utc}_{instance_id}")
211        };
212
213        let suffix = if is_json_format { "json" } else { "log" };
214        let mut file_path = PathBuf::new();
215
216        if let Some(directory) = file_config.directory.as_ref() {
217            file_path.push(directory);
218            create_dir_all(&file_path).expect("Failed to create directories for log file");
219        }
220
221        file_path.push(basename);
222        file_path.set_extension(suffix);
223        file_path
224    }
225
226    #[must_use]
227    pub fn should_rotate_file(&self) -> bool {
228        let current_date_utc = Utc::now().date_naive();
229        let metadata = self
230            .path
231            .metadata()
232            .expect("Failed to read log file metadata");
233        let creation_time = metadata
234            .created()
235            .expect("Failed to get log file creation time");
236
237        let creation_time_utc: DateTime<Utc> = creation_time.into();
238        let creation_date_utc = creation_time_utc.date_naive();
239
240        current_date_utc != creation_date_utc
241    }
242}
243
244impl LogWriter for FileWriter {
245    fn write(&mut self, line: &str) {
246        if self.should_rotate_file() {
247            self.flush();
248
249            let file_path = Self::create_log_file_path(
250                &self.file_config,
251                &self.trader_id,
252                &self.instance_id,
253                self.json_format,
254            );
255
256            match File::options()
257                .create(true)
258                .append(true)
259                .open(file_path.clone())
260            {
261                Ok(file) => {
262                    self.buf = BufWriter::new(file);
263                    self.path = file_path;
264                }
265                Err(e) => tracing::error!("Error creating log file: {e}"),
266            }
267        }
268
269        let line = strip_ansi_codes(line);
270
271        match self.buf.write_all(line.as_bytes()) {
272            Ok(()) => {}
273            Err(e) => tracing::error!("Error writing to file: {e:?}"),
274        }
275    }
276
277    fn flush(&mut self) {
278        match self.buf.flush() {
279            Ok(()) => {}
280            Err(e) => tracing::error!("Error flushing file: {e:?}"),
281        }
282    }
283
284    fn enabled(&self, line: &LogLine) -> bool {
285        line.level <= self.level
286    }
287}
288
289fn strip_nonprinting_except_newline(s: &str) -> String {
290    s.chars()
291        .filter(|&c| c == '\n' || (!c.is_control() && c != '\u{7F}'))
292        .collect()
293}
294
295fn strip_ansi_codes(s: &str) -> String {
296    let re = ANSI_RE.get_or_init(|| Regex::new(r"\x1B\[[0-9;?=]*[A-Za-z]|\x1B\].*?\x07").unwrap());
297    let no_controls = strip_nonprinting_except_newline(s);
298    re.replace_all(&no_controls, "").to_string()
299}