use std::io::IsTerminal;
use std::path::PathBuf;
use std::sync::OnceLock;
use tracing::level_filters::LevelFilter;
use tracing_appender::non_blocking::WorkerGuard;
use tracing_appender::rolling::{RollingFileAppender, Rotation};
use tracing_subscriber::{Layer, Registry};
const LOG_RETENTION_HOURS: usize = 72;
const LOG_DIR_MAX_BYTES: u64 = 1024 * 1024 * 1024;
fn is_rotating_freenet_log(name: &str) -> bool {
let stem = if let Some(rest) = name.strip_prefix("freenet.error.") {
rest
} else if let Some(rest) = name.strip_prefix("freenet.") {
rest
} else {
return false;
};
let Some(date_part) = stem.strip_suffix(".log") else {
return false;
};
!date_part.is_empty()
&& date_part.contains('-')
&& date_part.chars().all(|c| c.is_ascii_digit() || c == '-')
}
static LOG_GUARDS: OnceLock<Vec<WorkerGuard>> = OnceLock::new();
pub fn get_log_dir() -> Option<PathBuf> {
#[cfg(target_os = "linux")]
{
dirs::home_dir().map(|h| h.join(".local/state/freenet"))
}
#[cfg(target_os = "macos")]
{
dirs::home_dir().map(|h| h.join("Library/Logs/freenet"))
}
#[cfg(target_os = "windows")]
{
dirs::data_local_dir().map(|d| d.join("freenet").join("logs"))
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
None
}
}
fn cleanup_old_logs(log_dir: &std::path::Path) {
use std::time::{Duration, SystemTime};
let retention = Duration::from_secs(LOG_RETENTION_HOURS as u64 * 3600);
let cutoff = SystemTime::now() - retention;
let Ok(entries) = std::fs::read_dir(log_dir) else {
return;
};
let mut survivors: Vec<(std::path::PathBuf, SystemTime, u64)> = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if !is_rotating_freenet_log(name) {
continue;
}
let Ok(metadata) = path.metadata() else {
continue;
};
let Ok(modified) = metadata.modified() else {
continue;
};
if modified < cutoff {
if let Err(e) = std::fs::remove_file(&path) {
eprintln!("Failed to remove old log file {}: {}", path.display(), e);
}
continue;
}
survivors.push((path, modified, metadata.len()));
}
enforce_log_dir_size_cap(survivors, LOG_DIR_MAX_BYTES);
}
fn enforce_log_dir_size_cap(
mut files: Vec<(std::path::PathBuf, std::time::SystemTime, u64)>,
max_bytes: u64,
) {
let total: u64 = files.iter().map(|(_, _, size)| *size).sum();
if total <= max_bytes {
return;
}
files.sort_by_key(|(_, modified, _)| *modified);
let live = files.pop(); let live_size = live.as_ref().map(|(_, _, size)| *size).unwrap_or(0);
let mut non_live_remaining: u64 = files.iter().map(|(_, _, size)| *size).sum();
for (path, _, size) in files {
if live_size.saturating_add(non_live_remaining) <= max_bytes {
break;
}
match std::fs::remove_file(&path) {
Ok(()) => {
non_live_remaining = non_live_remaining.saturating_sub(size);
}
Err(e) => {
eprintln!(
"Failed to enforce log dir size cap on {}: {}",
path.display(),
e
);
}
}
}
}
pub fn init_tracer(
level: Option<LevelFilter>,
_endpoint: Option<String>,
log_dir: Option<&std::path::Path>,
) -> anyhow::Result<()> {
#[cfg(feature = "console-subscriber")]
{
if std::env::var("TOKIO_CONSOLE").is_ok() {
console_subscriber::init();
tracing::info!(
"Tokio console subscriber initialized. Connect with 'tokio-console' command."
);
return Ok(());
}
}
let default_filter = if cfg!(any(test, debug_assertions)) {
LevelFilter::DEBUG
} else {
LevelFilter::INFO
};
let default_filter = level.unwrap_or(default_filter);
use tracing_subscriber::layer::SubscriberExt;
let disabled_logs = std::env::var("FREENET_DISABLE_LOGS").is_ok();
if disabled_logs {
return Ok(());
}
let to_stderr = std::env::var("FREENET_LOG_TO_STDERR").is_ok();
let use_json = std::env::var("FREENET_LOG_FORMAT")
.map(|v| v.eq_ignore_ascii_case("json"))
.unwrap_or(false);
let use_file_logging = !to_stderr && log_dir.is_some();
fn build_filter(default_filter: LevelFilter) -> tracing_subscriber::EnvFilter {
tracing_subscriber::EnvFilter::builder()
.with_default_directive(default_filter.into())
.from_env_lossy()
.add_directive("moka=off".parse().expect("infallible"))
.add_directive("sqlx=error".parse().expect("infallible"))
}
let filter_layer = build_filter(default_filter);
let also_log_to_console = std::io::stdout().is_terminal();
let rate_limit: u64 = std::env::var("FREENET_LOG_RATE_LIMIT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(crate::util::rate_limit_layer::DEFAULT_MAX_EVENTS_PER_SECOND);
let per_callsite_limit: u64 = std::env::var("FREENET_LOG_RATE_LIMIT_PER_CALLSITE")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(crate::util::rate_limit_layer::DEFAULT_MAX_EVENTS_PER_CALLSITE_PER_SECOND);
let rate_limit_enabled = !cfg!(any(test, debug_assertions))
&& std::env::var("FREENET_DISABLE_LOG_RATE_LIMIT").is_err();
let rate_limiter = if rate_limit_enabled {
Some(crate::util::rate_limit_layer::RateLimiter::new(rate_limit))
} else {
None
};
let per_callsite_limiter = if rate_limit_enabled {
Some(crate::util::rate_limit_layer::PerCallsiteRateLimiter::new(
per_callsite_limit,
))
} else {
None
};
if use_file_logging {
if let Some(log_dir) = log_dir {
if let Err(e) = std::fs::create_dir_all(log_dir) {
eprintln!("Warning: Failed to create log directory: {e}");
return init_stdout_tracer(
default_filter,
to_stderr,
use_json,
filter_layer,
rate_limiter,
per_callsite_limiter,
);
}
cleanup_old_logs(log_dir);
let main_appender = RollingFileAppender::builder()
.rotation(Rotation::HOURLY)
.max_log_files(LOG_RETENTION_HOURS)
.filename_prefix("freenet")
.filename_suffix("log")
.build(log_dir)
.map_err(|e| anyhow::anyhow!("Failed to create log appender: {e}"))?;
let error_appender = RollingFileAppender::builder()
.rotation(Rotation::HOURLY)
.max_log_files(LOG_RETENTION_HOURS)
.filename_prefix("freenet.error")
.filename_suffix("log")
.build(log_dir)
.map_err(|e| anyhow::anyhow!("Failed to create error log appender: {e}"))?;
let (main_writer, main_guard) = tracing_appender::non_blocking(main_appender);
let (error_writer, error_guard) = tracing_appender::non_blocking(error_appender);
if LOG_GUARDS.set(vec![main_guard, error_guard]).is_err() {
return Err(anyhow::anyhow!(
"LOG_GUARDS already initialized; tracer cannot be re-initialized"
));
}
if let Some(rate_limiter) = rate_limiter.clone() {
let per_callsite = per_callsite_limiter.clone();
let rate_filter = tracing_subscriber::filter::DynFilterFn::new(move |meta, _cx| {
per_callsite
.as_ref()
.map(|pc| pc.should_allow(meta))
.unwrap_or(true)
&& rate_limiter.should_allow()
});
let base = Registry::default().with(rate_filter);
let main_layer = tracing_subscriber::fmt::layer()
.with_level(true)
.with_ansi(false)
.with_writer(main_writer.clone())
.with_filter(filter_layer);
let error_filter = tracing_subscriber::EnvFilter::builder()
.with_default_directive(LevelFilter::WARN.into())
.from_env_lossy();
let error_layer = tracing_subscriber::fmt::layer()
.with_level(true)
.with_ansi(false)
.with_writer(error_writer.clone())
.with_filter(error_filter);
if also_log_to_console {
let console_filter = build_filter(default_filter);
let console_layer = tracing_subscriber::fmt::layer()
.with_level(true)
.pretty()
.with_filter(console_filter);
let subscriber = base.with(main_layer).with(error_layer).with(console_layer);
tracing::subscriber::set_global_default(subscriber)
.expect("Error setting subscriber");
} else {
let subscriber = base.with(main_layer).with(error_layer);
tracing::subscriber::set_global_default(subscriber)
.expect("Error setting subscriber");
}
} else {
let main_layer = tracing_subscriber::fmt::layer()
.with_level(true)
.with_ansi(false)
.with_writer(main_writer)
.with_filter(filter_layer);
let error_filter = tracing_subscriber::EnvFilter::builder()
.with_default_directive(LevelFilter::WARN.into())
.from_env_lossy();
let error_layer = tracing_subscriber::fmt::layer()
.with_level(true)
.with_ansi(false)
.with_writer(error_writer)
.with_filter(error_filter);
if also_log_to_console {
let console_filter = build_filter(default_filter);
let console_layer = tracing_subscriber::fmt::layer()
.with_level(true)
.pretty()
.with_filter(console_filter);
let subscriber = Registry::default()
.with(main_layer)
.with(error_layer)
.with(console_layer);
tracing::subscriber::set_global_default(subscriber)
.expect("Error setting subscriber");
} else {
let subscriber = Registry::default().with(main_layer).with(error_layer);
tracing::subscriber::set_global_default(subscriber)
.expect("Error setting subscriber");
}
}
return Ok(());
}
}
init_stdout_tracer(
default_filter,
to_stderr,
use_json,
filter_layer,
rate_limiter,
per_callsite_limiter,
)
}
fn init_stdout_tracer(
_default_filter: LevelFilter,
to_stderr: bool,
use_json: bool,
filter_layer: tracing_subscriber::EnvFilter,
rate_limiter: Option<crate::util::rate_limit_layer::RateLimiter>,
per_callsite_limiter: Option<crate::util::rate_limit_layer::PerCallsiteRateLimiter>,
) -> anyhow::Result<()> {
use tracing_subscriber::layer::SubscriberExt;
fn make_layer<S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>>(
to_stderr: bool,
use_json: bool,
) -> Box<dyn tracing_subscriber::Layer<S> + Send + Sync> {
if to_stderr {
if use_json {
tracing_subscriber::fmt::layer()
.with_level(true)
.json()
.with_file(cfg!(any(test, debug_assertions)))
.with_line_number(cfg!(any(test, debug_assertions)))
.with_writer(std::io::stderr)
.boxed()
} else {
let layer = tracing_subscriber::fmt::layer().with_level(true).pretty();
let layer = if cfg!(any(test, debug_assertions)) {
layer.with_file(true).with_line_number(true)
} else {
layer
};
layer.with_writer(std::io::stderr).boxed()
}
} else if use_json {
tracing_subscriber::fmt::layer()
.with_level(true)
.json()
.with_file(cfg!(any(test, debug_assertions)))
.with_line_number(cfg!(any(test, debug_assertions)))
.boxed()
} else {
let layer = tracing_subscriber::fmt::layer().with_level(true).pretty();
if cfg!(any(test, debug_assertions)) {
layer.with_file(true).with_line_number(true).boxed()
} else {
layer.boxed()
}
}
}
if let Some(rate_limiter) = rate_limiter {
let per_callsite = per_callsite_limiter.clone();
let rate_filter = tracing_subscriber::filter::DynFilterFn::new(move |meta, _cx| {
per_callsite
.as_ref()
.map(|pc| pc.should_allow(meta))
.unwrap_or(true)
&& rate_limiter.should_allow()
});
let base = Registry::default().with(rate_filter);
let layer = make_layer(to_stderr, use_json);
let subscriber = base.with(layer.with_filter(filter_layer));
tracing::subscriber::set_global_default(subscriber).expect("Error setting subscriber");
} else {
let layer = make_layer(to_stderr, use_json);
let subscriber = Registry::default().with(layer.with_filter(filter_layer));
tracing::subscriber::set_global_default(subscriber).expect("Error setting subscriber");
}
Ok(())
}
#[cfg(test)]
mod cleanup_tests {
use super::{cleanup_old_logs, enforce_log_dir_size_cap};
use std::fs;
use std::time::{Duration, SystemTime};
fn write_with_mtime(path: &std::path::Path, size: usize, mtime: SystemTime) {
fs::write(path, vec![b'.'; size]).unwrap();
let times = std::fs::FileTimes::new().set_modified(mtime);
let f = std::fs::OpenOptions::new().write(true).open(path).unwrap();
f.set_times(times).unwrap();
}
#[test]
fn size_cap_deletes_oldest_first_until_under_limit() {
let dir = tempfile::tempdir().unwrap();
let now = SystemTime::now();
let oldest = dir.path().join("freenet.2026-05-25-12.log");
let middle = dir.path().join("freenet.2026-05-25-13.log");
let newest = dir.path().join("freenet.2026-05-25-14.log");
write_with_mtime(&oldest, 4096, now - Duration::from_secs(3600));
write_with_mtime(&middle, 4096, now - Duration::from_secs(60));
write_with_mtime(&newest, 4096, now - Duration::from_secs(30));
let files = vec![
(oldest.clone(), now - Duration::from_secs(3600), 4096),
(middle.clone(), now - Duration::from_secs(60), 4096),
(newest.clone(), now - Duration::from_secs(30), 4096),
];
enforce_log_dir_size_cap(files, 8192);
assert!(
!oldest.exists(),
"oldest file should be deleted by size cap"
);
assert!(middle.exists(), "middle file should survive");
assert!(newest.exists(), "newest file should survive");
}
#[test]
fn size_cap_is_noop_when_under_limit() {
let dir = tempfile::tempdir().unwrap();
let now = SystemTime::now();
let small = dir.path().join("freenet.2026-05-25-15.log");
write_with_mtime(&small, 1024, now);
let files = vec![(small.clone(), now, 1024)];
enforce_log_dir_size_cap(files, 1024 * 1024 * 1024);
assert!(small.exists(), "file under cap must survive");
}
#[test]
fn time_pass_removes_files_older_than_retention() {
let dir = tempfile::tempdir().unwrap();
let ancient = dir.path().join("freenet.2026-02-14-00.log");
write_with_mtime(
&ancient,
1024,
SystemTime::now() - Duration::from_secs(100 * 24 * 3600),
);
cleanup_old_logs(dir.path());
assert!(
!ancient.exists(),
"ancient file must be removed by time pass"
);
}
#[test]
fn cleanup_ignores_non_freenet_files() {
let dir = tempfile::tempdir().unwrap();
let other = dir.path().join("other.log");
fs::write(&other, b"unrelated").unwrap();
cleanup_old_logs(dir.path());
assert!(other.exists(), "non-freenet files must not be touched");
}
#[test]
fn size_cap_preserves_most_recently_modified_file() {
let dir = tempfile::tempdir().unwrap();
let now = SystemTime::now();
let live = dir.path().join("freenet.2026-05-25-18.log");
write_with_mtime(&live, 16 * 1024, now);
let files = vec![(live.clone(), now, 16 * 1024)];
enforce_log_dir_size_cap(files, 1024);
assert!(
live.exists(),
"live file must survive even when alone it exceeds the cap"
);
}
#[test]
fn size_cap_deletes_oldest_but_keeps_live() {
let dir = tempfile::tempdir().unwrap();
let now = SystemTime::now();
let old = dir.path().join("freenet.2026-05-25-12.log");
let live = dir.path().join("freenet.2026-05-25-18.log");
write_with_mtime(&old, 4096, now - Duration::from_secs(3600));
write_with_mtime(&live, 4096, now);
let files = vec![
(old.clone(), now - Duration::from_secs(3600), 4096),
(live.clone(), now, 4096),
];
enforce_log_dir_size_cap(files, 5120);
assert!(!old.exists(), "older file must be deleted");
assert!(live.exists(), "live file must survive");
}
#[test]
fn cleanup_skips_legacy_bare_freenet_log_names() {
let dir = tempfile::tempdir().unwrap();
let bare = dir.path().join("freenet.log");
let bare_err = dir.path().join("freenet.error.log");
let scratch = dir.path().join("freenet.error.log.last");
for p in [&bare, &bare_err, &scratch] {
write_with_mtime(
p,
1024,
SystemTime::now() - Duration::from_secs(30 * 24 * 3600),
);
}
cleanup_old_logs(dir.path());
assert!(
bare.exists(),
"legacy freenet.log must not be deleted (systemd-owned)"
);
assert!(
bare_err.exists(),
"legacy freenet.error.log must not be deleted (systemd-owned)"
);
assert!(
scratch.exists(),
"transient freenet.error.log.last must not be deleted (wrapper-owned)"
);
}
}