1#[cfg(not(target_family = "wasm"))]
2use std::ffi::OsStr;
3use std::io;
4use std::path::{Path, PathBuf};
5use std::sync::Mutex;
6#[cfg(not(target_family = "wasm"))]
7use std::sync::OnceLock;
8use std::thread::JoinHandle;
9use std::time::Duration;
10
11use chrono::{DateTime, FixedOffset, Local, Utc};
12use sysinfo::{Pid, ProcessRefreshKind, RefreshKind, System};
13use tracing::{debug, error, info, warn};
14#[cfg(not(target_family = "wasm"))]
15use tracing_appender::{non_blocking, rolling};
16use tracing_subscriber::layer::SubscriberExt;
17use tracing_subscriber::util::SubscriberInitExt;
18use tracing_subscriber::{EnvFilter, Layer};
19
20use super::config::*;
21use super::constants::DEFAULT_LOG_LEVEL_CONSOLE;
22#[cfg(not(target_family = "wasm"))]
23use super::constants::DEFAULT_LOG_LEVEL_FILE;
24use crate::error_printer::ErrorPrinter;
25use crate::utils::ByteSize;
26
27static LOG_CLEANUP_HANDLE: Mutex<Option<JoinHandle<()>>> = Mutex::new(None);
29
30pub fn wait_for_log_directory_cleanup() {
33 if let Ok(mut handle_opt) = LOG_CLEANUP_HANDLE.lock()
34 && let Some(handle) = handle_opt.take()
35 {
36 let _ = handle.join();
37 }
38}
39
40pub fn init(cfg: LoggingConfig) {
42 let mut dir_cleanup_task = None;
43
44 let maybe_log_file: Option<PathBuf> = {
45 match &cfg.logging_mode {
46 LoggingMode::Directory(log_dir) => {
47 if cfg.enable_log_dir_cleanup && log_dir.exists() && log_dir.is_dir() {
48 dir_cleanup_task =
49 Some(|| run_log_directory_cleanup_background(cfg.log_dir_config.clone(), log_dir));
50 }
51
52 Some(log_file_in_dir(&cfg.log_dir_config, log_dir))
53 },
54 LoggingMode::File(path_buf) => Some(path_buf.clone()),
55 LoggingMode::Console => None,
56 }
57 };
58
59 if let Some(log_file) = maybe_log_file {
61 if let Err(e) = init_logging_to_file(&log_file, cfg.use_json) {
63 init_logging_to_console(&cfg);
64 error!("Error logging to file {log_file:?} ({e}); falling back to console logging.");
65 }
66 } else {
67 init_logging_to_console(&cfg);
68 }
69
70 info!("{}, xet-core revision {}", &cfg.version, git_version::git_version!(fallback = "unknown"));
72
73 crate::utils::system_memory::log_derived_defaults();
77
78 if let Some(dir_cleanup_task_fn) = dir_cleanup_task {
79 dir_cleanup_task_fn();
80 }
81}
82
83fn init_logging_to_console(cfg: &LoggingConfig) {
84 let registry = tracing_subscriber::registry();
86
87 #[cfg(feature = "tokio-console")]
88 let registry = {
89 let console_layer = console_subscriber::spawn().with_filter(EnvFilter::new("tokio=trace,runtime=trace"));
91 registry.with(console_layer)
92 };
93
94 let fmt_layer_base = tracing_subscriber::fmt::layer()
95 .with_line_number(true)
96 .with_file(true)
97 .with_target(false);
98 let fmt_filter = EnvFilter::try_from_default_env()
99 .or_else(|_| EnvFilter::try_new(DEFAULT_LOG_LEVEL_CONSOLE))
100 .unwrap_or_default();
101
102 if cfg.use_json {
103 let filtered_fmt_layer = fmt_layer_base.json().with_filter(fmt_filter);
104 registry.with(filtered_fmt_layer).init();
105 } else {
106 let filtered_fmt_layer = fmt_layer_base.pretty().with_filter(fmt_filter);
107 registry.with(filtered_fmt_layer).init();
108 }
109}
110
111#[cfg(not(target_family = "wasm"))]
112fn init_logging_to_file(path: &Path, use_json: bool) -> Result<(), std::io::Error> {
113 let (path, file_name) = match path.file_name() {
115 Some(name) => (path.to_path_buf(), name),
116 None => (path.join("xet.log"), OsStr::new("xet.log")),
117 };
118
119 let log_directory = match path.parent() {
120 Some(parent) => {
121 std::fs::create_dir_all(parent)?;
122 parent
123 },
124 None => Path::new("."),
125 };
126
127 std::fs::write(&path, [])?;
129
130 let file_appender = rolling::never(log_directory, file_name);
133
134 let (writer, guard) = non_blocking(file_appender);
135
136 static FILE_GUARD: OnceLock<tracing_appender::non_blocking::WorkerGuard> = OnceLock::new();
138 let _ = FILE_GUARD.set(guard); let registry = tracing_subscriber::registry();
141
142 #[cfg(feature = "tokio-console")]
143 let registry = {
144 let console_layer = console_subscriber::spawn().with_filter(EnvFilter::new("tokio=trace,runtime=trace"));
146 registry.with(console_layer)
147 };
148
149 let fmt_layer_base = tracing_subscriber::fmt::layer()
151 .with_line_number(true)
152 .with_file(true)
153 .with_target(false)
154 .with_writer(writer);
155 let fmt_filter = EnvFilter::try_from_default_env()
157 .or_else(|_| EnvFilter::try_new(DEFAULT_LOG_LEVEL_FILE))
158 .unwrap_or_default();
159
160 if use_json {
161 registry.with(fmt_layer_base.json().with_filter(fmt_filter)).init();
162 } else {
163 registry.with(fmt_layer_base.pretty().with_filter(fmt_filter)).init();
164 };
165
166 Ok(())
167}
168
169#[cfg(target_family = "wasm")]
170fn init_logging_to_file(_path: &Path, _use_json: bool) -> Result<(), std::io::Error> {
171 Err(io::Error::new(io::ErrorKind::Unsupported, "file logging is not supported on wasm targets"))
172}
173
174pub fn log_file_in_dir(cfg: &LogDirConfig, dir: impl AsRef<Path>) -> PathBuf {
177 let now_local: DateTime<Local> = Local::now();
178 let now_fixed: DateTime<FixedOffset> = now_local.with_timezone(now_local.offset());
179
180 let ts = now_fixed.format("%Y%m%dT%H%M%S%3f%z"); let pid = std::process::id();
184 let prefix = &cfg.filename_prefix;
185 let filename = format!("{}_{}_{}.log", prefix, ts, pid);
186 dir.as_ref().join(filename)
187}
188
189pub fn parse_log_file_name(path: impl AsRef<Path>) -> Option<(String, DateTime<FixedOffset>, u32)> {
193 let path = path.as_ref();
194 let file_name = path.file_name()?.to_str()?;
195
196 let file_name = file_name.strip_suffix(".log")?;
198
199 let mut parts = file_name.rsplitn(3, '_');
202 let pid_str = parts.next()?;
203 let ts_str = parts.next()?;
204 let prefix = parts.next()?; let pid: u32 = pid_str.parse().ok()?;
207
208 let ts = DateTime::parse_from_str(ts_str, "%Y%m%dT%H%M%S%3f%z").ok()?;
210
211 Some((prefix.to_string(), ts, pid))
212}
213
214struct CandidateLogFile {
216 path: PathBuf,
217 size: u64,
218 age: Duration,
219}
220
221fn pid_protects_log_file(sys: &System, pid: u32, log_timestamp: DateTime<FixedOffset>) -> bool {
229 let Some(proc) = sys.process(Pid::from_u32(pid)) else {
230 return false;
231 };
232 let log_epoch_secs = log_timestamp.timestamp();
233 let proc_start_secs = i64::try_from(proc.start_time()).unwrap_or(i64::MAX);
234 if proc_start_secs > log_epoch_secs {
235 debug!("PID {pid} likely reused: process start {proc_start_secs}s > log timestamp {log_epoch_secs}s");
236 return false;
237 }
238 true
239}
240
241fn run_log_directory_cleanup_background(cfg: LogDirConfig, log_dir: &Path) {
242 let log_dir = log_dir.to_path_buf();
244 let handle = std::thread::spawn(move || {
245 if let Err(e) = run_log_directory_cleanup(cfg, &log_dir) {
246 warn!("Error during log directory cleanup in {:?}: {}", log_dir, e);
247 }
248 });
249
250 if let Ok(mut handle_opt) = LOG_CLEANUP_HANDLE.lock() {
252 debug_assert!(handle_opt.is_none(), "Log directory cleanup called multiple times.");
253 *handle_opt = Some(handle);
254 }
255}
256
257fn run_log_directory_cleanup(cfg: LogDirConfig, log_dir: &Path) -> io::Result<()> {
258 info!(
259 "starting log cleanup in {:?} (min_age={:?}, max_retention={:?}, max_size={} bytes)",
260 log_dir,
261 cfg.min_deletion_age,
262 cfg.max_retention_age,
263 ByteSize::new(cfg.size_limit)
264 );
265
266 let sys = System::new_with_specifics(RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()));
270
271 let mut candidates = Vec::<CandidateLogFile>::new();
273 let mut total_bytes: u64 = 0;
274 let mut candidate_deletion_bytes: u64 = 0;
275
276 let now = Utc::now();
277 let mut n_log_files = 0usize;
278
279 for entry in std::fs::read_dir(log_dir)? {
280 let Ok(entry) = entry.warn_error_fn(|| format!("read_dir error while reading {log_dir:?}")) else {
281 continue;
282 };
283
284 let path = entry.path();
285
286 let Ok(ft) = entry.file_type() else { continue };
287 if !ft.is_file() {
288 continue;
289 }
290
291 let Some((prefix, timestamp, pid)) = parse_log_file_name(&path) else {
292 debug!("ignoring unparseable log file {:?}", path);
293 continue;
294 };
295
296 if prefix != cfg.filename_prefix {
297 debug!("ignoring log file {:?} with differing prefix {prefix} (!={})", path, &cfg.filename_prefix);
298 continue;
299 }
300
301 let Ok(meta) = entry
303 .metadata()
304 .info_error_fn(|| format!("Reading metadata failed for {:?}", path))
305 else {
306 continue;
307 };
308
309 let size = meta.len();
310 total_bytes += size;
311 n_log_files += 1;
312
313 let Ok(age) = (now - timestamp.to_utc()).to_std() else {
314 debug!("Skipping deletion for very new log file {path:?}");
315 continue;
316 };
317
318 if age < cfg.min_deletion_age {
320 debug!("Skipping deletion for new log file {path:?}");
321 continue;
322 }
323
324 if pid_protects_log_file(&sys, pid, timestamp) {
326 debug!("Skipping deletion for log file {path:?} with active associated PID.");
327 continue;
328 }
329
330 candidates.push(CandidateLogFile { path, size, age });
332
333 candidate_deletion_bytes += size;
334 }
335
336 info!(
337 "Log Directory Cleanup: found {:?} of logs in {} log files, with {:?} in {} files eligible for deletion.",
338 ByteSize::new(total_bytes),
339 n_log_files,
340 ByteSize::new(candidate_deletion_bytes),
341 candidates.len()
342 );
343
344 let mut deleted_bytes: u64 = 0;
346 candidates.retain(|lf| {
347 if lf.age > cfg.max_retention_age {
348 let path = &lf.path;
349 match std::fs::remove_file(path) {
350 Ok(_) => {
351 deleted_bytes += lf.size;
352 debug!("Log Directory Cleanup: Removed old log file {path:?})");
353 },
354 Err(e) => {
355 if e.kind() == io::ErrorKind::NotFound {
358 deleted_bytes += lf.size;
359 debug!("Log Directory Cleanup: Old log file {path:?} already deleted.");
360 } else {
361 info!("Log Directory Cleanup: Error removing old log file {path:?}, skipping: {e}");
362 }
363 },
364 };
365 false
366 } else {
367 true
368 }
369 });
370
371 let mut n_pruned = 0;
373 if total_bytes - deleted_bytes > cfg.size_limit {
374 candidates.sort_by_key(|lf| std::cmp::Reverse(lf.age));
376 for lf in &candidates {
377 if total_bytes - deleted_bytes <= cfg.size_limit {
378 break;
379 }
380
381 match std::fs::remove_file(&lf.path) {
382 Ok(()) => {
383 deleted_bytes += lf.size;
384 n_pruned += 1;
385 debug!("Log Directory cleanup: Pruned log file {:?}.", lf.path);
386 },
387 Err(e) => {
388 if e.kind() == io::ErrorKind::NotFound {
389 deleted_bytes += lf.size;
390 n_pruned += 1;
391 debug!("Log Directory cleanup: Log file {:?} already deleted, ignoring.", lf.path);
392 } else {
393 info!("Log Directory Cleanup: Error removing size-pruned log file {:?}: {}", lf.path, e);
394 }
395 },
396 }
397 }
398 }
399
400 info!(
401 "Log Directory Cleanup: deleted {:?} in {} files",
402 ByteSize::new(deleted_bytes),
403 candidates.len() - n_pruned
404 );
405 Ok(())
406}
407
408#[cfg(test)]
409mod tests {
410
411 use chrono::{Datelike, Timelike};
412
413 use super::*;
414
415 #[test]
416 fn round_trip_make_and_parse() {
417 let dir = Path::new("/tmp");
418 let cfg = LogDirConfig::from_config(&crate::config::XetConfig::new());
419 let path = log_file_in_dir(&cfg, dir);
420 let (base, ts, pid) = parse_log_file_name(&path).expect("parse");
421 assert_eq!(base, cfg.filename_prefix);
422 assert!(pid > 0);
423
424 let fname = path.file_name().unwrap().to_str().unwrap();
426 let ts_part = fname
427 .strip_prefix(&format!("{}_", base))
428 .unwrap()
429 .strip_suffix(&format!("_{}.log", pid))
430 .unwrap();
431 assert_eq!(ts_part, ts.format("%Y%m%dT%H%M%S%3f%z").to_string());
432 }
433
434 #[test]
435 fn parse_known_file() {
436 let fname = "app_base_20250915T083210123-0700_12345.log";
437 let (base, ts, pid) = parse_log_file_name(fname).expect("parse");
438 assert_eq!(base, "app_base");
439 assert_eq!(pid, 12345);
440 assert_eq!(ts.format("%Y%m%dT%H%M%S%3f%z").to_string(), "20250915T083210123-0700");
441 assert_eq!(ts.year(), 2025);
442 assert_eq!(ts.month(), 9);
443 assert_eq!(ts.day(), 15);
444 assert_eq!(ts.hour(), 8);
445 assert_eq!(ts.minute(), 32);
446 assert_eq!(ts.second(), 10);
447 assert_eq!(ts.timestamp_subsec_millis(), 123);
448 assert_eq!(ts.offset().local_minus_utc(), -7 * 3600);
449 }
450
451 #[test]
452 fn allows_underscores_in_base() {
453 let fname = "my_cool_app_20240102T030405006+0530_999.log";
454 let (base, ts, pid) = parse_log_file_name(fname).expect("parse");
455 assert_eq!(base, "my_cool_app");
456 assert_eq!(pid, 999);
457 assert_eq!(ts.format("%Y%m%dT%H%M%S%3f%z").to_string(), "20240102T030405006+0530");
458 }
459
460 #[test]
461 fn parse_with_directory_path() {
462 let path = Path::new("/var/log/myprog/app_20250915T083210123-0700_12345.log");
463 let (base, _, pid) = parse_log_file_name(path).expect("parse");
464 assert_eq!(base, "app");
465 assert_eq!(pid, 12345);
466 }
467}