use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use tracing_appender::non_blocking::WorkerGuard;
use tracing_subscriber::{
EnvFilter, Layer, Registry, fmt, fmt::format::FmtSpan, layer::SubscriberExt,
util::SubscriberInitExt,
};
mod admin;
mod auth;
mod builder;
mod db;
mod errors;
mod forms;
mod mailer;
mod middleware;
mod migration;
mod output;
mod session;
mod templates;
pub use admin::AdminTracing;
pub use auth::AuthTracing;
pub use builder::BuilderTracing;
pub use db::DbTracing;
pub use errors::ErrorsTracing;
pub use forms::FormTracing;
pub use mailer::MailerTracing;
pub use middleware::MiddlewareTracing;
pub use migration::MigrationTracing;
pub use output::{LogOutput, LogRecord, LogRotation, LogSink};
pub use session::SessionTracing;
pub use templates::TemplatesTracing;
#[derive(Debug, Clone, Default)]
pub struct RuniqueLog {
subscriber_level: Option<String>,
pub forms: Option<FormTracing>,
pub middleware: Option<MiddlewareTracing>,
pub session: Option<SessionTracing>,
pub auth: Option<AuthTracing>,
pub admin: Option<AdminTracing>,
pub db: Option<DbTracing>,
pub mailer: Option<MailerTracing>,
pub migration: Option<MigrationTracing>,
pub templates: Option<TemplatesTracing>,
pub errors: Option<ErrorsTracing>,
pub builder: Option<BuilderTracing>,
outputs: Vec<LogOutput>,
external: bool,
}
impl RuniqueLog {
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn subscriber_level(mut self, level: impl Into<String>) -> Self {
self.subscriber_level = Some(level.into());
self
}
#[must_use]
pub fn output(mut self, output: LogOutput) -> Self {
self.outputs.push(output);
self
}
#[must_use]
pub fn external(mut self) -> Self {
self.external = true;
self
}
#[must_use]
pub fn init_subscriber(&self) -> Vec<WorkerGuard> {
if self.external && !self.outputs.is_empty() {
tracing::warn!(
"{}",
crate::utils::trad::t("build.external_outputs_ignored")
);
}
if self.external {
return Vec::new();
}
let default = self.subscriber_level.as_deref().unwrap_or_else(|| {
if crate::utils::env::is_debug() {
"debug"
} else {
"warn"
}
});
let filter =
std::env::var("RUST_LOG").map_or_else(|_| EnvFilter::new(default), EnvFilter::new);
let mut outputs = if self.outputs.is_empty() {
vec![LogOutput::Stdout]
} else {
self.outputs.clone()
};
if let Ok(path) = std::env::var("RUNIQUE_LOG_FILE")
&& !path.is_empty()
{
outputs.push(LogOutput::file(path));
}
let (layers, guards) = Self::build_layers(outputs);
let already_installed = tracing_subscriber::registry()
.with(layers)
.with(filter)
.try_init()
.is_err();
if already_installed {
tracing::warn!(
"{}",
crate::utils::trad::t("build.subscriber_already_installed")
);
return Vec::new();
}
guards
}
fn build_layers(
outputs: Vec<LogOutput>,
) -> (
Vec<Box<dyn Layer<Registry> + Send + Sync>>,
Vec<WorkerGuard>,
) {
let mut guards: Vec<WorkerGuard> = Vec::new();
let mut layers: Vec<Box<dyn Layer<Registry> + Send + Sync>> = Vec::new();
for output in outputs {
match output {
LogOutput::Stdout => {
layers.push(fmt::layer().with_span_events(FmtSpan::CLOSE).boxed());
}
LogOutput::File { path, rotation } => {
let dir = path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map_or_else(|| PathBuf::from("."), Path::to_path_buf);
let Some(prefix) = path.file_name().map(std::ffi::OsStr::to_owned) else {
continue;
};
let appender = match rotation {
LogRotation::Daily => tracing_appender::rolling::daily(&dir, &prefix),
LogRotation::Hourly => tracing_appender::rolling::hourly(&dir, &prefix),
LogRotation::Never => tracing_appender::rolling::never(&dir, &prefix),
};
let (writer, guard) = tracing_appender::non_blocking(appender);
guards.push(guard);
let layer = fmt::layer()
.with_ansi(false)
.with_writer(writer)
.with_span_events(FmtSpan::CLOSE);
if LogOutput::is_json(&path) {
layers.push(layer.json().boxed());
} else {
layers.push(layer.boxed());
}
}
LogOutput::Custom(sink) => {
layers.push(output::SinkLayer::new(sink).boxed());
}
}
}
(layers, guards)
}
#[must_use]
pub fn forms(mut self, f: impl FnOnce(FormTracing) -> FormTracing) -> Self {
self.forms = Some(f(self.forms.take().unwrap_or_default()));
self
}
#[must_use]
pub fn middleware(mut self, f: impl FnOnce(MiddlewareTracing) -> MiddlewareTracing) -> Self {
self.middleware = Some(f(self.middleware.take().unwrap_or_default()));
self
}
#[must_use]
pub fn session(mut self, f: impl FnOnce(SessionTracing) -> SessionTracing) -> Self {
self.session = Some(f(self.session.take().unwrap_or_default()));
self
}
#[must_use]
pub fn auth(mut self, f: impl FnOnce(AuthTracing) -> AuthTracing) -> Self {
self.auth = Some(f(self.auth.take().unwrap_or_default()));
self
}
#[must_use]
pub fn admin(mut self, f: impl FnOnce(AdminTracing) -> AdminTracing) -> Self {
self.admin = Some(f(self.admin.take().unwrap_or_default()));
self
}
#[must_use]
pub fn db(mut self, f: impl FnOnce(DbTracing) -> DbTracing) -> Self {
self.db = Some(f(self.db.take().unwrap_or_default()));
self
}
#[must_use]
pub fn mailer(mut self, f: impl FnOnce(MailerTracing) -> MailerTracing) -> Self {
self.mailer = Some(f(self.mailer.take().unwrap_or_default()));
self
}
#[must_use]
pub fn migration(mut self, f: impl FnOnce(MigrationTracing) -> MigrationTracing) -> Self {
self.migration = Some(f(self.migration.take().unwrap_or_default()));
self
}
#[must_use]
pub fn templates(mut self, f: impl FnOnce(TemplatesTracing) -> TemplatesTracing) -> Self {
self.templates = Some(f(self.templates.take().unwrap_or_default()));
self
}
#[must_use]
pub fn errors(mut self, f: impl FnOnce(ErrorsTracing) -> ErrorsTracing) -> Self {
self.errors = Some(f(self.errors.take().unwrap_or_default()));
self
}
#[must_use]
pub fn builder(mut self, f: impl FnOnce(BuilderTracing) -> BuilderTracing) -> Self {
self.builder = Some(f(self.builder.take().unwrap_or_default()));
self
}
#[must_use]
pub fn dev(self) -> Self {
if !crate::utils::env::is_debug() {
return self;
}
self.forms(|f| f.dev())
.middleware(|m| m.dev())
.session(|s| s.dev())
.auth(|a| a.dev())
.admin(|a| a.dev())
.db(|d| d.dev())
.mailer(|m| m.dev())
.migration(|m| m.dev())
.templates(|t| t.dev())
.errors(|e| e.dev())
.builder(|b| b.dev())
}
}
static LOG_CONFIG: Mutex<Option<Arc<RuniqueLog>>> = Mutex::new(None);
pub fn log_init(config: RuniqueLog) {
if let Ok(mut guard) = LOG_CONFIG.lock()
&& guard.is_none()
{
*guard = Some(Arc::new(config));
}
}
pub fn get_log() -> Arc<RuniqueLog> {
LOG_CONFIG
.lock()
.ok()
.and_then(|g| g.as_ref().cloned())
.unwrap_or_default()
}
pub fn reset_log_for_test() {
if let Ok(mut guard) = LOG_CONFIG.lock() {
*guard = None;
}
}
#[macro_export]
macro_rules! runique_log {
($level:expr, $($args:tt)*) => {
match $level {
::tracing::Level::ERROR => ::tracing::error!($($args)*),
::tracing::Level::WARN => ::tracing::warn!($($args)*),
::tracing::Level::INFO => ::tracing::info!($($args)*),
::tracing::Level::DEBUG => ::tracing::debug!($($args)*),
_ => ::tracing::trace!($($args)*),
}
};
}
#[cfg(test)]
mod tests {
use super::*;
use tracing_subscriber::layer::SubscriberExt;
fn unique_dir(tag: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let dir = std::env::temp_dir().join(format!("runique_log_test_{tag}_{nanos}"));
std::fs::create_dir_all(&dir).expect("create temp dir");
dir
}
#[test]
fn output_is_repeatable_and_cumulative() {
let log = RuniqueLog::new()
.output(LogOutput::stdout())
.output(LogOutput::file("logs/app.json"));
assert_eq!(log.outputs.len(), 2);
}
#[test]
fn external_installs_no_subscriber() {
let guards = RuniqueLog::new().external().init_subscriber();
assert!(guards.is_empty());
}
#[test]
fn file_defaults_to_daily_rotation() {
match LogOutput::file("logs/app.log") {
LogOutput::File { path, rotation } => {
assert_eq!(path, PathBuf::from("logs/app.log"));
assert_eq!(rotation, LogRotation::Daily);
}
other => panic!("expected a File output, got {other:?}"),
}
}
#[test]
fn rotation_overrides_file_and_noops_on_stdout() {
match LogOutput::file("a.log").rotation(LogRotation::Never) {
LogOutput::File { rotation, .. } => assert_eq!(rotation, LogRotation::Never),
other => panic!("expected a File output, got {other:?}"),
}
assert!(matches!(
LogOutput::stdout().rotation(LogRotation::Hourly),
LogOutput::Stdout
));
}
#[test]
fn is_json_follows_the_extension() {
assert!(LogOutput::is_json(Path::new("logs/app.json")));
assert!(LogOutput::is_json(Path::new("APP.JSON"))); assert!(!LogOutput::is_json(Path::new("app.log")));
assert!(!LogOutput::is_json(Path::new("app")));
}
#[test]
fn plain_file_receives_events_and_fields() {
let dir = unique_dir("plain");
let path = dir.join("app.log");
let output = LogOutput::file(&path).rotation(LogRotation::Never);
let (layers, guards) = RuniqueLog::build_layers(vec![output]);
let subscriber = tracing_subscriber::registry().with(layers);
tracing::subscriber::with_default(subscriber, || {
tracing::error!(user = 42, "boom in plain file");
});
drop(guards);
let content = std::fs::read_to_string(&path).expect("log file written");
assert!(content.contains("boom in plain file"), "got: {content}");
assert!(content.contains("user"), "field missing: {content}");
assert!(!content.contains('\u{1b}'), "ANSI escape leaked into file");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn json_file_is_structured() {
let dir = unique_dir("json");
let path = dir.join("app.json");
let output = LogOutput::file(&path).rotation(LogRotation::Never);
let (layers, guards) = RuniqueLog::build_layers(vec![output]);
let subscriber = tracing_subscriber::registry().with(layers);
tracing::subscriber::with_default(subscriber, || {
tracing::warn!(code = 7, "structured line");
});
drop(guards);
let content = std::fs::read_to_string(&path).expect("log file written");
let first = content.lines().next().expect("at least one line");
let v: serde_json::Value = serde_json::from_str(first).expect("each line is valid JSON");
assert_eq!(v["level"], "WARN");
assert_eq!(v["fields"]["message"], "structured line");
assert_eq!(v["fields"]["code"], 7);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn custom_sink_receives_records() {
type CapturedRecord = (tracing::Level, String, Vec<(&'static str, String)>);
#[derive(Clone, Default)]
struct Capture(Arc<std::sync::Mutex<Vec<CapturedRecord>>>);
impl LogSink for Capture {
fn log(&self, record: &LogRecord<'_>) {
self.0.lock().unwrap().push((
record.level,
record.message.clone(),
record.fields.clone(),
));
}
}
let cap = Capture::default();
let (layers, guards) = RuniqueLog::build_layers(vec![LogOutput::sink(cap.clone())]);
let subscriber = tracing_subscriber::registry().with(layers);
tracing::subscriber::with_default(subscriber, || {
tracing::info!(answer = 42, "hello sink");
});
drop(guards);
let recs = cap.0.lock().unwrap();
assert_eq!(recs.len(), 1);
let (level, message, fields) = &recs[0];
assert_eq!(*level, tracing::Level::INFO);
assert_eq!(message, "hello sink");
assert_eq!(fields.as_slice(), &[("answer", "42".to_string())]);
}
}