Skip to main content

quickfix_tokio/
log.rs

1//! Session logging: raw wire traffic and human-readable events.
2
3use std::io::Write;
4use std::path::PathBuf;
5
6use crate::error::Result;
7use crate::session_id::SessionId;
8
9pub trait Log: Send {
10    fn on_incoming(&mut self, raw: &[u8]);
11    fn on_outgoing(&mut self, raw: &[u8]);
12    fn on_event(&mut self, event: &str);
13}
14
15pub trait LogFactory: Send + Sync {
16    fn create(&self, session_id: &SessionId) -> Result<Box<dyn Log>>;
17}
18
19// ----- null -----
20
21#[derive(Debug, Default)]
22pub struct NullLog;
23
24impl Log for NullLog {
25    fn on_incoming(&mut self, _raw: &[u8]) {}
26    fn on_outgoing(&mut self, _raw: &[u8]) {}
27    fn on_event(&mut self, _event: &str) {}
28}
29
30#[derive(Debug, Default)]
31pub struct NullLogFactory;
32
33impl LogFactory for NullLogFactory {
34    fn create(&self, _session_id: &SessionId) -> Result<Box<dyn Log>> {
35        Ok(Box::new(NullLog))
36    }
37}
38
39// ----- tracing (screen) -----
40
41/// Emits traffic and events through the `tracing` crate, with SOH rendered
42/// as `|` for readability.
43pub struct TracingLog {
44    session_id: String,
45}
46
47fn printable(raw: &[u8]) -> String {
48    String::from_utf8_lossy(raw).replace('\x01', "|")
49}
50
51impl Log for TracingLog {
52    fn on_incoming(&mut self, raw: &[u8]) {
53        tracing::debug!(session = %self.session_id, "<- {}", printable(raw));
54    }
55    fn on_outgoing(&mut self, raw: &[u8]) {
56        tracing::debug!(session = %self.session_id, "-> {}", printable(raw));
57    }
58    fn on_event(&mut self, event: &str) {
59        tracing::info!(session = %self.session_id, "{event}");
60    }
61}
62
63#[derive(Debug, Default)]
64pub struct TracingLogFactory;
65
66impl LogFactory for TracingLogFactory {
67    fn create(&self, session_id: &SessionId) -> Result<Box<dyn Log>> {
68        Ok(Box::new(TracingLog { session_id: session_id.to_string() }))
69    }
70}
71
72// ----- file -----
73
74/// Size-based rotation with a retention limit. `max_size == 0` disables
75/// rotation (unbounded append). When a write would exceed `max_size`, the
76/// current file is rolled to `<name>.1`, existing backups shift up, and
77/// anything beyond `max_backups` is deleted.
78#[derive(Debug, Clone, Copy)]
79pub struct Rotation {
80    pub max_size: u64,
81    pub max_backups: usize,
82}
83
84impl Rotation {
85    fn none() -> Self {
86        Self { max_size: 0, max_backups: 0 }
87    }
88}
89
90/// An append log file that rotates by size and prunes old backups.
91struct RotatingFile {
92    path: PathBuf,
93    file: std::fs::File,
94    size: u64,
95    rotation: Rotation,
96}
97
98impl RotatingFile {
99    fn open(path: PathBuf, rotation: Rotation) -> Result<Self> {
100        let file =
101            std::fs::OpenOptions::new().create(true).append(true).open(&path)?;
102        let size = file.metadata().map(|m| m.len()).unwrap_or(0);
103        Ok(Self { path, file, size, rotation })
104    }
105
106    fn write_line(&mut self, line: &str) {
107        let bytes = line.len() as u64 + 1; // + newline
108        if self.rotation.max_size > 0
109            && self.size > 0
110            && self.size + bytes > self.rotation.max_size
111        {
112            if let Err(e) = self.rotate() {
113                tracing::warn!("log rotation failed for {:?}: {e}", self.path);
114            }
115        }
116        if writeln!(self.file, "{line}").is_ok() {
117            self.size += bytes;
118        }
119    }
120
121    fn rotate(&mut self) -> std::io::Result<()> {
122        let backup = |n: usize| -> PathBuf {
123            let mut p = self.path.clone().into_os_string();
124            p.push(format!(".{n}"));
125            PathBuf::from(p)
126        };
127        // Drop the oldest backup, then shift the rest up by one.
128        let _ = std::fs::remove_file(backup(self.rotation.max_backups));
129        for n in (1..self.rotation.max_backups).rev() {
130            if backup(n).exists() {
131                std::fs::rename(backup(n), backup(n + 1))?;
132            }
133        }
134        if self.rotation.max_backups > 0 {
135            std::fs::rename(&self.path, backup(1))?;
136        } else {
137            // No backups retained: just truncate.
138            std::fs::remove_file(&self.path)?;
139        }
140        self.file =
141            std::fs::OpenOptions::new().create(true).append(true).open(&self.path)?;
142        self.size = 0;
143        Ok(())
144    }
145}
146
147/// Two rotating append files per session, like the reference engines:
148/// `<prefix>.messages.log` (raw traffic) and `<prefix>.event.log`.
149pub struct FileLog {
150    messages: RotatingFile,
151    events: RotatingFile,
152}
153
154impl Log for FileLog {
155    fn on_incoming(&mut self, raw: &[u8]) {
156        self.messages.write_line(&format!("{} <- {}", now(), printable(raw)));
157    }
158    fn on_outgoing(&mut self, raw: &[u8]) {
159        self.messages.write_line(&format!("{} -> {}", now(), printable(raw)));
160    }
161    fn on_event(&mut self, event: &str) {
162        self.events.write_line(&format!("{} {}", now(), event));
163    }
164}
165
166fn now() -> String {
167    chrono::Utc::now().format("%Y%m%d-%H:%M:%S%.3f").to_string()
168}
169
170pub struct FileLogFactory {
171    pub path: PathBuf,
172    rotation: Rotation,
173}
174
175impl FileLogFactory {
176    pub fn new(path: impl Into<PathBuf>) -> Self {
177        Self { path: path.into(), rotation: Rotation::none() }
178    }
179
180    /// Rotate each log at `max_size` bytes, keeping at most `max_backups`
181    /// rolled files (`<name>.1` .. `<name>.max_backups`).
182    pub fn with_rotation(mut self, max_size: u64, max_backups: usize) -> Self {
183        self.rotation = Rotation { max_size, max_backups };
184        self
185    }
186}
187
188impl LogFactory for FileLogFactory {
189    fn create(&self, session_id: &SessionId) -> Result<Box<dyn Log>> {
190        std::fs::create_dir_all(&self.path)?;
191        let prefix = self.path.join(session_id.file_prefix());
192        Ok(Box::new(FileLog {
193            messages: RotatingFile::open(prefix.with_extension("messages.log"), self.rotation)?,
194            events: RotatingFile::open(prefix.with_extension("event.log"), self.rotation)?,
195        }))
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn file_log_rotates_and_prunes_backups() {
205        let dir = std::env::temp_dir().join(format!("qft-log-{}", std::process::id()));
206        let _ = std::fs::remove_dir_all(&dir);
207        let sid = SessionId::new("FIX.4.4", "S", "T");
208        // Rotate every ~80 bytes, keep 2 backups.
209        let factory = FileLogFactory::new(&dir).with_rotation(80, 2);
210        let mut log = factory.create(&sid).unwrap();
211
212        // Each line is ~40+ bytes; write enough to force several rotations.
213        for i in 0..20 {
214            log.on_outgoing(format!("8=FIX.4.4|35=0|34={i}|line-padding-here|").as_bytes());
215        }
216        drop(log);
217
218        let base = dir.join(sid.file_prefix()).with_extension("messages.log");
219        assert!(base.exists(), "current log missing");
220        let b1 = with_suffix(&base, ".1");
221        let b2 = with_suffix(&base, ".2");
222        let b3 = with_suffix(&base, ".3");
223        assert!(b1.exists() && b2.exists(), "expected 2 rotated backups");
224        // Retention: no third backup is ever kept.
225        assert!(!b3.exists(), "backups exceeded max_backups=2");
226        let _ = std::fs::remove_dir_all(&dir);
227    }
228
229    fn with_suffix(p: &std::path::Path, s: &str) -> PathBuf {
230        let mut o = p.to_path_buf().into_os_string();
231        o.push(s);
232        PathBuf::from(o)
233    }
234}