diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e90add5..3a81d39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,7 +87,7 @@ jobs: cargo run --no-default-features --example simple_stdout cargo run --features="layout-json" --example json_stdout cargo run --features="layout-json,append-rolling-file" --example rolling_file - cargo run --features="layout-json,append-single-file" --example single_file + cargo run --features="layout-json,append-rolling-file" --example single_file cargo run --features="fastrace/enable,diagnostic-fastrace,layout-google-cloud-logging" --example google_cloud_logging cargo run --features="fastrace/enable,append-fastrace,diagnostic-fastrace" --example fastrace diff --git a/CHANGELOG.md b/CHANGELOG.md index 120b020..b02dd86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ All notable changes to this project will be documented in this file. ### Breaking changes * `JsonLayout` now collects diagnostics context into a separate field `diags`. +* `SingleFile` appender is removed. You can replace it with `RollingFile` with `Rotation::Never`. +* `RollingFile` appender now requires `filename` when constructing. +* `RollingFile`'s `filename_prefix` is now renamed to mandatory `filename`. +* `RollingFile`'s `max_log_files` and `max_file_size` now take `NonZeroUsize`. +* RollingFile's rollover strategy has been changed: + ``` + from: + app.log + app.1.log + app.2.log + + to: + app.log + app.1.log - old app.log + app.2.log - old app.1.log + - old app.2.log deleted + ``` ## [0.27.0] 2025-08-18 diff --git a/Cargo.toml b/Cargo.toml index a298ee4..94fd143 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,7 +43,6 @@ append-opentelemetry = [ "dep:opentelemetry_sdk", ] append-rolling-file = ["internal-non-blocking"] -append-single-file = ["internal-non-blocking"] append-syslog = ["internal-non-blocking", "dep:fasyslog"] # Layouts @@ -130,7 +129,7 @@ required-features = ["append-rolling-file", "layout-json"] doc-scrape-examples = true name = "single_file" path = "examples/single_file.rs" -required-features = ["append-single-file", "layout-json"] +required-features = ["append-rolling-file", "layout-json"] [[example]] doc-scrape-examples = true diff --git a/README.md b/README.md index bd8cd67..e736ad5 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ Other appenders, filters, layouts, and diagnostics are still evolving and may ch The following components yet to be unstabilized have known production usage and are considered reliable: -* Appenders: `Fastrace`, `OpentelemetryLog`, `SingleFile`, and `RollingFile` +* Appenders: `Fastrace`, `OpentelemetryLog`, and `RollingFile` * Layouts: `LogfmtLayout` and `GoogleCloudLoggingLayout` * Diagnostics: `FastraceDiagnostic` @@ -122,7 +122,7 @@ The rest components, due to their external dependencies and several missing feat **What are the missing features?** -Before stabilize `SingleFile`, `RollingFile` and `Syslog` appenders that depend on the `NonBlocking` utility, I need to decide whether an `AsyncAppend` composition is better (see [#145](https://github.com/fast/logforth/issues/145)). +Before stabilize `RollingFile` and `Syslog` appenders that depend on the `NonBlocking` utility, I need to decide whether an `AsyncAppend` composition is better (see [#145](https://github.com/fast/logforth/issues/145)). Otherwise, how to share utilities like `NonBlocking` and `LevelColor` between different separate crates without duplicating code is still an open question. diff --git a/examples/rolling_file.rs b/examples/rolling_file.rs index 18a3e85..be18826 100644 --- a/examples/rolling_file.rs +++ b/examples/rolling_file.rs @@ -17,10 +17,9 @@ use logforth::append::rolling_file::Rotation; use logforth::layout::JsonLayout; fn main() { - let (rolling_writer, _guard) = RollingFileBuilder::new("logs") + let (rolling_writer, _guard) = RollingFileBuilder::new("logs", "my_app") .layout(JsonLayout::default()) .rotation(Rotation::Daily) - .filename_prefix("app_log") .build() .unwrap(); diff --git a/examples/single_file.rs b/examples/single_file.rs index 4a83235..6e5de9f 100644 --- a/examples/single_file.rs +++ b/examples/single_file.rs @@ -12,27 +12,30 @@ // See the License for the specific language governing permissions and // limitations under the License. -use logforth::append::single_file::SingleFileBuilder; +use logforth::append::rolling_file::RollingFileBuilder; +use logforth::append::rolling_file::Rotation; use logforth::layout::JsonLayout; fn main() { - let (single_writer, _guard) = SingleFileBuilder::new("my.log") + let (rolling_writer, _guard) = RollingFileBuilder::new("logs", "my_app") + .filename_suffix("log") .layout(JsonLayout::default()) + .rotation(Rotation::Never) .build() .unwrap(); logforth::builder() - .dispatch(|d| d.filter(log::LevelFilter::Trace).append(single_writer)) + .dispatch(|d| d.filter(log::LevelFilter::Trace).append(rolling_writer)) .apply(); let repeat = 1; for i in 0..repeat { - log::error!("Hello error!"); - log::warn!("Hello warn!"); - log::info!("Hello info!"); - log::debug!("Hello debug!"); - log::trace!("Hello trace!"); + log::error!("Hello single error!"); + log::warn!("Hello single warn!"); + log::info!("Hello single info!"); + log::debug!("Hello single debug!"); + log::trace!("Hello single trace!"); if i + 1 < repeat { std::thread::sleep(std::time::Duration::from_secs(10)); diff --git a/src/append/mod.rs b/src/append/mod.rs index fa8c121..2205c94 100644 --- a/src/append/mod.rs +++ b/src/append/mod.rs @@ -26,8 +26,6 @@ mod journald; pub mod opentelemetry; #[cfg(feature = "append-rolling-file")] pub mod rolling_file; -#[cfg(feature = "append-single-file")] -pub mod single_file; mod stdio; #[cfg(feature = "append-syslog")] pub mod syslog; @@ -41,8 +39,6 @@ pub use self::journald::Journald; pub use self::opentelemetry::OpentelemetryLog; #[cfg(feature = "append-rolling-file")] pub use self::rolling_file::RollingFile; -#[cfg(feature = "append-single-file")] -pub use self::single_file::SingleFile; pub use self::stdio::Stderr; pub use self::stdio::Stdout; #[cfg(feature = "append-syslog")] diff --git a/src/append/rolling_file/append.rs b/src/append/rolling_file/append.rs index 308d5f6..0165767 100644 --- a/src/append/rolling_file/append.rs +++ b/src/append/rolling_file/append.rs @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +use std::num::NonZeroUsize; use std::path::PathBuf; use std::time::Duration; @@ -42,9 +43,13 @@ pub struct RollingFileBuilder { impl RollingFileBuilder { /// Create a new builder. - pub fn new(basedir: impl Into) -> Self { + /// + /// # Error + /// + /// If `filename` is empty, [`RollingFileBuilder::build`] would return an error. + pub fn new(basedir: impl Into, filename: impl Into) -> Self { Self { - builder: RollingFileWriterBuilder::new(basedir), + builder: RollingFileWriterBuilder::new(basedir, filename), layout: Box::new(TextLayout::default().no_color()), thread_name: "logforth-rolling-file".to_string(), @@ -57,7 +62,9 @@ impl RollingFileBuilder { /// /// # Errors /// - /// Returns an error if the log directory cannot be created. + /// Returns an error if: + /// * The log directory cannot be created. + /// * The configured filename is empty. pub fn build(self) -> anyhow::Result<(RollingFile, DropGuard)> { let RollingFileBuilder { builder, @@ -84,7 +91,7 @@ impl RollingFileBuilder { /// use logforth::append::rolling_file::RollingFileBuilder; /// use logforth::layout::JsonLayout; /// - /// let builder = RollingFileBuilder::new("my_service"); + /// let builder = RollingFileBuilder::new("my_service", "my_app"); /// builder.layout(JsonLayout::default()); /// ``` pub fn layout(mut self, layout: impl Into>) -> Self { @@ -116,12 +123,6 @@ impl RollingFileBuilder { self } - /// Sets the filename prefix. - pub fn filename_prefix(mut self, prefix: impl Into) -> Self { - self.builder = self.builder.filename_prefix(prefix); - self - } - /// Sets the filename suffix. pub fn filename_suffix(mut self, suffix: impl Into) -> Self { self.builder = self.builder.filename_suffix(suffix); @@ -129,13 +130,13 @@ impl RollingFileBuilder { } /// Sets the maximum number of log files to keep. - pub fn max_log_files(mut self, n: usize) -> Self { + pub fn max_log_files(mut self, n: NonZeroUsize) -> Self { self.builder = self.builder.max_log_files(n); self } /// Sets the maximum size of a log file in bytes. - pub fn max_file_size(mut self, n: usize) -> Self { + pub fn max_file_size(mut self, n: NonZeroUsize) -> Self { self.builder = self.builder.max_file_size(n); self } diff --git a/src/append/rolling_file/mod.rs b/src/append/rolling_file/mod.rs index ebb8fb5..128ca2e 100644 --- a/src/append/rolling_file/mod.rs +++ b/src/append/rolling_file/mod.rs @@ -23,10 +23,9 @@ //! use logforth::append::rolling_file::Rotation; //! use logforth::layout::JsonLayout; //! -//! let (rolling_writer, _guard) = RollingFileBuilder::new("logs") +//! let (rolling_writer, _guard) = RollingFileBuilder::new("logs", "app_log") //! .layout(JsonLayout::default()) //! .rotation(Rotation::Daily) -//! .filename_prefix("app_log") //! .build() //! .unwrap(); //! diff --git a/src/append/rolling_file/rolling.rs b/src/append/rolling_file/rolling.rs index d49f3b3..e1bfafb 100644 --- a/src/append/rolling_file/rolling.rs +++ b/src/append/rolling_file/rolling.rs @@ -14,14 +14,19 @@ use std::fs; use std::fs::File; +use std::fs::Metadata; use std::fs::OpenOptions; use std::io; use std::io::Write; +use std::num::NonZeroUsize; use std::path::Path; use std::path::PathBuf; +use std::str::FromStr; use anyhow::Context; +use anyhow::ensure; use jiff::Zoned; +use jiff::civil::DateTime; use crate::append::rolling_file::Rotation; use crate::append::rolling_file::clock::Clock; @@ -38,12 +43,13 @@ impl Write for RollingFileWriter { let now = self.state.clock.now(); let writer = &mut self.writer; if self.state.should_rollover_on_date(&now) { - self.state.advance_date(&now); - self.state.refresh_writer(&now, 0, writer); + self.state.current_filesize = 0; + self.state.next_date_timestamp = self.state.rotation.next_date_timestamp(&now); + self.state.refresh_writer(&now, writer); } if self.state.should_rollover_on_size() { - let cnt = self.state.advance_cnt(); - self.state.refresh_writer(&now, cnt, writer); + self.state.current_filesize = 0; + self.state.refresh_writer(&now, writer); } writer @@ -61,26 +67,26 @@ impl Write for RollingFileWriter { pub struct RollingFileWriterBuilder { // required basedir: PathBuf, + filename: String, // has default rotation: Rotation, - prefix: Option, - suffix: Option, - max_size: usize, - max_files: Option, + filename_suffix: Option, + max_size: Option, + max_files: Option, clock: Clock, } impl RollingFileWriterBuilder { /// Creates a new [`RollingFileWriterBuilder`]. #[must_use] - pub fn new(basedir: impl Into) -> Self { + pub fn new(basedir: impl Into, filename: impl Into) -> Self { Self { basedir: basedir.into(), + filename: filename.into(), rotation: Rotation::Never, - prefix: None, - suffix: None, - max_size: usize::MAX, + filename_suffix: None, + max_size: None, max_files: None, clock: Clock::DefaultClock, } @@ -93,23 +99,11 @@ impl RollingFileWriterBuilder { self } - /// Sets the filename prefix. - #[must_use] - pub fn filename_prefix(mut self, prefix: impl Into) -> Self { - let prefix = prefix.into(); - self.prefix = if prefix.is_empty() { - None - } else { - Some(prefix) - }; - self - } - /// Sets the filename suffix. #[must_use] pub fn filename_suffix(mut self, suffix: impl Into) -> Self { let suffix = suffix.into(); - self.suffix = if suffix.is_empty() { + self.filename_suffix = if suffix.is_empty() { None } else { Some(suffix) @@ -119,15 +113,15 @@ impl RollingFileWriterBuilder { /// Sets the maximum number of log files to keep. #[must_use] - pub fn max_log_files(mut self, n: usize) -> Self { + pub fn max_log_files(mut self, n: NonZeroUsize) -> Self { self.max_files = Some(n); self } /// Sets the maximum size of a log file in bytes. #[must_use] - pub fn max_file_size(mut self, n: usize) -> Self { - self.max_size = n; + pub fn max_file_size(mut self, n: NonZeroUsize) -> Self { + self.max_size = Some(n); self } @@ -142,31 +136,60 @@ impl RollingFileWriterBuilder { let Self { basedir, rotation, - prefix, - suffix, + filename, + filename_suffix, max_size, max_files, clock, } = self; + + ensure!(!filename.is_empty(), "filename must not be empty"); + let (state, writer) = State::new( - rotation, basedir, prefix, suffix, max_size, max_files, clock, + rotation, + basedir, + filename, + filename_suffix, + max_size, + max_files, + clock, )?; + Ok(RollingFileWriter { state, writer }) } } +#[derive(Debug)] +struct LogFile { + filepath: PathBuf, + metadata: Metadata, + datetime: DateTime, + count: usize, +} + +// oldest is the least +fn compare_logfile(a: &LogFile, b: &LogFile) -> std::cmp::Ordering { + match a.datetime.cmp(&b.datetime) { + std::cmp::Ordering::Equal => { + let a_rev = usize::MAX - a.count; + let b_rev = usize::MAX - b.count; + a_rev.cmp(&b_rev) + } + ord => ord, + } +} + #[derive(Debug)] struct State { log_dir: PathBuf, - log_filename_prefix: Option, + log_filename: String, log_filename_suffix: Option, date_format: &'static str, rotation: Rotation, - current_count: usize, current_filesize: usize, next_date_timestamp: Option, - max_size: usize, - max_files: Option, + max_size: Option, + max_files: Option, clock: Clock, } @@ -174,143 +197,216 @@ impl State { fn new( rotation: Rotation, dir: impl AsRef, - log_filename_prefix: Option, + log_filename: String, log_filename_suffix: Option, - max_size: usize, - max_files: Option, + max_size: Option, + max_files: Option, clock: Clock, ) -> anyhow::Result<(Self, File)> { - let log_dir = dir.as_ref().to_path_buf(); - let date_format = rotation.date_format(); let now = clock.now(); - let next_date_timestamp = rotation.next_date_timestamp(&now); - - let current_count = 0; - let current_filesize = 0; + let log_dir = dir.as_ref().to_path_buf(); + fs::create_dir_all(&log_dir).context("failed to create log directory")?; - let state = State { + let mut state = State { log_dir, - log_filename_prefix, + log_filename, log_filename_suffix, - date_format, - current_count, - current_filesize, - next_date_timestamp, + date_format: rotation.date_format(), + current_filesize: 0, + next_date_timestamp: rotation.next_date_timestamp(&now), rotation, max_size, max_files, clock, }; - let file = state.create_log_writer(&now, 0)?; + let files = { + let mut files = state.list_logfiles()?; + files.sort_by(compare_logfile); + files + }; + + let file = match files.last() { + None => { + // brand-new directory + state.create_log_writer()? + } + Some(last) => { + let filename = state.current_filename(); + if last.filepath != filename { + // for some reason, the `filename.suffix` file does not exist; create a new one + state.create_log_writer()? + } else { + // continue to use the existing current log file + state.current_filesize = last.metadata.len() as usize; + OpenOptions::new() + .append(true) + .open(&filename) + .context("failed to open existing log file")? + } + } + }; + Ok((state, file)) } - fn join_date(&self, date: &Zoned, cnt: usize) -> String { + fn current_filename(&self) -> PathBuf { + let filename = &self.log_filename; + match self.log_filename_suffix.as_ref() { + None => self.log_dir.join(filename), + Some(suffix) => self.log_dir.join(format!("{filename}.{suffix}")), + } + } + + fn create_log_writer(&self) -> anyhow::Result { + let filename = self.current_filename(); + OpenOptions::new() + .write(true) + .create_new(true) + .open(&filename) + .context("failed to create log file") + } + + fn join_date(&self, date: &Zoned, cnt: usize) -> PathBuf { let date = date.strftime(self.date_format); - match ( + let filename = match ( &self.rotation, - &self.log_filename_prefix, + &self.log_filename, &self.log_filename_suffix, ) { - (&Rotation::Never, Some(filename), None) => format!("{filename}.{cnt}"), - (&Rotation::Never, Some(filename), Some(suffix)) => { + (&Rotation::Never, filename, None) => format!("{filename}.{cnt}"), + (&Rotation::Never, filename, Some(suffix)) => { format!("{filename}.{cnt}.{suffix}") } - (&Rotation::Never, None, Some(suffix)) => format!("{cnt}.{suffix}"), - (_, Some(filename), Some(suffix)) => format!("{filename}.{date}.{cnt}.{suffix}"), - (_, Some(filename), None) => format!("{filename}.{date}.{cnt}"), - (_, None, Some(suffix)) => format!("{date}.{cnt}.{suffix}"), - (_, None, None) => format!("{date}.{cnt}"), - } - } - - fn create_log_writer(&self, now: &Zoned, cnt: usize) -> anyhow::Result { - fs::create_dir_all(&self.log_dir).context("failed to create log directory")?; - let filename = self.join_date(now, cnt); - if let Some(max_files) = self.max_files { - if let Err(err) = self.delete_oldest_logs(max_files) { - eprintln!("failed to delete oldest logs: {err}"); - } - } - OpenOptions::new() - .append(true) - .create(true) - .open(self.log_dir.join(filename)) - .context("failed to create log file") + (_, filename, Some(suffix)) => format!("{filename}.{date}.{cnt}.{suffix}"), + (_, filename, None) => format!("{filename}.{date}.{cnt}"), + }; + self.log_dir.join(filename) } - fn delete_oldest_logs(&self, max_files: usize) -> anyhow::Result<()> { + fn list_logfiles(&self) -> anyhow::Result> { let read_dir = fs::read_dir(&self.log_dir) .with_context(|| format!("failed to read log dir: {}", self.log_dir.display()))?; - let mut files = read_dir + let files = read_dir .filter_map(|entry| { let entry = entry.ok()?; - let metadata = entry.metadata().ok()?; + let filepath = entry.path(); + let metadata = entry.metadata().ok()?; // the appender only creates files, not directories or symlinks, - // so we should never delete a dir or symlink. if !metadata.is_file() { return None; } let filename = entry.file_name(); // if the filename is not a UTF-8 string, skip it. - let filename = filename.to_str()?; - if let Some(prefix) = &self.log_filename_prefix { - if !filename.starts_with(prefix) { - return None; - } + let mut filename = filename.to_str()?; + if !filename.starts_with(&self.log_filename) { + return None; } + filename = &filename[self.log_filename.len()..]; if let Some(suffix) = &self.log_filename_suffix { if !filename.ends_with(suffix) { return None; } + filename = &filename[..filename.len() - suffix.len() - 1]; } - if self.log_filename_prefix.is_none() - && self.log_filename_suffix.is_none() - && jiff::civil::DateTime::strptime(self.date_format, filename).is_err() - { + if filename.is_empty() { + // the current log file is the largest + return Some(LogFile { + filepath, + metadata, + datetime: DateTime::MAX, + count: 0, + }); + } + + if filename.starts_with(".") { + filename = &filename[1..]; + } else { return None; } - // On Linux (e.g., CentOS), `metadata.created()` may return an error due to lack of - // filesystem support. Fallback to `metadata.modified()` ensures - // compatibility across platforms. - let created = metadata.created().or_else(|_| metadata.modified()).ok()?; - Some((entry, created)) + let datetime = if self.rotation != Rotation::Never { + // mandatory datetime part + let pos = filename.find('.')?; + let datetime = DateTime::strptime(self.date_format, &filename[..pos]).ok()?; + filename = &filename[pos + 1..]; + datetime + } else { + DateTime::MAX + }; + + let count = usize::from_str(&filename[..filename.len()]).ok()?; + + Some(LogFile { + filepath, + metadata, + datetime, + count, + }) }) .collect::>(); + Ok(files) + } + + fn delete_oldest_logs(&self, max_files: usize) -> anyhow::Result<()> { + let mut files = self.list_logfiles()?; if files.len() < max_files { return Ok(()); } - // sort the files by their creation timestamps. - files.sort_by_key(|(_, created_at)| *created_at); - // delete files, so that (n-1) files remain, because we will create another log file - for (file, _) in files.iter().take(files.len() - (max_files - 1)) { - fs::remove_file(file.path()).with_context(|| { - format!("Failed to remove old log file {}", file.path().display()) - })?; + files.sort_by(compare_logfile); + for file in files.iter().take(files.len() - (max_files - 1)) { + let filepath = &file.filepath; + fs::remove_file(filepath).context("failed to remove old log file")?; } Ok(()) } - fn refresh_writer(&self, now: &Zoned, cnt: usize, file: &mut File) { - match self.create_log_writer(now, cnt) { + fn rotate_log_writer(&self, now: &Zoned) -> anyhow::Result { + let mut renames = vec![]; + for i in 1..self.max_files.map_or(usize::MAX, |n| n.get()) { + let filepath = self.join_date(now, i); + if fs::exists(&filepath).is_ok_and(|ok| ok) { + let next = self.join_date(now, i + 1); + renames.push((filepath, next)); + } else { + break; + } + } + + for (old, new) in renames.iter().rev() { + fs::rename(old, new)?; + } + + let archive_filepath = self.join_date(now, 1); + let current_filepath = self.current_filename(); + fs::rename(¤t_filepath, &archive_filepath)?; + if let Some(max_files) = self.max_files { + if let Err(err) = self.delete_oldest_logs(max_files.get()) { + eprintln!("failed to delete oldest logs: {err}"); + } + } + + self.create_log_writer() + } + + fn refresh_writer(&self, now: &Zoned, file: &mut File) { + match self.rotate_log_writer(now) { Ok(new_file) => { if let Err(err) = file.flush() { eprintln!("failed to flush previous writer: {err}"); } *file = new_file; } - Err(err) => eprintln!("failed to create writer for logs: {err}"), + Err(err) => eprintln!("failed to rotate log writer: {err}"), } } @@ -320,19 +416,8 @@ impl State { } fn should_rollover_on_size(&self) -> bool { - self.current_filesize >= self.max_size - } - - fn advance_cnt(&mut self) -> usize { - self.current_count += 1; - self.current_filesize = 0; - self.current_count - } - - fn advance_date(&mut self, now: &Zoned) { - self.current_count = 0; - self.current_filesize = 0; - self.next_date_timestamp = self.rotation.next_date_timestamp(now); + self.max_size + .is_some_and(|n| self.current_filesize >= n.get()) } } @@ -341,6 +426,7 @@ mod tests { use std::cmp::min; use std::fs; use std::io::Write; + use std::num::NonZeroUsize; use std::ops::Add; use std::str::FromStr; @@ -364,21 +450,23 @@ mod tests { test_file_rolling_for_specific_file_size(20, 6666); test_file_rolling_for_specific_file_size(20, 10000); } + fn test_file_rolling_for_specific_file_size(max_files: usize, max_size: usize) { + let max_files = NonZeroUsize::new(max_files).unwrap(); + let max_size = NonZeroUsize::new(max_size).unwrap(); let temp_dir = TempDir::new().expect("failed to create a temporary directory"); - let mut writer = RollingFileWriterBuilder::new(temp_dir.as_ref()) + let mut writer = RollingFileWriterBuilder::new(temp_dir.as_ref(), "test_file") .rotation(Rotation::Never) - .filename_prefix("test_prefix") .filename_suffix("log") .max_log_files(max_files) .max_file_size(max_size) .build() .unwrap(); - for i in 1..=(max_files * 2) { + for i in 1..=(max_files.get() * 2) { let mut expected_file_size = 0; - while expected_file_size < max_size { + while expected_file_size < max_size.get() { let rand_str = generate_random_string(); expected_file_size += rand_str.len(); assert_eq!(writer.write(rand_str.as_bytes()).unwrap(), rand_str.len()); @@ -388,7 +476,7 @@ mod tests { writer.flush().unwrap(); assert_eq!( fs::read_dir(&writer.state.log_dir).unwrap().count(), - min(i, max_files) + min(i, max_files.get()) ); } } @@ -417,23 +505,21 @@ mod tests { rotation_duration: Span, write_interval: Span, ) { + let max_files = NonZeroUsize::new(10).unwrap(); let temp_dir = TempDir::new().expect("failed to create a temporary directory"); - let max_files = 10; let start_time = Zoned::from_str("2024-08-10T00:00:00[UTC]").unwrap(); - let mut writer = RollingFileWriterBuilder::new(temp_dir.as_ref()) + let mut writer = RollingFileWriterBuilder::new(temp_dir.as_ref(), "test_file") .rotation(rotation) - .filename_prefix("test_prefix") .filename_suffix("log") .max_log_files(max_files) - .max_file_size(usize::MAX) .clock(Clock::ManualClock(ManualClock::new(start_time.clone()))) .build() .unwrap(); let mut cur_time = start_time; - for i in 1..=(max_files * 2) { + for i in 1..=(max_files.get() * 2) { let mut expected_file_size = 0; let end_time = cur_time.add(rotation_duration); while cur_time < end_time { @@ -451,7 +537,7 @@ mod tests { writer.flush().unwrap(); assert_eq!( fs::read_dir(&writer.state.log_dir).unwrap().count(), - min(i, max_files) + min(i, max_files.get()) ); } } @@ -480,17 +566,16 @@ mod tests { rotation_duration: Span, write_interval: Span, ) { - let temp_dir = TempDir::new().expect("failed to create a temporary directory"); - let max_files = 10; + let max_files = NonZeroUsize::new(10).unwrap(); + let file_size = NonZeroUsize::new(500).unwrap(); // Small file size and too many files to ensure both of file size and time rotation can be // triggered. let total_files = 100; - let file_size = 500; + let temp_dir = TempDir::new().expect("failed to create a temporary directory"); let start_time = Zoned::from_str("2024-08-10T00:00:00[UTC]").unwrap(); - let mut writer = RollingFileWriterBuilder::new(temp_dir.as_ref()) + let mut writer = RollingFileWriterBuilder::new(temp_dir.as_ref(), "test_file") .rotation(rotation) - .filename_prefix("test_prefix") .filename_suffix("log") .max_log_files(max_files) .max_file_size(file_size) @@ -521,7 +606,7 @@ mod tests { time_rotation_trigger = true; break; } - if expected_file_size >= file_size { + if expected_file_size >= file_size.get() { file_size_rotation_trigger = true; break; } @@ -530,7 +615,7 @@ mod tests { writer.flush().unwrap(); assert_eq!( fs::read_dir(&writer.state.log_dir).unwrap().count(), - min(i, max_files) + min(i, max_files.get()) ); } assert!(file_size_rotation_trigger); diff --git a/src/append/rolling_file/rotation.rs b/src/append/rolling_file/rotation.rs index d6a314a..b8b0daf 100644 --- a/src/append/rolling_file/rotation.rs +++ b/src/append/rolling_file/rotation.rs @@ -17,9 +17,10 @@ use jiff::ToSpan; use jiff::Unit; use jiff::Zoned; use jiff::ZonedRound; +use jiff::civil::DateTime; /// Rotation policies for rolling files. -#[derive(Clone, Eq, PartialEq, Debug)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Rotation { /// Rotate files every minute. Minutely, @@ -34,23 +35,32 @@ pub enum Rotation { impl Rotation { /// Get the next date timestamp based on the current date and rotation policy. pub fn next_date_timestamp(&self, current_date: &Zoned) -> Option { - let timestamp_round = ZonedRound::new().mode(RoundMode::Trunc); - + let round = ZonedRound::new().mode(RoundMode::Trunc); let next_date = match *self { Rotation::Never => return None, - Rotation::Minutely => { - (current_date + 1.minute()).round(timestamp_round.smallest(Unit::Minute)) - } - Rotation::Hourly => { - (current_date + 1.hour()).round(timestamp_round.smallest(Unit::Hour)) - } - Rotation::Daily => (current_date + 1.day()).round(timestamp_round.smallest(Unit::Day)), + Rotation::Minutely => (current_date + 1.minute()).round(round.smallest(Unit::Minute)), + Rotation::Hourly => (current_date + 1.hour()).round(round.smallest(Unit::Hour)), + Rotation::Daily => (current_date + 1.day()).round(round.smallest(Unit::Day)), }; let next_date = next_date.expect("invalid time; this is a bug in logforth rolling file appender"); Some(next_date.timestamp().as_millisecond() as usize) } + /// Get the current datetime based on the current date and rotation policy. + pub fn current_datetime(&self, current_date: &Zoned) -> Option { + let round = ZonedRound::new().mode(RoundMode::Trunc); + let current_date = match *self { + Rotation::Never => return None, + Rotation::Minutely => current_date.round(round.smallest(Unit::Minute)), + Rotation::Hourly => current_date.round(round.smallest(Unit::Hour)), + Rotation::Daily => current_date.round(round.smallest(Unit::Day)), + }; + let current_date = + current_date.expect("invalid time; this is a bug in logforth rolling file appender"); + Some(current_date.datetime()) + } + /// Get the date format string for the rotation policy. pub fn date_format(&self) -> &'static str { match *self { diff --git a/src/append/single_file/append.rs b/src/append/single_file/append.rs deleted file mode 100644 index d66cf5b..0000000 --- a/src/append/single_file/append.rs +++ /dev/null @@ -1,133 +0,0 @@ -// Copyright 2024 FastLabs Developers -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -use std::path::PathBuf; -use std::time::Duration; - -use log::Record; - -use crate::Diagnostic; -use crate::DropGuard; -use crate::Layout; -use crate::append::Append; -use crate::append::single_file::single::SingleFileWriter; -use crate::append::single_file::single::SingleFileWriterBuilder; -use crate::layout::TextLayout; -use crate::non_blocking::NonBlocking; -use crate::non_blocking::NonBlockingBuilder; - -/// A builder to configure and create an [`SingleFile`] appender. -#[derive(Debug)] -pub struct SingleFileBuilder { - builder: SingleFileWriterBuilder, - layout: Box, - - // non-blocking options - thread_name: String, - buffered_lines_limit: Option, - shutdown_timeout: Option, -} - -impl SingleFileBuilder { - /// Create a new builder. - pub fn new(log_path: impl Into) -> Self { - Self { - builder: SingleFileWriterBuilder::new(log_path), - layout: Box::new(TextLayout::default().no_color()), - - thread_name: "logforth-single-file".to_string(), - buffered_lines_limit: None, - shutdown_timeout: None, - } - } - - /// Build the [`SingleFile`] appender. - /// - /// # Errors - /// - /// Returns an error if the log file cannot be created. - pub fn build(self) -> anyhow::Result<(SingleFile, DropGuard)> { - let SingleFileBuilder { - builder, - layout, - thread_name, - buffered_lines_limit, - shutdown_timeout, - } = self; - let writer = builder.build()?; - let (non_blocking, guard) = NonBlockingBuilder::new(thread_name, writer) - .buffered_lines_limit(buffered_lines_limit) - .shutdown_timeout(shutdown_timeout) - .build(); - Ok((SingleFile::new(non_blocking, layout), Box::new(guard))) - } - - /// Sets the layout for the logs. - /// - /// Default to [`TextLayout`]. - /// - /// # Examples - /// - /// ``` - /// use logforth::append::single_file::SingleFileBuilder; - /// use logforth::layout::JsonLayout; - /// - /// let builder = SingleFileBuilder::new("my_service.log"); - /// builder.layout(JsonLayout::default()); - /// ``` - pub fn layout(mut self, layout: impl Into>) -> Self { - self.layout = layout.into(); - self - } - - /// Sets the buffer size of pending messages. - pub fn buffered_lines_limit(mut self, buffered_lines_limit: Option) -> Self { - self.buffered_lines_limit = buffered_lines_limit; - self - } - - /// Sets the shutdown timeout before the worker guard dropped. - pub fn shutdown_timeout(mut self, shutdown_timeout: Option) -> Self { - self.shutdown_timeout = shutdown_timeout; - self - } - - /// Sets the thread name for the background sender thread. - pub fn thread_name(mut self, thread_name: impl Into) -> Self { - self.thread_name = thread_name.into(); - self - } -} - -/// An appender that writes log records to a file. -#[derive(Debug)] -pub struct SingleFile { - layout: Box, - writer: NonBlocking, -} - -impl SingleFile { - fn new(writer: NonBlocking, layout: Box) -> Self { - Self { layout, writer } - } -} - -impl Append for SingleFile { - fn append(&self, record: &Record, diagnostics: &[Box]) -> anyhow::Result<()> { - let mut bytes = self.layout.format(record, diagnostics)?; - bytes.push(b'\n'); - self.writer.send(bytes)?; - Ok(()) - } -} diff --git a/src/append/single_file/mod.rs b/src/append/single_file/mod.rs deleted file mode 100644 index 6d442c9..0000000 --- a/src/append/single_file/mod.rs +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright 2024 FastLabs Developers -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! Appender for writing log records to a file. -//! -//! # Example -//! -//!``` -//! use logforth::append::single_file; -//! use logforth::append::single_file::SingleFile; -//! use logforth::append::single_file::SingleFileBuilder; -//! use logforth::layout::JsonLayout; -//! -//! let (file_writer, _guard) = SingleFileBuilder::new("file.log") -//! .layout(JsonLayout::default()) -//! .build() -//! .unwrap(); -//! -//! logforth::builder() -//! .dispatch(|d| d.filter(log::LevelFilter::Trace).append(file_writer)) -//! .apply(); -//! -//! log::info!("This log will be written to a file."); -//! ``` - -pub use append::SingleFile; -pub use append::SingleFileBuilder; - -mod append; -mod single; diff --git a/src/append/single_file/single.rs b/src/append/single_file/single.rs deleted file mode 100644 index 30da132..0000000 --- a/src/append/single_file/single.rs +++ /dev/null @@ -1,109 +0,0 @@ -// Copyright 2024 FastLabs Developers -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -use std::fs; -use std::fs::File; -use std::fs::OpenOptions; -use std::io; -use std::io::Write; -use std::path::PathBuf; - -use anyhow::Context; - -/// A writer for files. -#[derive(Debug)] -pub struct SingleFileWriter { - writer: File, -} - -impl Write for SingleFileWriter { - fn write(&mut self, buf: &[u8]) -> io::Result { - let writer = &mut self.writer; - - writer.write(buf) - } - - fn flush(&mut self) -> io::Result<()> { - self.writer.flush() - } -} - -/// A builder for configuring [`SingleFileWriter`]. -#[derive(Debug)] -pub struct SingleFileWriterBuilder { - // required - filepath: PathBuf, -} - -impl SingleFileWriterBuilder { - /// Creates a new [`SingleFileWriterBuilder`]. - #[must_use] - pub fn new(filepath: impl Into) -> Self { - Self { - filepath: filepath.into(), - } - } - - /// Builds the [`SingleFileWriter`]. - pub fn build(self) -> anyhow::Result { - let dir = &self - .filepath - .parent() - .context("failed to get log directory")?; - fs::create_dir_all(dir).context("failed to create log directory")?; - let writer = OpenOptions::new() - .append(true) - .create(true) - .open(&self.filepath) - .context("failed to create log file")?; - Ok(SingleFileWriter { writer }) - } -} - -#[cfg(test)] -mod tests { - use std::io::Write; - - use rand::Rng; - use rand::distr::Alphanumeric; - use tempfile::NamedTempFile; - - use crate::append::single_file::single::SingleFileWriterBuilder; - - #[test] - fn test_single_file() { - // To Do: Make this a file - let temp_file = NamedTempFile::new().expect("failed to create a temporary directory"); - - let mut writer = SingleFileWriterBuilder::new(temp_file.path()) - .build() - .unwrap(); - - let rand_str = generate_random_string(); - assert_eq!(writer.write(rand_str.as_bytes()).unwrap(), rand_str.len()); - writer.flush().unwrap(); - } - - fn generate_random_string() -> String { - let mut rng = rand::rng(); - let len = rng.random_range(50..=100); - let random_string: String = std::iter::repeat(()) - .map(|()| rng.sample(Alphanumeric)) - .map(char::from) - .take(len) - .collect(); - - random_string - } -} diff --git a/tests/recursive_logging.rs b/tests/recursive_logging.rs index 23791a4..663b0d0 100644 --- a/tests/recursive_logging.rs +++ b/tests/recursive_logging.rs @@ -14,6 +14,8 @@ #![cfg(feature = "append-rolling-file")] +use std::num::NonZeroUsize; + use log::Record; use logforth::Diagnostic; use logforth::Layout; @@ -35,13 +37,12 @@ impl Layout for CustomLayout { fn test_meta_logging_in_format_works() { let stdout = append::Stdout::default().with_layout(CustomLayout("out")); let stderr = append::Stderr::default().with_layout(CustomLayout("err")); - let (rolling, _guard) = RollingFileBuilder::new("logs") + let (rolling, _guard) = RollingFileBuilder::new("logs", "example") .layout(CustomLayout("file")) .rotation(Rotation::Minutely) - .filename_prefix("example") .filename_suffix("log") - .max_log_files(10) - .max_file_size(1024 * 1024) + .max_log_files(NonZeroUsize::new(10).unwrap()) + .max_file_size(NonZeroUsize::new(1024 * 1024).unwrap()) .build() .unwrap();