Skip to content

Commit 656a7c9

Browse files
NickCaplingerhds
authored andcommitted
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.
1 parent c46c613 commit 656a7c9

File tree

1 file changed

+172
-13
lines changed

1 file changed

+172
-13
lines changed

tracing-appender/src/rolling.rs

Lines changed: 172 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -362,14 +362,50 @@ pub fn hourly(
362362
/// }
363363
/// ```
364364
///
365-
/// This will result in a log file located at `/some/path/rolling.log.yyyy-MM-dd-HH`.
365+
/// This will result in a log file located at `/some/path/rolling.log.yyyy-MM-dd`.
366366
pub fn daily(
367367
directory: impl AsRef<Path>,
368368
file_name_prefix: impl AsRef<Path>,
369369
) -> RollingFileAppender {
370370
RollingFileAppender::new(Rotation::DAILY, directory, file_name_prefix)
371371
}
372372

373+
/// Creates a weekly-rotating file appender. The logs will rotate every Sunday at midnight UTC.
374+
///
375+
/// The appender returned by `rolling::weekly` can be used with `non_blocking` to create
376+
/// a non-blocking, weekly file appender.
377+
///
378+
/// A `RollingFileAppender` has a fixed rotation whose frequency is
379+
/// defined by [`Rotation`][self::Rotation]. The `directory` and
380+
/// `file_name_prefix` arguments determine the location and file name's _prefix_
381+
/// of the log file. `RollingFileAppender` automatically appends the current date in UTC.
382+
///
383+
/// # Examples
384+
///
385+
/// ``` rust
386+
/// # #[clippy::allow(needless_doctest_main)]
387+
/// fn main () {
388+
/// # fn doc() {
389+
/// let appender = tracing_appender::rolling::weekly("/some/path", "rolling.log");
390+
/// let (non_blocking_appender, _guard) = tracing_appender::non_blocking(appender);
391+
///
392+
/// let subscriber = tracing_subscriber::fmt().with_writer(non_blocking_appender);
393+
///
394+
/// tracing::subscriber::with_default(subscriber.finish(), || {
395+
/// tracing::event!(tracing::Level::INFO, "Hello");
396+
/// });
397+
/// # }
398+
/// }
399+
/// ```
400+
///
401+
/// This will result in a log file located at `/some/path/rolling.log.yyyy-MM-dd`.
402+
pub fn weekly(
403+
directory: impl AsRef<Path>,
404+
file_name_prefix: impl AsRef<Path>,
405+
) -> RollingFileAppender {
406+
RollingFileAppender::new(Rotation::WEEKLY, directory, file_name_prefix)
407+
}
408+
373409
/// Creates a non-rolling file appender.
374410
///
375411
/// The appender returned by `rolling::never` can be used with `non_blocking` to create
@@ -429,6 +465,14 @@ pub fn never(directory: impl AsRef<Path>, file_name: impl AsRef<Path>) -> Rollin
429465
/// # }
430466
/// ```
431467
///
468+
/// ### Weekly Rotation
469+
/// ```rust
470+
/// # fn docs() {
471+
/// use tracing_appender::rolling::Rotation;
472+
/// let rotation = tracing_appender::rolling::Rotation::WEEKLY;
473+
/// # }
474+
/// ```
475+
///
432476
/// ### No Rotation
433477
/// ```rust
434478
/// # fn docs() {
@@ -444,31 +488,40 @@ enum RotationKind {
444488
Minutely,
445489
Hourly,
446490
Daily,
491+
Weekly,
447492
Never,
448493
}
449494

450495
impl Rotation {
451-
/// Provides an minutely rotation
496+
/// Provides a minutely rotation.
452497
pub const MINUTELY: Self = Self(RotationKind::Minutely);
453-
/// Provides an hourly rotation
498+
/// Provides an hourly rotation.
454499
pub const HOURLY: Self = Self(RotationKind::Hourly);
455-
/// Provides a daily rotation
500+
/// Provides a daily rotation.
456501
pub const DAILY: Self = Self(RotationKind::Daily);
502+
/// Provides a weekly rotation that rotates every Sunday at midnight UTC.
503+
pub const WEEKLY: Self = Self(RotationKind::Weekly);
457504
/// Provides a rotation that never rotates.
458505
pub const NEVER: Self = Self(RotationKind::Never);
459506

507+
/// Determines the next date that we should round to or `None` if `self` uses [`Rotation::NEVER`].
460508
pub(crate) fn next_date(&self, current_date: &OffsetDateTime) -> Option<OffsetDateTime> {
461509
let unrounded_next_date = match *self {
462510
Rotation::MINUTELY => *current_date + Duration::minutes(1),
463511
Rotation::HOURLY => *current_date + Duration::hours(1),
464512
Rotation::DAILY => *current_date + Duration::days(1),
513+
Rotation::WEEKLY => *current_date + Duration::weeks(1),
465514
Rotation::NEVER => return None,
466515
};
467-
Some(self.round_date(&unrounded_next_date))
516+
Some(self.round_date(unrounded_next_date))
468517
}
469518

470-
// note that this method will panic if passed a `Rotation::NEVER`.
471-
pub(crate) fn round_date(&self, date: &OffsetDateTime) -> OffsetDateTime {
519+
/// Rounds the date towards the past using the [`Rotation`] interval.
520+
///
521+
/// # Panics
522+
///
523+
/// This method will panic if `self`` uses [`Rotation::NEVER`].
524+
pub(crate) fn round_date(&self, date: OffsetDateTime) -> OffsetDateTime {
472525
match *self {
473526
Rotation::MINUTELY => {
474527
let time = Time::from_hms(date.hour(), date.minute(), 0)
@@ -485,6 +538,14 @@ impl Rotation {
485538
.expect("Invalid time; this is a bug in tracing-appender");
486539
date.replace_time(time)
487540
}
541+
Rotation::WEEKLY => {
542+
let zero_time = Time::from_hms(0, 0, 0)
543+
.expect("Invalid time; this is a bug in tracing-appender");
544+
545+
let days_since_sunday = date.weekday().number_days_from_sunday();
546+
let date = date - Duration::days(days_since_sunday.into());
547+
date.replace_time(zero_time)
548+
}
488549
// Rotation::NEVER is impossible to round.
489550
Rotation::NEVER => {
490551
unreachable!("Rotation::NEVER is impossible to round.")
@@ -497,6 +558,7 @@ impl Rotation {
497558
Rotation::MINUTELY => format_description::parse("[year]-[month]-[day]-[hour]-[minute]"),
498559
Rotation::HOURLY => format_description::parse("[year]-[month]-[day]-[hour]"),
499560
Rotation::DAILY => format_description::parse("[year]-[month]-[day]"),
561+
Rotation::WEEKLY => format_description::parse("[year]-[month]-[day]"),
500562
Rotation::NEVER => format_description::parse("[year]-[month]-[day]"),
501563
}
502564
.expect("Unable to create a formatter; this is a bug in tracing-appender")
@@ -548,10 +610,17 @@ impl Inner {
548610
Ok((inner, writer))
549611
}
550612

613+
/// Returns the full filename for the provided date, using [`Rotation`] to round accordingly.
551614
pub(crate) fn join_date(&self, date: &OffsetDateTime) -> String {
552-
let date = date
553-
.format(&self.date_format)
554-
.expect("Unable to format OffsetDateTime; this is a bug in tracing-appender");
615+
let date = if let Rotation::NEVER = self.rotation {
616+
date.format(&self.date_format)
617+
.expect("Unable to format OffsetDateTime; this is a bug in tracing-appender")
618+
} else {
619+
self.rotation
620+
.round_date(*date)
621+
.format(&self.date_format)
622+
.expect("Unable to format OffsetDateTime; this is a bug in tracing-appender")
623+
};
555624

556625
match (
557626
&self.rotation,
@@ -748,7 +817,7 @@ mod test {
748817

749818
#[test]
750819
fn write_minutely_log() {
751-
test_appender(Rotation::HOURLY, "minutely.log");
820+
test_appender(Rotation::MINUTELY, "minutely.log");
752821
}
753822

754823
#[test]
@@ -761,6 +830,11 @@ mod test {
761830
test_appender(Rotation::DAILY, "daily.log");
762831
}
763832

833+
#[test]
834+
fn write_weekly_log() {
835+
test_appender(Rotation::WEEKLY, "weekly.log");
836+
}
837+
764838
#[test]
765839
fn write_never_log() {
766840
test_appender(Rotation::NEVER, "never.log");
@@ -778,24 +852,109 @@ mod test {
778852
let next = Rotation::HOURLY.next_date(&now).unwrap();
779853
assert_eq!((now + Duration::HOUR).hour(), next.hour());
780854

781-
// daily-basis
855+
// per-day basis
782856
let now = OffsetDateTime::now_utc();
783857
let next = Rotation::DAILY.next_date(&now).unwrap();
784858
assert_eq!((now + Duration::DAY).day(), next.day());
785859

860+
// per-week basis
861+
let now = OffsetDateTime::now_utc();
862+
let now_rounded = Rotation::WEEKLY.round_date(now);
863+
let next = Rotation::WEEKLY.next_date(&now).unwrap();
864+
assert!(now_rounded < next);
865+
786866
// never
787867
let now = OffsetDateTime::now_utc();
788868
let next = Rotation::NEVER.next_date(&now);
789869
assert!(next.is_none());
790870
}
791871

872+
#[test]
873+
fn test_join_date() {
874+
struct TestCase {
875+
expected: &'static str,
876+
rotation: Rotation,
877+
prefix: Option<&'static str>,
878+
suffix: Option<&'static str>,
879+
now: OffsetDateTime,
880+
}
881+
882+
let format = format_description::parse(
883+
"[year]-[month]-[day] [hour]:[minute]:[second] [offset_hour \
884+
sign:mandatory]:[offset_minute]:[offset_second]",
885+
)
886+
.unwrap();
887+
let directory = tempfile::tempdir().expect("failed to create tempdir");
888+
889+
let test_cases = vec![
890+
TestCase {
891+
expected: "my_prefix.2025-02-16.log",
892+
rotation: Rotation::WEEKLY,
893+
prefix: Some("my_prefix"),
894+
suffix: Some("log"),
895+
now: OffsetDateTime::parse("2025-02-17 10:01:00 +00:00:00", &format).unwrap(),
896+
},
897+
// Make sure weekly rotation rounds to the preceding year when appropriate
898+
TestCase {
899+
expected: "my_prefix.2024-12-29.log",
900+
rotation: Rotation::WEEKLY,
901+
prefix: Some("my_prefix"),
902+
suffix: Some("log"),
903+
now: OffsetDateTime::parse("2025-01-01 10:01:00 +00:00:00", &format).unwrap(),
904+
},
905+
TestCase {
906+
expected: "my_prefix.2025-02-17.log",
907+
rotation: Rotation::DAILY,
908+
prefix: Some("my_prefix"),
909+
suffix: Some("log"),
910+
now: OffsetDateTime::parse("2025-02-17 10:01:00 +00:00:00", &format).unwrap(),
911+
},
912+
TestCase {
913+
expected: "my_prefix.2025-02-17-10.log",
914+
rotation: Rotation::HOURLY,
915+
prefix: Some("my_prefix"),
916+
suffix: Some("log"),
917+
now: OffsetDateTime::parse("2025-02-17 10:01:00 +00:00:00", &format).unwrap(),
918+
},
919+
TestCase {
920+
expected: "my_prefix.2025-02-17-10-01.log",
921+
rotation: Rotation::MINUTELY,
922+
prefix: Some("my_prefix"),
923+
suffix: Some("log"),
924+
now: OffsetDateTime::parse("2025-02-17 10:01:00 +00:00:00", &format).unwrap(),
925+
},
926+
TestCase {
927+
expected: "my_prefix.log",
928+
rotation: Rotation::NEVER,
929+
prefix: Some("my_prefix"),
930+
suffix: Some("log"),
931+
now: OffsetDateTime::parse("2025-02-17 10:01:00 +00:00:00", &format).unwrap(),
932+
},
933+
];
934+
935+
for test_case in test_cases {
936+
let (inner, _) = Inner::new(
937+
test_case.now,
938+
test_case.rotation.clone(),
939+
directory.path(),
940+
test_case.prefix.map(ToString::to_string),
941+
test_case.suffix.map(ToString::to_string),
942+
None,
943+
)
944+
.unwrap();
945+
let path = inner.join_date(&test_case.now);
946+
947+
assert_eq!(path, test_case.expected);
948+
}
949+
}
950+
792951
#[test]
793952
#[should_panic(
794953
expected = "internal error: entered unreachable code: Rotation::NEVER is impossible to round."
795954
)]
796955
fn test_never_date_rounding() {
797956
let now = OffsetDateTime::now_utc();
798-
let _ = Rotation::NEVER.round_date(&now);
957+
let _ = Rotation::NEVER.round_date(now);
799958
}
800959

801960
#[test]

0 commit comments

Comments
 (0)