1use crate::protocol::TraceObject;
23use crate::server::config::{LogFormat, LoggingParams};
24use crate::server::node::NodeId;
25use chrono::Utc;
26use std::collections::HashMap;
27use std::fs::{self, File, OpenOptions};
28use std::io::{self, Write};
29use std::path::{Path, PathBuf};
30use std::sync::Mutex;
31
32fn ext(fmt: LogFormat) -> &'static str {
34 match fmt {
35 LogFormat::ForHuman => "log",
36 LogFormat::ForMachine => "json",
37 }
38}
39
40#[derive(Hash, PartialEq, Eq, Clone, Debug)]
42pub struct LogKey {
43 pub node_id: NodeId,
45 pub log_root: PathBuf,
47 pub log_format: LogFormat,
49}
50
51pub struct LogHandle {
53 pub file: File,
55 pub path: PathBuf,
57 pub bytes_written: u64,
59}
60
61pub struct LogWriter {
63 handles: Mutex<HashMap<LogKey, LogHandle>>,
64}
65
66impl LogWriter {
67 pub fn new() -> Self {
69 LogWriter {
70 handles: Mutex::new(HashMap::new()),
71 }
72 }
73
74 pub fn write_traces(
76 &self,
77 node_id: &NodeId,
78 params: &LoggingParams,
79 traces: &[TraceObject],
80 ) -> io::Result<()> {
81 let key = LogKey {
82 node_id: node_id.clone(),
83 log_root: params.log_root.clone(),
84 log_format: params.log_format,
85 };
86
87 let mut handles = self.handles.lock().unwrap();
88 if !handles.contains_key(&key) {
89 let handle = Self::open_new_file(¶ms.log_root, node_id, params.log_format)?;
90 handles.insert(key.clone(), handle);
91 }
92 let handle = handles.get_mut(&key).unwrap();
93
94 for trace in traces {
95 let line = format_trace(trace, params.log_format);
96 let bytes = line.as_bytes();
97 handle.file.write_all(bytes)?;
98 handle.bytes_written += bytes.len() as u64;
99 }
100 handle.file.flush()?;
101 Ok(())
102 }
103
104 pub fn rotate_if_needed(
106 &self,
107 node_id: &NodeId,
108 params: &LoggingParams,
109 limit_bytes: u64,
110 ) -> io::Result<()> {
111 let key = LogKey {
112 node_id: node_id.clone(),
113 log_root: params.log_root.clone(),
114 log_format: params.log_format,
115 };
116
117 let mut handles = self.handles.lock().unwrap();
118 if let Some(handle) = handles.get(&key) {
119 if handle.bytes_written >= limit_bytes {
120 let new_handle = Self::open_new_file(¶ms.log_root, node_id, params.log_format)?;
121 handles.insert(key, new_handle);
122 }
123 }
124 Ok(())
125 }
126
127 pub fn open_new_file(
129 log_root: &Path,
130 node_id: &NodeId,
131 format: LogFormat,
132 ) -> io::Result<LogHandle> {
133 let node_dir_name: String = node_id
135 .chars()
136 .map(|c| {
137 if c.is_alphanumeric() || c == '-' || c == '_' {
138 c
139 } else {
140 '_'
141 }
142 })
143 .collect();
144
145 let node_dir = log_root.join(&node_dir_name);
146 fs::create_dir_all(&node_dir)?;
147
148 let ts = Utc::now().format("%Y-%m-%dT%H-%M-%S");
149 let filename = format!("node-{}.{}", ts, ext(format));
150 let path = node_dir.join(&filename);
151
152 let file = OpenOptions::new().create(true).append(true).open(&path)?;
153
154 let link = node_dir.join(format!("node.{}", ext(format)));
156 let tmp_link = node_dir.join(format!("node.{}.tmp", ext(format)));
157
158 let _ = fs::remove_file(&tmp_link);
160
161 #[cfg(unix)]
163 std::os::unix::fs::symlink(&filename, &tmp_link)?;
164
165 #[cfg(unix)]
166 fs::rename(&tmp_link, &link)?;
167
168 Ok(LogHandle {
169 file,
170 path,
171 bytes_written: 0,
172 })
173 }
174}
175
176impl Default for LogWriter {
177 fn default() -> Self {
178 Self::new()
179 }
180}
181
182pub fn format_trace(trace: &TraceObject, format: LogFormat) -> String {
184 match format {
185 LogFormat::ForHuman => format_human(trace),
186 LogFormat::ForMachine => format_machine(trace),
187 }
188}
189
190pub fn format_human(trace: &TraceObject) -> String {
193 let ts = trace.to_timestamp.format("%Y-%m-%dT%H:%M:%S%.3fZ");
194 let ns = trace.to_namespace.join(".");
195 let msg = trace.to_human.as_deref().unwrap_or(&trace.to_machine);
196 format!("{} [{}] {} {}\n", ts, trace.to_severity, ns, msg)
197}
198
199pub fn format_machine(trace: &TraceObject) -> String {
201 let mut line = serde_json::to_string(trace).unwrap_or_else(|_| "{}".to_string());
202 line.push('\n');
203 line
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209 use crate::protocol::types::{DetailLevel, Severity};
210 use chrono::TimeZone;
211
212 fn make_trace() -> TraceObject {
213 TraceObject {
214 to_human: Some("hello world".to_string()),
215 to_machine: r#"{"msg":"hello world"}"#.to_string(),
216 to_namespace: vec!["TestNS".to_string(), "Sub".to_string()],
217 to_severity: Severity::Info,
218 to_details: DetailLevel::DNormal,
219 to_timestamp: Utc.with_ymd_and_hms(2024, 1, 15, 10, 30, 0).unwrap(),
220 to_hostname: "testhost".to_string(),
221 to_thread_id: "1".to_string(),
222 }
223 }
224
225 #[test]
226 fn test_format_human() {
227 let trace = make_trace();
228 let line = format_human(&trace);
229 assert!(line.contains("[Info]"));
230 assert!(line.contains("TestNS.Sub"));
231 assert!(line.contains("hello world"));
232 assert!(line.ends_with('\n'));
233 }
234
235 #[test]
236 fn test_format_machine() {
237 let trace = make_trace();
238 let line = format_machine(&trace);
239 assert!(line.starts_with('{'));
240 assert!(line.ends_with('\n'));
241 let _: serde_json::Value = serde_json::from_str(line.trim()).unwrap();
243 }
244
245 #[test]
246 fn test_write_and_read_back() {
247 let dir = tempfile::tempdir().unwrap();
248 let log_root = dir.path().to_path_buf();
249 let params = LoggingParams {
250 log_root: log_root.clone(),
251 log_mode: crate::server::config::LogMode::FileMode,
252 log_format: LogFormat::ForMachine,
253 };
254
255 let writer = LogWriter::new();
256 let traces = vec![make_trace()];
257 writer
258 .write_traces(&"test-node".to_string(), ¶ms, &traces)
259 .unwrap();
260
261 let node_dir = log_root.join("test-node");
263 let entries: Vec<_> = fs::read_dir(&node_dir)
264 .unwrap()
265 .filter_map(|e| e.ok())
266 .filter(|e| e.file_name().to_string_lossy().ends_with(".json"))
267 .filter(|e| !e.file_name().to_string_lossy().starts_with("node.json"))
268 .collect();
269 assert_eq!(entries.len(), 1);
270 let content = fs::read_to_string(entries[0].path()).unwrap();
271 assert!(!content.is_empty());
272 }
273}