Skip to main content

rust_zero_core/
log.rs

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/// Severity threshold and event level for structured logs.
22#[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/// Encoding used for each emitted log record.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum LogEncoding {
48    Json,
49    Plain,
50}
51
52/// File rollover strategy.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum RotationPolicy {
55    /// Writes to a UTC date-stamped file and opens a new file when the date changes.
56    Daily,
57    /// Rolls the active file before it would exceed `max_bytes`.
58    ///
59    /// `max_backups == 0` retains all backups. Otherwise, only that many numbered backups
60    /// are retained.
61    Size { max_bytes: u64, max_backups: usize },
62}
63
64/// Destination for log records.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum LogTarget {
67    Console,
68    File {
69        directory: PathBuf,
70        rotation: RotationPolicy,
71    },
72}
73
74/// Standalone structured logger configuration.
75#[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    /// Number of UTC daily log files to retain, including the active day.
83    /// `None` retains daily files indefinitely. Size rotation continues to use `max_backups`.
84    pub retention_days: Option<u64>,
85    /// Compresses files after they leave the active daily or size-rotated position.
86    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    /// Retains only the most recent `days` UTC daily files. This setting has no effect on
141    /// console or size-rotated targets.
142    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    /// Enables gzip compression after a file is rotated out of the active position.
149    pub fn with_rotated_compression(mut self, enabled: bool) -> Self {
150        self.compress_rotated = enabled;
151        self
152    }
153}
154
155/// Opt-in desensitization for values containing secrets or personal information.
156pub trait Sensitive {
157    fn mask_sensitive(&self) -> Value;
158}
159
160/// One structured key/value pair.
161#[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/// Fields shared by logs emitted while handling one request or operation.
194#[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/// A cheap deterministic sampler that keeps an initial burst and then every Nth event.
217#[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/// Cloneable, process-wide structured logger.
244#[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    /// Creates a logger whose bounded writer thread keeps file or console I/O off callers.
284    ///
285    /// When the queue is full, records are discarded instead of blocking application work and
286    /// are reflected by [`Logger::dropped_records`].
287    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    /// Creates a logger backed by an application-provided writer.
307    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    /// Creates a bounded non-blocking logger backed by an application-provided local or remote
320    /// writer. Writer failures are accounted as dropped records because they happen off-thread.
321    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    /// Returns records discarded because the non-blocking queue was full or its writer failed.
346    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        // Framework-owned fields are inserted last so callers cannot spoof severity, service,
420        // timestamps, messages, or trace identity with a custom field.
421        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
832// Howard Hinnant's civil-from-days algorithm, with day zero at 1970-01-01.
833fn 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        // The worker blocks on one record, the queue holds one, and subsequent calls shed.
1019        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}