1use std::{
2 collections::BTreeMap,
3 fmt,
4 fs::{self, File, OpenOptions},
5 io::{self, Write},
6 path::{Path, PathBuf},
7 sync::{
8 atomic::{AtomicU64, Ordering},
9 mpsc::{self, TrySendError},
10 Arc, Mutex,
11 },
12 time::{SystemTime, UNIX_EPOCH},
13};
14
15use flate2::{write::GzEncoder, Compression};
16use serde::Serialize;
17use serde_json::{Map, Value};
18
19use crate::TraceContext;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
23pub enum LogLevel {
24 Debug,
25 Info,
26 Slow,
27 Warn,
28 Error,
29 Severe,
30}
31
32impl LogLevel {
33 fn as_str(self) -> &'static str {
34 match self {
35 Self::Debug => "debug",
36 Self::Info => "info",
37 Self::Slow => "slow",
38 Self::Warn => "warn",
39 Self::Error => "error",
40 Self::Severe => "severe",
41 }
42 }
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum LogEncoding {
48 Json,
49 Plain,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum RotationPolicy {
55 Daily,
57 Size { max_bytes: u64, max_backups: usize },
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum LogTarget {
67 Console,
68 File {
69 directory: PathBuf,
70 rotation: RotationPolicy,
71 },
72}
73
74#[derive(Debug, Clone)]
76pub struct LogConfig {
77 pub service_name: String,
78 pub level: LogLevel,
79 pub encoding: LogEncoding,
80 pub target: LogTarget,
81 pub max_content_length: Option<usize>,
82 pub retention_days: Option<u64>,
85 pub compress_rotated: bool,
87}
88
89impl LogConfig {
90 pub fn console(service_name: impl Into<String>) -> Self {
91 Self {
92 service_name: service_name.into(),
93 level: LogLevel::Info,
94 encoding: LogEncoding::Json,
95 target: LogTarget::Console,
96 max_content_length: None,
97 retention_days: None,
98 compress_rotated: false,
99 }
100 }
101
102 pub fn file(
103 service_name: impl Into<String>,
104 directory: impl Into<PathBuf>,
105 rotation: RotationPolicy,
106 ) -> Self {
107 Self {
108 service_name: service_name.into(),
109 level: LogLevel::Info,
110 encoding: LogEncoding::Json,
111 target: LogTarget::File {
112 directory: directory.into(),
113 rotation,
114 },
115 max_content_length: None,
116 retention_days: None,
117 compress_rotated: false,
118 }
119 }
120
121 pub fn with_level(mut self, level: LogLevel) -> Self {
122 self.level = level;
123 self
124 }
125
126 pub fn with_encoding(mut self, encoding: LogEncoding) -> Self {
127 self.encoding = encoding;
128 self
129 }
130
131 pub fn with_max_content_length(mut self, length: usize) -> Self {
132 assert!(
133 length > 0,
134 "maximum content length must be greater than zero"
135 );
136 self.max_content_length = Some(length);
137 self
138 }
139
140 pub fn with_retention_days(mut self, days: u64) -> Self {
143 assert!(days > 0, "log retention must be at least one day");
144 self.retention_days = Some(days);
145 self
146 }
147
148 pub fn with_rotated_compression(mut self, enabled: bool) -> Self {
150 self.compress_rotated = enabled;
151 self
152 }
153}
154
155pub trait Sensitive {
157 fn mask_sensitive(&self) -> Value;
158}
159
160#[derive(Debug, Clone, PartialEq)]
162pub struct LogField {
163 key: String,
164 value: Value,
165}
166
167impl LogField {
168 pub fn new(key: impl Into<String>, value: impl Into<Value>) -> Self {
169 Self {
170 key: key.into(),
171 value: value.into(),
172 }
173 }
174
175 pub fn from_serializable(
176 key: impl Into<String>,
177 value: impl Serialize,
178 ) -> Result<Self, serde_json::Error> {
179 Ok(Self {
180 key: key.into(),
181 value: serde_json::to_value(value)?,
182 })
183 }
184
185 pub fn sensitive(key: impl Into<String>, value: &impl Sensitive) -> Self {
186 Self {
187 key: key.into(),
188 value: value.mask_sensitive(),
189 }
190 }
191}
192
193#[derive(Debug, Clone, Default)]
195pub struct LogContext {
196 fields: BTreeMap<String, Value>,
197 trace: Option<TraceContext>,
198}
199
200impl LogContext {
201 pub fn new() -> Self {
202 Self::default()
203 }
204
205 pub fn with_field(mut self, field: LogField) -> Self {
206 self.fields.insert(field.key, field.value);
207 self
208 }
209
210 pub fn with_trace(mut self, trace: TraceContext) -> Self {
211 self.trace = Some(trace);
212 self
213 }
214}
215
216#[derive(Debug)]
218pub struct LogSampler {
219 first: u64,
220 thereafter: u64,
221 seen: AtomicU64,
222}
223
224impl LogSampler {
225 pub fn new(first: u64, thereafter: u64) -> Self {
226 assert!(
227 thereafter > 0,
228 "sampling interval must be greater than zero"
229 );
230 Self {
231 first,
232 thereafter,
233 seen: AtomicU64::new(0),
234 }
235 }
236
237 pub fn allow(&self) -> bool {
238 let seen = self.seen.fetch_add(1, Ordering::Relaxed);
239 seen < self.first || (seen - self.first).is_multiple_of(self.thereafter)
240 }
241}
242
243#[derive(Clone)]
245pub struct Logger {
246 config: Arc<LogConfig>,
247 sink: Arc<Mutex<Sink>>,
248 dropped: Arc<AtomicU64>,
249}
250
251impl fmt::Debug for Logger {
252 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
253 formatter
254 .debug_struct("Logger")
255 .field("config", &self.config)
256 .finish_non_exhaustive()
257 }
258}
259
260impl Logger {
261 pub fn new(config: LogConfig) -> Result<Self, LogError> {
262 validate_config(&config)?;
263 let sink = match &config.target {
264 LogTarget::Console => Sink::Writer(Box::new(io::stdout())),
265 LogTarget::File {
266 directory,
267 rotation,
268 } => Sink::Rotating(RotatingFile::new(
269 directory,
270 &config.service_name,
271 *rotation,
272 config.retention_days,
273 config.compress_rotated,
274 )?),
275 };
276 Ok(Self {
277 config: Arc::new(config),
278 sink: Arc::new(Mutex::new(sink)),
279 dropped: Arc::new(AtomicU64::new(0)),
280 })
281 }
282
283 pub fn new_non_blocking(config: LogConfig, capacity: usize) -> Result<Self, LogError> {
288 validate_capacity(capacity)?;
289 validate_config(&config)?;
290 let sink = match &config.target {
291 LogTarget::Console => Sink::Writer(Box::new(io::stdout())),
292 LogTarget::File {
293 directory,
294 rotation,
295 } => Sink::Rotating(RotatingFile::new(
296 directory,
297 &config.service_name,
298 *rotation,
299 config.retention_days,
300 config.compress_rotated,
301 )?),
302 };
303 Self::from_non_blocking_sink(config, sink, capacity)
304 }
305
306 pub fn to_writer(
308 config: LogConfig,
309 writer: impl Write + Send + 'static,
310 ) -> Result<Self, LogError> {
311 validate_config(&config)?;
312 Ok(Self {
313 config: Arc::new(config),
314 sink: Arc::new(Mutex::new(Sink::Writer(Box::new(writer)))),
315 dropped: Arc::new(AtomicU64::new(0)),
316 })
317 }
318
319 pub fn to_non_blocking_writer(
322 config: LogConfig,
323 writer: impl Write + Send + 'static,
324 capacity: usize,
325 ) -> Result<Self, LogError> {
326 validate_capacity(capacity)?;
327 validate_config(&config)?;
328 Self::from_non_blocking_sink(config, Sink::Writer(Box::new(writer)), capacity)
329 }
330
331 fn from_non_blocking_sink(
332 config: LogConfig,
333 sink: Sink,
334 capacity: usize,
335 ) -> Result<Self, LogError> {
336 let dropped = Arc::new(AtomicU64::new(0));
337 let async_sink = AsyncSink::spawn(sink, capacity, Arc::clone(&dropped))?;
338 Ok(Self {
339 config: Arc::new(config),
340 sink: Arc::new(Mutex::new(Sink::Async(async_sink))),
341 dropped,
342 })
343 }
344
345 pub fn dropped_records(&self) -> u64 {
347 self.dropped.load(Ordering::Relaxed)
348 }
349
350 pub fn enabled(&self, level: LogLevel) -> bool {
351 level >= self.config.level
352 }
353
354 pub fn log(
355 &self,
356 level: LogLevel,
357 message: impl AsRef<str>,
358 fields: impl IntoIterator<Item = LogField>,
359 ) -> Result<bool, LogError> {
360 self.log_with_context(level, message, None, fields)
361 }
362
363 pub fn log_with_context(
364 &self,
365 level: LogLevel,
366 message: impl AsRef<str>,
367 context: Option<&LogContext>,
368 fields: impl IntoIterator<Item = LogField>,
369 ) -> Result<bool, LogError> {
370 if !self.enabled(level) {
371 return Ok(false);
372 }
373
374 let record = self.record(level, message.as_ref(), context, fields);
375 let mut encoded = match self.config.encoding {
376 LogEncoding::Json => serde_json::to_vec(&record)?,
377 LogEncoding::Plain => encode_plain(&record),
378 };
379 encoded.push(b'\n');
380 let written = self
381 .sink
382 .lock()
383 .map_err(|_| LogError::Poisoned)?
384 .write_all(&encoded)?;
385 Ok(written)
386 }
387
388 pub fn log_sampled(
389 &self,
390 sampler: &LogSampler,
391 level: LogLevel,
392 message: impl AsRef<str>,
393 context: Option<&LogContext>,
394 fields: impl IntoIterator<Item = LogField>,
395 ) -> Result<bool, LogError> {
396 if !self.enabled(level) || !sampler.allow() {
397 return Ok(false);
398 }
399 self.log_with_context(level, message, context, fields)
400 }
401
402 fn record(
403 &self,
404 level: LogLevel,
405 message: &str,
406 context: Option<&LogContext>,
407 fields: impl IntoIterator<Item = LogField>,
408 ) -> Map<String, Value> {
409 let mut record = Map::new();
410 if let Some(context) = context {
411 for (key, value) in &context.fields {
412 record.insert(key.clone(), value.clone());
413 }
414 }
415 for field in fields {
416 record.insert(field.key, field.value);
417 }
418
419 record.insert("timestamp".to_owned(), Value::String(timestamp()));
422 record.insert("level".to_owned(), Value::String(level.as_str().to_owned()));
423 record.insert(
424 "service".to_owned(),
425 Value::String(self.config.service_name.clone()),
426 );
427 record.insert(
428 "message".to_owned(),
429 Value::String(truncate(message, self.config.max_content_length)),
430 );
431 if let Some(trace) = context.and_then(|context| context.trace.as_ref()) {
432 record.insert("trace_id".to_owned(), Value::String(trace.trace_id()));
433 record.insert("span_id".to_owned(), Value::String(trace.span_id()));
434 }
435 record
436 }
437}
438
439enum Sink {
440 Writer(Box<dyn Write + Send>),
441 Rotating(RotatingFile),
442 Async(AsyncSink),
443}
444
445impl Sink {
446 fn write_all(&mut self, bytes: &[u8]) -> io::Result<bool> {
447 match self {
448 Self::Writer(writer) => {
449 writer.write_all(bytes)?;
450 writer.flush()?;
451 Ok(true)
452 }
453 Self::Rotating(file) => {
454 file.write_all(bytes)?;
455 Ok(true)
456 }
457 Self::Async(sink) => Ok(sink.try_write(bytes)),
458 }
459 }
460}
461
462struct AsyncSink {
463 sender: mpsc::SyncSender<Vec<u8>>,
464 dropped: Arc<AtomicU64>,
465}
466
467impl AsyncSink {
468 fn spawn(mut sink: Sink, capacity: usize, dropped: Arc<AtomicU64>) -> io::Result<Self> {
469 let (sender, receiver) = mpsc::sync_channel::<Vec<u8>>(capacity);
470 let worker_dropped = Arc::clone(&dropped);
471 std::thread::Builder::new()
472 .name("rust-zero-log-writer".to_owned())
473 .spawn(move || {
474 while let Ok(record) = receiver.recv() {
475 if !matches!(sink.write_all(&record), Ok(true)) {
476 worker_dropped.fetch_add(1, Ordering::Relaxed);
477 }
478 }
479 })?;
480 Ok(Self { sender, dropped })
481 }
482
483 fn try_write(&self, bytes: &[u8]) -> bool {
484 match self.sender.try_send(bytes.to_vec()) {
485 Ok(()) => true,
486 Err(TrySendError::Full(_)) | Err(TrySendError::Disconnected(_)) => {
487 self.dropped.fetch_add(1, Ordering::Relaxed);
488 false
489 }
490 }
491 }
492}
493
494struct RotatingFile {
495 directory: PathBuf,
496 service_name: String,
497 rotation: RotationPolicy,
498 file: Option<File>,
499 active_path: PathBuf,
500 active_day: i64,
501 bytes_written: u64,
502 retention_days: Option<u64>,
503 compress_rotated: bool,
504}
505
506impl RotatingFile {
507 fn new(
508 directory: &Path,
509 service_name: &str,
510 rotation: RotationPolicy,
511 retention_days: Option<u64>,
512 compress_rotated: bool,
513 ) -> io::Result<Self> {
514 fs::create_dir_all(directory)?;
515 let service_name = safe_file_name(service_name);
516 let active_day = unix_day();
517 let active_path = log_path(directory, &service_name, rotation, active_day);
518 let file = append_file(&active_path)?;
519 let bytes_written = file.metadata()?.len();
520 let rotating = Self {
521 directory: directory.to_owned(),
522 service_name,
523 rotation,
524 file: Some(file),
525 active_path,
526 active_day,
527 bytes_written,
528 retention_days,
529 compress_rotated,
530 };
531 if matches!(rotation, RotationPolicy::Daily) {
532 rotating.maintain_daily_files(active_day)?;
533 }
534 Ok(rotating)
535 }
536
537 fn write_all(&mut self, bytes: &[u8]) -> io::Result<()> {
538 match self.rotation {
539 RotationPolicy::Daily if unix_day() != self.active_day => {
540 if let Err(error) = self.rotate_daily() {
541 self.reopen_active();
542 return Err(error);
543 }
544 }
545 RotationPolicy::Size { max_bytes, .. }
546 if self.bytes_written > 0
547 && self.bytes_written.saturating_add(bytes.len() as u64) > max_bytes =>
548 {
549 if let Err(error) = self.rotate_size() {
550 self.reopen_active();
551 return Err(error);
552 }
553 }
554 _ => {}
555 }
556 let file = self.file.as_mut().ok_or_else(|| {
557 io::Error::new(io::ErrorKind::NotConnected, "rotating log file is closed")
558 })?;
559 file.write_all(bytes)?;
560 file.flush()?;
561 self.bytes_written = self.bytes_written.saturating_add(bytes.len() as u64);
562 Ok(())
563 }
564
565 fn rotate_daily(&mut self) -> io::Result<()> {
566 self.file.take();
567 let previous_path = self.active_path.clone();
568 self.active_day = unix_day();
569 self.active_path = log_path(
570 &self.directory,
571 &self.service_name,
572 self.rotation,
573 self.active_day,
574 );
575 let file = append_file(&self.active_path)?;
576 self.bytes_written = file.metadata()?.len();
577 self.file = Some(file);
578 if self.compress_rotated && previous_path != self.active_path && previous_path.exists() {
579 compress_file(&previous_path)?;
580 }
581 self.maintain_daily_files(self.active_day)?;
582 Ok(())
583 }
584
585 fn rotate_size(&mut self) -> io::Result<()> {
586 let RotationPolicy::Size { max_backups, .. } = self.rotation else {
587 return Ok(());
588 };
589 self.file.take();
590
591 if max_backups == 0 {
592 let backup = self
593 .directory
594 .join(format!("{}.{}.log", self.service_name, unix_nanos()));
595 fs::rename(&self.active_path, &backup)?;
596 if self.compress_rotated {
597 compress_file(&backup)?;
598 }
599 } else {
600 let oldest = backup_path(&self.active_path, max_backups);
601 remove_backup(&oldest)?;
602 for index in (1..max_backups).rev() {
603 let from = backup_path(&self.active_path, index);
604 if let Some(from) = existing_backup(&from) {
605 let mut to = backup_path(&self.active_path, index + 1);
606 if is_gzip(&from) {
607 to = gzip_path(&to);
608 }
609 fs::rename(from, to)?;
610 }
611 }
612 if self.active_path.exists() {
613 let backup = backup_path(&self.active_path, 1);
614 fs::rename(&self.active_path, &backup)?;
615 if self.compress_rotated {
616 compress_file(&backup)?;
617 }
618 }
619 }
620
621 self.file = Some(append_file(&self.active_path)?);
622 self.bytes_written = 0;
623 Ok(())
624 }
625
626 fn reopen_active(&mut self) {
627 if self.file.is_none() {
628 self.file = append_file(&self.active_path).ok();
629 }
630 }
631
632 fn maintain_daily_files(&self, active_day: i64) -> io::Result<()> {
633 let prefix = format!("{}.", self.service_name);
634 let active_date = date_from_unix_day(active_day);
635 let cutoff = self
636 .retention_days
637 .map(|days| date_from_unix_day(active_day - days.saturating_sub(1) as i64));
638
639 for entry in fs::read_dir(&self.directory)? {
640 let entry = entry?;
641 if !entry.file_type()?.is_file() {
642 continue;
643 }
644 let name = entry.file_name();
645 let name = name.to_string_lossy();
646 let Some(date) = daily_file_date(&name, &prefix) else {
647 continue;
648 };
649 if date < active_date.as_str() && self.compress_rotated && !name.ends_with(".gz") {
650 compress_file(&entry.path())?;
651 }
652 if cutoff.as_deref().is_some_and(|cutoff| date < cutoff) {
653 let path = entry.path();
654 if path.exists() {
655 fs::remove_file(path)?;
656 }
657 let compressed = gzip_path(&entry.path());
658 if compressed.exists() {
659 fs::remove_file(compressed)?;
660 }
661 }
662 }
663 Ok(())
664 }
665}
666
667fn daily_file_date<'a>(name: &'a str, prefix: &str) -> Option<&'a str> {
668 let remainder = name.strip_prefix(prefix)?;
669 let date = remainder
670 .strip_suffix(".log")
671 .or_else(|| remainder.strip_suffix(".log.gz"))?;
672 (date.len() == 10
673 && date.as_bytes().get(4) == Some(&b'-')
674 && date.as_bytes().get(7) == Some(&b'-')
675 && date
676 .chars()
677 .all(|value| value.is_ascii_digit() || value == '-'))
678 .then_some(date)
679}
680
681fn gzip_path(path: &Path) -> PathBuf {
682 let mut compressed = path.as_os_str().to_owned();
683 compressed.push(".gz");
684 PathBuf::from(compressed)
685}
686
687fn is_gzip(path: &Path) -> bool {
688 path.extension().is_some_and(|extension| extension == "gz")
689}
690
691fn existing_backup(path: &Path) -> Option<PathBuf> {
692 path.exists()
693 .then(|| path.to_owned())
694 .or_else(|| gzip_path(path).exists().then(|| gzip_path(path)))
695}
696
697fn remove_backup(path: &Path) -> io::Result<()> {
698 if path.exists() {
699 fs::remove_file(path)?;
700 }
701 let compressed = gzip_path(path);
702 if compressed.exists() {
703 fs::remove_file(compressed)?;
704 }
705 Ok(())
706}
707
708fn compress_file(path: &Path) -> io::Result<PathBuf> {
709 let compressed = gzip_path(path);
710 let mut input = File::open(path)?;
711 let output = File::create(&compressed)?;
712 let mut encoder = GzEncoder::new(output, Compression::default());
713 io::copy(&mut input, &mut encoder)?;
714 encoder.finish()?.sync_all()?;
715 fs::remove_file(path)?;
716 Ok(compressed)
717}
718
719fn validate_config(config: &LogConfig) -> Result<(), LogError> {
720 if config.service_name.trim().is_empty() {
721 return Err(LogError::EmptyServiceName);
722 }
723 if let LogTarget::File {
724 rotation: RotationPolicy::Size { max_bytes, .. },
725 ..
726 } = &config.target
727 {
728 if *max_bytes == 0 {
729 return Err(LogError::InvalidMaxSize);
730 }
731 }
732 Ok(())
733}
734
735fn validate_capacity(capacity: usize) -> Result<(), LogError> {
736 if capacity == 0 {
737 Err(LogError::InvalidBufferCapacity)
738 } else {
739 Ok(())
740 }
741}
742
743fn append_file(path: &Path) -> io::Result<File> {
744 OpenOptions::new().create(true).append(true).open(path)
745}
746
747fn log_path(directory: &Path, service_name: &str, rotation: RotationPolicy, day: i64) -> PathBuf {
748 match rotation {
749 RotationPolicy::Daily => {
750 directory.join(format!("{service_name}.{}.log", date_from_unix_day(day)))
751 }
752 RotationPolicy::Size { .. } => directory.join(format!("{service_name}.log")),
753 }
754}
755
756fn backup_path(active: &Path, index: usize) -> PathBuf {
757 let mut path = active.as_os_str().to_owned();
758 path.push(format!(".{index}"));
759 PathBuf::from(path)
760}
761
762fn safe_file_name(value: &str) -> String {
763 value
764 .chars()
765 .map(|character| {
766 if character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') {
767 character
768 } else {
769 '_'
770 }
771 })
772 .collect()
773}
774
775fn truncate(value: &str, limit: Option<usize>) -> String {
776 let Some(limit) = limit else {
777 return value.to_owned();
778 };
779 value.chars().take(limit).collect()
780}
781
782fn encode_plain(record: &Map<String, Value>) -> Vec<u8> {
783 let timestamp = record["timestamp"].as_str().unwrap_or_default();
784 let level = record["level"].as_str().unwrap_or_default();
785 let service = record["service"].as_str().unwrap_or_default();
786 let message = record["message"].as_str().unwrap_or_default();
787 let mut output = format!("{timestamp} {level} {service}: {message}");
788 for (key, value) in record {
789 if matches!(key.as_str(), "timestamp" | "level" | "service" | "message") {
790 continue;
791 }
792 output.push(' ');
793 output.push_str(key);
794 output.push('=');
795 output.push_str(&value.to_string());
796 }
797 output.into_bytes()
798}
799
800fn timestamp() -> String {
801 let duration = SystemTime::now()
802 .duration_since(UNIX_EPOCH)
803 .unwrap_or_default();
804 let seconds = duration.as_secs() as i64;
805 let day = seconds.div_euclid(86_400);
806 let seconds_in_day = seconds.rem_euclid(86_400);
807 let hour = seconds_in_day / 3_600;
808 let minute = seconds_in_day % 3_600 / 60;
809 let second = seconds_in_day % 60;
810 format!(
811 "{}T{hour:02}:{minute:02}:{second:02}.{:03}Z",
812 date_from_unix_day(day),
813 duration.subsec_millis()
814 )
815}
816
817fn unix_day() -> i64 {
818 SystemTime::now()
819 .duration_since(UNIX_EPOCH)
820 .unwrap_or_default()
821 .as_secs()
822 .div_euclid(86_400) as i64
823}
824
825fn unix_nanos() -> u128 {
826 SystemTime::now()
827 .duration_since(UNIX_EPOCH)
828 .unwrap_or_default()
829 .as_nanos()
830}
831
832fn date_from_unix_day(day: i64) -> String {
834 let day = day + 719_468;
835 let era = if day >= 0 { day } else { day - 146_096 } / 146_097;
836 let day_of_era = day - era * 146_097;
837 let year_of_era =
838 (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
839 let mut year = year_of_era + era * 400;
840 let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
841 let month_prime = (5 * day_of_year + 2) / 153;
842 let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
843 let month = month_prime + if month_prime < 10 { 3 } else { -9 };
844 year += i64::from(month <= 2);
845 format!("{year:04}-{month:02}-{day:02}")
846}
847
848#[derive(Debug)]
849pub enum LogError {
850 EmptyServiceName,
851 InvalidMaxSize,
852 InvalidBufferCapacity,
853 Io(io::Error),
854 Serialize(serde_json::Error),
855 Poisoned,
856}
857
858impl fmt::Display for LogError {
859 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
860 match self {
861 Self::EmptyServiceName => formatter.write_str("log service name cannot be empty"),
862 Self::InvalidMaxSize => {
863 formatter.write_str("log rotation maximum size must be greater than zero")
864 }
865 Self::InvalidBufferCapacity => {
866 formatter.write_str("log buffer capacity must be greater than zero")
867 }
868 Self::Io(error) => write!(formatter, "log I/O error: {error}"),
869 Self::Serialize(error) => write!(formatter, "log serialization error: {error}"),
870 Self::Poisoned => formatter.write_str("log writer mutex poisoned"),
871 }
872 }
873}
874
875impl std::error::Error for LogError {
876 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
877 match self {
878 Self::Io(error) => Some(error),
879 Self::Serialize(error) => Some(error),
880 _ => None,
881 }
882 }
883}
884
885impl From<io::Error> for LogError {
886 fn from(error: io::Error) -> Self {
887 Self::Io(error)
888 }
889}
890
891impl From<serde_json::Error> for LogError {
892 fn from(error: serde_json::Error) -> Self {
893 Self::Serialize(error)
894 }
895}
896
897#[cfg(test)]
898mod tests {
899 use super::*;
900 use flate2::read::GzDecoder;
901 use std::io::Read;
902 use std::sync::{Condvar, MutexGuard};
903
904 #[derive(Clone, Default)]
905 struct SharedWriter(Arc<Mutex<Vec<u8>>>);
906
907 impl Write for SharedWriter {
908 fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
909 self.0.lock().unwrap().extend_from_slice(bytes);
910 Ok(bytes.len())
911 }
912
913 fn flush(&mut self) -> io::Result<()> {
914 Ok(())
915 }
916 }
917
918 #[derive(Clone, Default)]
919 struct BlockingWriter {
920 state: Arc<(Mutex<bool>, Condvar)>,
921 }
922
923 impl BlockingWriter {
924 fn release(&self) {
925 let (lock, ready) = &*self.state;
926 *lock.lock().unwrap() = true;
927 ready.notify_all();
928 }
929 }
930
931 impl Write for BlockingWriter {
932 fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
933 let (lock, ready) = &*self.state;
934 let mut released: MutexGuard<'_, bool> = lock.lock().unwrap();
935 while !*released {
936 released = ready.wait(released).unwrap();
937 }
938 Ok(bytes.len())
939 }
940
941 fn flush(&mut self) -> io::Result<()> {
942 Ok(())
943 }
944 }
945
946 #[test]
947 fn emits_json_with_context_and_masks_sensitive_fields() {
948 struct Credentials;
949 impl Sensitive for Credentials {
950 fn mask_sensitive(&self) -> Value {
951 serde_json::json!({"password": "******"})
952 }
953 }
954
955 let output = SharedWriter::default();
956 let logger =
957 Logger::to_writer(LogConfig::console("users"), output.clone()).expect("valid logger");
958 let trace =
959 TraceContext::parse("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01").unwrap();
960 let context = LogContext::new()
961 .with_field(LogField::new("request_id", "request-1"))
962 .with_trace(trace);
963
964 logger
965 .log_with_context(
966 LogLevel::Info,
967 "authenticated",
968 Some(&context),
969 [LogField::sensitive("credentials", &Credentials)],
970 )
971 .unwrap();
972
973 let bytes = output.0.lock().unwrap().clone();
974 let record: Value = serde_json::from_slice(&bytes).unwrap();
975 assert_eq!(record["service"], "users");
976 assert_eq!(record["request_id"], "request-1");
977 assert_eq!(record["trace_id"], "4bf92f3577b34da6a3ce929d0e0e4736");
978 assert_eq!(record["credentials"]["password"], "******");
979 }
980
981 #[test]
982 fn filters_levels_truncates_content_and_samples() {
983 let output = SharedWriter::default();
984 let logger = Logger::to_writer(
985 LogConfig::console("orders")
986 .with_level(LogLevel::Warn)
987 .with_max_content_length(4),
988 output.clone(),
989 )
990 .unwrap();
991 let sampler = LogSampler::new(1, 3);
992
993 assert!(!logger.log(LogLevel::Info, "hidden", []).unwrap());
994 for _ in 0..5 {
995 logger
996 .log_sampled(&sampler, LogLevel::Error, "abcdef", None, [])
997 .unwrap();
998 }
999
1000 let bytes = output.0.lock().unwrap().clone();
1001 let lines: Vec<_> = bytes
1002 .split(|byte| *byte == b'\n')
1003 .filter(|line| !line.is_empty())
1004 .collect();
1005 assert_eq!(lines.len(), 3);
1006 assert!(lines
1007 .iter()
1008 .all(|line| serde_json::from_slice::<Value>(line).unwrap()["message"] == "abcd"));
1009 }
1010
1011 #[test]
1012 fn bounded_writer_drops_without_blocking_and_accounts_records() {
1013 let writer = BlockingWriter::default();
1014 let logger =
1015 Logger::to_non_blocking_writer(LogConfig::console("orders"), writer.clone(), 1)
1016 .unwrap();
1017
1018 assert!(logger.log(LogLevel::Info, "one", []).unwrap());
1020 std::thread::yield_now();
1021 let _ = logger.log(LogLevel::Info, "two", []).unwrap();
1022 for index in 0..10 {
1023 let _ = logger
1024 .log(LogLevel::Info, "overflow", [LogField::new("index", index)])
1025 .unwrap();
1026 }
1027 assert!(logger.dropped_records() > 0);
1028 writer.release();
1029 }
1030
1031 #[test]
1032 fn rejects_an_empty_non_blocking_queue() {
1033 assert!(matches!(
1034 Logger::to_non_blocking_writer(LogConfig::console("api"), io::sink(), 0),
1035 Err(LogError::InvalidBufferCapacity)
1036 ));
1037 }
1038
1039 #[test]
1040 fn rotates_size_limited_files() {
1041 let directory = std::env::temp_dir().join(format!("rust-zero-log-{}", unix_nanos()));
1042 let config = LogConfig::file(
1043 "gateway",
1044 &directory,
1045 RotationPolicy::Size {
1046 max_bytes: 80,
1047 max_backups: 2,
1048 },
1049 );
1050 let logger = Logger::new(config).unwrap();
1051
1052 for index in 0..4 {
1053 logger
1054 .log(
1055 LogLevel::Info,
1056 "a record long enough to rotate",
1057 [LogField::new("index", index)],
1058 )
1059 .unwrap();
1060 }
1061
1062 assert!(directory.join("gateway.log").exists());
1063 assert!(directory.join("gateway.log.1").exists());
1064 drop(logger);
1065 fs::remove_dir_all(directory).unwrap();
1066 }
1067
1068 #[test]
1069 fn compresses_and_limits_size_rotated_files() {
1070 let directory = std::env::temp_dir().join(format!("rust-zero-log-gzip-{}", unix_nanos()));
1071 let config = LogConfig::file(
1072 "gateway",
1073 &directory,
1074 RotationPolicy::Size {
1075 max_bytes: 80,
1076 max_backups: 2,
1077 },
1078 )
1079 .with_rotated_compression(true);
1080 let logger = Logger::new(config).unwrap();
1081
1082 for index in 0..8 {
1083 logger
1084 .log(
1085 LogLevel::Info,
1086 "a record long enough to rotate",
1087 [LogField::new("index", index)],
1088 )
1089 .unwrap();
1090 }
1091
1092 let newest = directory.join("gateway.log.1.gz");
1093 assert!(newest.exists());
1094 assert!(directory.join("gateway.log.2.gz").exists());
1095 assert!(!directory.join("gateway.log.3.gz").exists());
1096 let mut decoded = String::new();
1097 GzDecoder::new(File::open(newest).unwrap())
1098 .read_to_string(&mut decoded)
1099 .unwrap();
1100 assert!(decoded.contains("a record long enough to rotate"));
1101 drop(logger);
1102 fs::remove_dir_all(directory).unwrap();
1103 }
1104
1105 #[test]
1106 fn compresses_and_expires_daily_files_on_startup() {
1107 let directory = std::env::temp_dir().join(format!("rust-zero-log-daily-{}", unix_nanos()));
1108 fs::create_dir_all(&directory).unwrap();
1109 let today = unix_day();
1110 let expired = directory.join(format!(
1111 "api.{}.log",
1112 date_from_unix_day(today.saturating_sub(3))
1113 ));
1114 let retained = directory.join(format!(
1115 "api.{}.log",
1116 date_from_unix_day(today.saturating_sub(1))
1117 ));
1118 fs::write(&expired, b"expired").unwrap();
1119 fs::write(&retained, b"retained").unwrap();
1120
1121 let logger = Logger::new(
1122 LogConfig::file("api", &directory, RotationPolicy::Daily)
1123 .with_retention_days(2)
1124 .with_rotated_compression(true),
1125 )
1126 .unwrap();
1127
1128 assert!(!expired.exists());
1129 assert!(!gzip_path(&expired).exists());
1130 assert!(!retained.exists());
1131 assert!(gzip_path(&retained).exists());
1132 assert!(directory
1133 .join(format!("api.{}.log", date_from_unix_day(today)))
1134 .exists());
1135 drop(logger);
1136 fs::remove_dir_all(directory).unwrap();
1137 }
1138
1139 #[test]
1140 fn converts_epoch_to_expected_date() {
1141 assert_eq!(date_from_unix_day(0), "1970-01-01");
1142 assert_eq!(date_from_unix_day(20_665), "2026-07-31");
1143 }
1144}