Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions tracing-appender/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ rust-version = "1.63.0"
crossbeam-channel = "0.5.6"
time = { version = "0.3.2", default-features = false, features = ["formatting", "parsing"] }
parking_lot = { optional = true, version = "0.12.1" }
symlink = "0.1.0"
thiserror = "2"

[dependencies.tracing-subscriber]
Expand Down
119 changes: 107 additions & 12 deletions tracing-appender/src/rolling.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ struct Inner {
log_directory: PathBuf,
log_filename_prefix: Option<String>,
log_filename_suffix: Option<String>,
log_latest_symlink_name: Option<String>,
date_format: Vec<format_description::FormatItem<'static>>,
rotation: Rotation,
next_date: AtomicUsize,
Expand Down Expand Up @@ -189,6 +190,7 @@ impl RollingFileAppender {
ref rotation,
ref prefix,
ref suffix,
ref latest_symlink,
ref max_files,
} = builder;
let directory = directory.as_ref().to_path_buf();
Expand All @@ -199,6 +201,7 @@ impl RollingFileAppender {
directory,
prefix.clone(),
suffix.clone(),
latest_symlink.clone(),
*max_files,
)?;
Ok(Self {
Expand Down Expand Up @@ -586,6 +589,7 @@ impl Inner {
directory: impl AsRef<Path>,
log_filename_prefix: Option<String>,
log_filename_suffix: Option<String>,
log_latest_symlink_name: Option<String>,
max_files: Option<usize>,
) -> Result<(Self, RwLock<File>), builder::InitError> {
let log_directory = directory.as_ref().to_path_buf();
Expand All @@ -596,6 +600,7 @@ impl Inner {
log_directory,
log_filename_prefix,
log_filename_suffix,
log_latest_symlink_name,
date_format,
next_date: AtomicUsize::new(
next_date
Expand All @@ -611,7 +616,11 @@ impl Inner {
}

let filename = inner.join_date(&now);
let writer = RwLock::new(create_writer(inner.log_directory.as_ref(), &filename)?);
let writer = RwLock::new(create_writer(
inner.log_directory.as_ref(),
&filename,
inner.log_latest_symlink_name.as_deref(),
)?);
Ok((inner, writer))
}

Expand Down Expand Up @@ -732,7 +741,11 @@ impl Inner {
self.prune_old_logs(max_files);
}

match create_writer(&self.log_directory, &filename) {
match create_writer(
&self.log_directory,
&filename,
self.log_latest_symlink_name.as_deref(),
) {
Ok(new_file) => {
if let Err(err) = file.flush() {
eprintln!("Couldn't flush previous writer: {}", err);
Expand Down Expand Up @@ -777,22 +790,37 @@ impl Inner {
}
}

fn create_writer(directory: &Path, filename: &str) -> Result<File, InitError> {
fn create_writer(
directory: &Path,
filename: &str,
latest_symlink_name: Option<&str>,
) -> Result<File, InitError> {
let path = directory.join(filename);
let mut open_options = OpenOptions::new();
open_options.append(true).create(true);

let new_file = open_options.open(path.as_path());
if new_file.is_err() {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(InitError::ctx("failed to create log directory"))?;
return open_options
.open(path)
.map_err(InitError::ctx("failed to create initial log file"));
}
let new_file = open_options
.open(&path)
.map_err(InitError::ctx("failed to create log file"))
.or_else(|_| {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(InitError::ctx("failed to create log directory"))?;
}
open_options
.open(&path)
.map_err(InitError::ctx("failed to create log file"))
})?;

if let Some(symlink_name) = latest_symlink_name {
let symlink_path = directory.join(symlink_name);
let _ = symlink::remove_symlink_file(&symlink_path);
symlink::symlink_file(path, symlink_path).map_err(InitError::ctx(
"failed to create symlink to latest log file",
))?;
}

new_file.map_err(InitError::ctx("failed to create initial log file"))
Ok(new_file)
}

#[cfg(test)]
Expand Down Expand Up @@ -962,6 +990,7 @@ mod test {
test_case.prefix.map(ToString::to_string),
test_case.suffix.map(ToString::to_string),
None,
None,
)
.unwrap();
let path = inner.join_date(&test_case.now);
Expand Down Expand Up @@ -1010,6 +1039,7 @@ mod test {
prefix.map(ToString::to_string),
suffix.map(ToString::to_string),
None,
None,
)
.unwrap();
let path = inner.join_date(&now);
Expand Down Expand Up @@ -1122,6 +1152,7 @@ mod test {
Some("test_make_writer".to_string()),
None,
None,
None,
)
.unwrap();

Expand Down Expand Up @@ -1203,6 +1234,7 @@ mod test {
directory.path(),
Some("test_max_log_files".to_string()),
None,
None,
Some(2),
)
.unwrap();
Expand Down Expand Up @@ -1287,4 +1319,67 @@ mod test {
}
}
}

#[test]
fn test_latest_symlink() {
use std::sync::{Arc, Mutex};

let format = format_description::parse(
"[year]-[month]-[day] [hour]:[minute]:[second] [offset_hour \
sign:mandatory]:[offset_minute]:[offset_second]",
)
.unwrap();

let now = OffsetDateTime::parse("2020-02-01 10:01:00 +00:00:00", &format).unwrap();
let directory = tempfile::tempdir().expect("failed to create tempdir");
let (state, writer) = Inner::new(
now,
Rotation::HOURLY,
directory.path(),
Some("test_latest_symlink".to_string()),
None,
Some("latest.log".to_string()),
None,
)
.unwrap();

// Verify symlink was created pointing to the initial log file
let symlink_path = directory.path().join("latest.log");
assert!(symlink_path.is_symlink(), "latest.log should be a symlink");
let target = fs::read_link(&symlink_path).expect("failed to read symlink");
assert!(
target.to_string_lossy().contains("2020-02-01-10"),
"symlink should point to file with date 2020-02-01-10, but points to {:?}",
target
);

// Set up appender with mock clock to test rotation
let clock = Arc::new(Mutex::new(now));
let now_fn = {
let clock = clock.clone();
Box::new(move || *clock.lock().unwrap())
};
let mut appender = RollingFileAppender {
state,
writer,
now: now_fn,
};

// Advance time by one hour and write to trigger rotation
*clock.lock().unwrap() += Duration::hours(1);
appender.write_all(b"test\n").expect("failed to write");
appender.flush().expect("failed to flush");

// Verify symlink now points to the new log file
let target = fs::read_link(&symlink_path).expect("failed to read symlink");
assert!(
target.to_string_lossy().contains("2020-02-01-11"),
"symlink should point to file with date 2020-02-01-11, but points to {:?}",
target
);

// Verify the symlink is functional
let content = fs::read_to_string(&symlink_path).expect("failed to read through symlink");
assert_eq!("test\n", content);
}
}
29 changes: 29 additions & 0 deletions tracing-appender/src/rolling/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pub struct Builder {
pub(super) rotation: Rotation,
pub(super) prefix: Option<String>,
pub(super) suffix: Option<String>,
pub(super) latest_symlink: Option<String>,
pub(super) max_files: Option<usize>,
}

Expand Down Expand Up @@ -53,6 +54,7 @@ impl Builder {
rotation: Rotation::NEVER,
prefix: None,
suffix: None,
latest_symlink: None,
max_files: None,
}
}
Expand Down Expand Up @@ -238,6 +240,33 @@ impl Builder {
}
}

/// Create a symbolic link that points to the latest log file.
/// The symbolic link will be updated when new log files are created.
///
/// # Examples
///
/// ```
/// use tracing_appender::rolling::RollingFileAppender;
///
/// # fn docs() {
/// let appender = RollingFileAppender::builder()
/// .latest_symlink("log.latest")
/// // ...
/// .build("/var/log")
/// .expect("failed to initialize rolling file appender");
/// # drop(appender)
/// # }
/// ```
#[must_use]
pub fn latest_symlink(self, name: impl Into<String>) -> Self {
let name = name.into();
let latest_symlink = if name.is_empty() { None } else { Some(name) };
Self {
latest_symlink,
..self
}
}

/// Builds a new [`RollingFileAppender`] with the configured parameters,
/// emitting log files to the provided directory.
///
Expand Down