From d81a69e0ff80a64ca4a06bb492dea8e3392cbba3 Mon Sep 17 00:00:00 2001 From: Nick Caplinger Date: Fri, 30 May 2025 09:06:36 -0500 Subject: [PATCH] appender: introduce weekly rotation (#3218) ## Motivation While configuring tracing-appender, I wanted to specify a weekly log rotation interval. I was unable to do so, as the largest rotation interval was daily. ## Solution Before my introduction of weekly log rotation, rounding the current `OffsetDateTime` was straightforward: we could simply keep the current date and truncate part or all of the time component. However, we cannot simply truncate the time with weekly rotation; the date must now be modified. To round the date, we roll logs at 00:00 UTC on Sunday. This gives us consistent date-times that only change weekly. --- tracing-appender/src/rolling.rs | 185 +++++++++++++++++++++++++++++--- 1 file changed, 172 insertions(+), 13 deletions(-) diff --git a/tracing-appender/src/rolling.rs b/tracing-appender/src/rolling.rs index ef931b37..3c0321f0 100644 --- a/tracing-appender/src/rolling.rs +++ b/tracing-appender/src/rolling.rs @@ -362,7 +362,7 @@ pub fn hourly( /// } /// ``` /// -/// This will result in a log file located at `/some/path/rolling.log.yyyy-MM-dd-HH`. +/// This will result in a log file located at `/some/path/rolling.log.yyyy-MM-dd`. pub fn daily( directory: impl AsRef, file_name_prefix: impl AsRef, @@ -370,6 +370,42 @@ pub fn daily( RollingFileAppender::new(Rotation::DAILY, directory, file_name_prefix) } +/// Creates a weekly-rotating file appender. The logs will rotate every Sunday at midnight UTC. +/// +/// The appender returned by `rolling::weekly` can be used with `non_blocking` to create +/// a non-blocking, weekly file appender. +/// +/// A `RollingFileAppender` has a fixed rotation whose frequency is +/// defined by [`Rotation`][self::Rotation]. The `directory` and +/// `file_name_prefix` arguments determine the location and file name's _prefix_ +/// of the log file. `RollingFileAppender` automatically appends the current date in UTC. +/// +/// # Examples +/// +/// ``` rust +/// # #[clippy::allow(needless_doctest_main)] +/// fn main () { +/// # fn doc() { +/// let appender = tracing_appender::rolling::weekly("/some/path", "rolling.log"); +/// let (non_blocking_appender, _guard) = tracing_appender::non_blocking(appender); +/// +/// let collector = tracing_subscriber::fmt().with_writer(non_blocking_appender); +/// +/// tracing::collect::with_default(collector.finish(), || { +/// tracing::event!(tracing::Level::INFO, "Hello"); +/// }); +/// # } +/// } +/// ``` +/// +/// This will result in a log file located at `/some/path/rolling.log.yyyy-MM-dd`. +pub fn weekly( + directory: impl AsRef, + file_name_prefix: impl AsRef, +) -> RollingFileAppender { + RollingFileAppender::new(Rotation::WEEKLY, directory, file_name_prefix) +} + /// Creates a non-rolling file appender. /// /// The appender returned by `rolling::never` can be used with `non_blocking` to create @@ -429,6 +465,14 @@ pub fn never(directory: impl AsRef, file_name: impl AsRef) -> Rollin /// # } /// ``` /// +/// ### Weekly Rotation +/// ```rust +/// # fn docs() { +/// use tracing_appender::rolling::Rotation; +/// let rotation = tracing_appender::rolling::Rotation::WEEKLY; +/// # } +/// ``` +/// /// ### No Rotation /// ```rust /// # fn docs() { @@ -444,31 +488,40 @@ enum RotationKind { Minutely, Hourly, Daily, + Weekly, Never, } impl Rotation { - /// Provides an minutely rotation + /// Provides a minutely rotation. pub const MINUTELY: Self = Self(RotationKind::Minutely); - /// Provides an hourly rotation + /// Provides an hourly rotation. pub const HOURLY: Self = Self(RotationKind::Hourly); - /// Provides a daily rotation + /// Provides a daily rotation. pub const DAILY: Self = Self(RotationKind::Daily); + /// Provides a weekly rotation that rotates every Sunday at midnight UTC. + pub const WEEKLY: Self = Self(RotationKind::Weekly); /// Provides a rotation that never rotates. pub const NEVER: Self = Self(RotationKind::Never); + /// Determines the next date that we should round to or `None` if `self` uses [`Rotation::NEVER`]. pub(crate) fn next_date(&self, current_date: &OffsetDateTime) -> Option { let unrounded_next_date = match *self { Rotation::MINUTELY => *current_date + Duration::minutes(1), Rotation::HOURLY => *current_date + Duration::hours(1), Rotation::DAILY => *current_date + Duration::days(1), + Rotation::WEEKLY => *current_date + Duration::weeks(1), Rotation::NEVER => return None, }; - Some(self.round_date(&unrounded_next_date)) + Some(self.round_date(unrounded_next_date)) } - // note that this method will panic if passed a `Rotation::NEVER`. - pub(crate) fn round_date(&self, date: &OffsetDateTime) -> OffsetDateTime { + /// Rounds the date towards the past using the [`Rotation`] interval. + /// + /// # Panics + /// + /// This method will panic if `self`` uses [`Rotation::NEVER`]. + pub(crate) fn round_date(&self, date: OffsetDateTime) -> OffsetDateTime { match *self { Rotation::MINUTELY => { let time = Time::from_hms(date.hour(), date.minute(), 0) @@ -485,6 +538,14 @@ impl Rotation { .expect("Invalid time; this is a bug in tracing-appender"); date.replace_time(time) } + Rotation::WEEKLY => { + let zero_time = Time::from_hms(0, 0, 0) + .expect("Invalid time; this is a bug in tracing-appender"); + + let days_since_sunday = date.weekday().number_days_from_sunday(); + let date = date - Duration::days(days_since_sunday.into()); + date.replace_time(zero_time) + } // Rotation::NEVER is impossible to round. Rotation::NEVER => { unreachable!("Rotation::NEVER is impossible to round.") @@ -497,6 +558,7 @@ impl Rotation { Rotation::MINUTELY => format_description::parse("[year]-[month]-[day]-[hour]-[minute]"), Rotation::HOURLY => format_description::parse("[year]-[month]-[day]-[hour]"), Rotation::DAILY => format_description::parse("[year]-[month]-[day]"), + Rotation::WEEKLY => format_description::parse("[year]-[month]-[day]"), Rotation::NEVER => format_description::parse("[year]-[month]-[day]"), } .expect("Unable to create a formatter; this is a bug in tracing-appender") @@ -548,10 +610,17 @@ impl Inner { Ok((inner, writer)) } + /// Returns the full filename for the provided date, using [`Rotation`] to round accordingly. pub(crate) fn join_date(&self, date: &OffsetDateTime) -> String { - let date = date - .format(&self.date_format) - .expect("Unable to format OffsetDateTime; this is a bug in tracing-appender"); + let date = if let Rotation::NEVER = self.rotation { + date.format(&self.date_format) + .expect("Unable to format OffsetDateTime; this is a bug in tracing-appender") + } else { + self.rotation + .round_date(*date) + .format(&self.date_format) + .expect("Unable to format OffsetDateTime; this is a bug in tracing-appender") + }; match ( &self.rotation, @@ -748,7 +817,7 @@ mod test { #[test] fn write_minutely_log() { - test_appender(Rotation::HOURLY, "minutely.log"); + test_appender(Rotation::MINUTELY, "minutely.log"); } #[test] @@ -761,6 +830,11 @@ mod test { test_appender(Rotation::DAILY, "daily.log"); } + #[test] + fn write_weekly_log() { + test_appender(Rotation::WEEKLY, "weekly.log"); + } + #[test] fn write_never_log() { test_appender(Rotation::NEVER, "never.log"); @@ -778,24 +852,109 @@ mod test { let next = Rotation::HOURLY.next_date(&now).unwrap(); assert_eq!((now + Duration::HOUR).hour(), next.hour()); - // daily-basis + // per-day basis let now = OffsetDateTime::now_utc(); let next = Rotation::DAILY.next_date(&now).unwrap(); assert_eq!((now + Duration::DAY).day(), next.day()); + // per-week basis + let now = OffsetDateTime::now_utc(); + let now_rounded = Rotation::WEEKLY.round_date(now); + let next = Rotation::WEEKLY.next_date(&now).unwrap(); + assert!(now_rounded < next); + // never let now = OffsetDateTime::now_utc(); let next = Rotation::NEVER.next_date(&now); assert!(next.is_none()); } + #[test] + fn test_join_date() { + struct TestCase { + expected: &'static str, + rotation: Rotation, + prefix: Option<&'static str>, + suffix: Option<&'static str>, + now: OffsetDateTime, + } + + let format = format_description::parse( + "[year]-[month]-[day] [hour]:[minute]:[second] [offset_hour \ + sign:mandatory]:[offset_minute]:[offset_second]", + ) + .unwrap(); + let directory = tempfile::tempdir().expect("failed to create tempdir"); + + let test_cases = vec![ + TestCase { + expected: "my_prefix.2025-02-16.log", + rotation: Rotation::WEEKLY, + prefix: Some("my_prefix"), + suffix: Some("log"), + now: OffsetDateTime::parse("2025-02-17 10:01:00 +00:00:00", &format).unwrap(), + }, + // Make sure weekly rotation rounds to the preceding year when appropriate + TestCase { + expected: "my_prefix.2024-12-29.log", + rotation: Rotation::WEEKLY, + prefix: Some("my_prefix"), + suffix: Some("log"), + now: OffsetDateTime::parse("2025-01-01 10:01:00 +00:00:00", &format).unwrap(), + }, + TestCase { + expected: "my_prefix.2025-02-17.log", + rotation: Rotation::DAILY, + prefix: Some("my_prefix"), + suffix: Some("log"), + now: OffsetDateTime::parse("2025-02-17 10:01:00 +00:00:00", &format).unwrap(), + }, + TestCase { + expected: "my_prefix.2025-02-17-10.log", + rotation: Rotation::HOURLY, + prefix: Some("my_prefix"), + suffix: Some("log"), + now: OffsetDateTime::parse("2025-02-17 10:01:00 +00:00:00", &format).unwrap(), + }, + TestCase { + expected: "my_prefix.2025-02-17-10-01.log", + rotation: Rotation::MINUTELY, + prefix: Some("my_prefix"), + suffix: Some("log"), + now: OffsetDateTime::parse("2025-02-17 10:01:00 +00:00:00", &format).unwrap(), + }, + TestCase { + expected: "my_prefix.log", + rotation: Rotation::NEVER, + prefix: Some("my_prefix"), + suffix: Some("log"), + now: OffsetDateTime::parse("2025-02-17 10:01:00 +00:00:00", &format).unwrap(), + }, + ]; + + for test_case in test_cases { + let (inner, _) = Inner::new( + test_case.now, + test_case.rotation.clone(), + directory.path(), + test_case.prefix.map(ToString::to_string), + test_case.suffix.map(ToString::to_string), + None, + ) + .unwrap(); + let path = inner.join_date(&test_case.now); + + assert_eq!(path, test_case.expected); + } + } + #[test] #[should_panic( expected = "internal error: entered unreachable code: Rotation::NEVER is impossible to round." )] fn test_never_date_rounding() { let now = OffsetDateTime::now_utc(); - let _ = Rotation::NEVER.round_date(&now); + let _ = Rotation::NEVER.round_date(now); } #[test]