use serde::Deserialize;
use std::fs::{File, OpenOptions};
use std::path::Path;
use std::sync::{Mutex, Once};
use tracing_subscriber::fmt::writer::BoxMakeWriter;
use tracing_subscriber::{fmt, EnvFilter};
pub const REQUEST_TARGET: &str = "doido::request";
pub const RESPONSE_TARGET: &str = "doido::response";
pub const NOISE_DIRECTIVES: &str = "sqlx=warn,sqlx::query=info,hyper=warn,tower=warn";
pub const DEFAULT_DIRECTIVES: &str = "info,sqlx=warn,sqlx::query=info,hyper=warn,tower=warn";
pub fn directives_for_level(level: &str) -> String {
format!("{level},{NOISE_DIRECTIVES}")
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LogFormat {
#[default]
Compact,
Verbose,
JsonResponse,
}
#[derive(Debug, Clone, Deserialize)]
pub struct LoggerConfig {
#[serde(default = "default_level")]
pub level: String,
#[serde(default)]
pub directives: Option<String>,
#[serde(default)]
pub file: Option<String>,
#[serde(default = "default_sql")]
pub sql: bool,
#[serde(default)]
pub format: LogFormat,
}
fn default_level() -> String {
"info".to_string()
}
fn default_sql() -> bool {
true
}
impl Default for LoggerConfig {
fn default() -> Self {
Self {
level: default_level(),
directives: None,
file: None,
sql: default_sql(),
format: LogFormat::default(),
}
}
}
impl LoggerConfig {
pub fn directives(&self) -> String {
match (&self.directives, self.format) {
(Some(directives), _) => directives.clone(),
(None, LogFormat::JsonResponse) => format!("off,{RESPONSE_TARGET}=info"),
(None, _) => directives_for_level(&self.level),
}
}
}
static INIT: Once = Once::new();
pub fn init() {
init_with_config(&LoggerConfig::default());
}
pub fn init_with(default_directives: &str) {
init_with_config(&LoggerConfig {
directives: Some(default_directives.to_string()),
..LoggerConfig::default()
});
}
pub fn init_with_config(config: &LoggerConfig) {
INIT.call_once(|| {
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new(config.directives()));
let (writer, to_file) = match open_log_file(config.file.as_deref()) {
Some(file) => (BoxMakeWriter::new(Mutex::new(file)), true),
None => (BoxMakeWriter::new(std::io::stdout), false),
};
let builder = fmt().with_env_filter(filter).with_writer(writer);
let builder = if to_file {
builder.with_ansi(false)
} else {
builder
};
match config.format {
LogFormat::Compact => {
let _ = builder.with_target(true).try_init();
}
LogFormat::Verbose => {
let _ = builder
.pretty()
.with_target(true)
.with_thread_names(true)
.with_file(true)
.with_line_number(true)
.try_init();
}
LogFormat::JsonResponse => {
let _ = builder.json().flatten_event(true).try_init();
}
}
});
}
fn open_log_file(path: Option<&str>) -> Option<File> {
let path = path?;
if let Some(parent) = Path::new(path).parent() {
if !parent.as_os_str().is_empty() {
let _ = std::fs::create_dir_all(parent);
}
}
match OpenOptions::new().create(true).append(true).open(path) {
Ok(file) => Some(file),
Err(e) => {
eprintln!("doido: could not open log file '{path}': {e}; logging to stdout");
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_directives_are_valid() {
assert!(EnvFilter::try_new(DEFAULT_DIRECTIVES).is_ok());
}
#[test]
fn info_level_matches_default_directives() {
assert_eq!(directives_for_level("info"), DEFAULT_DIRECTIVES);
}
#[test]
fn level_is_prepended_to_noise_directives() {
let directives = directives_for_level("debug");
assert!(directives.starts_with("debug,"));
assert!(directives.ends_with(NOISE_DIRECTIVES));
assert!(EnvFilter::try_new(&directives).is_ok());
}
#[test]
fn config_defaults_to_info_and_sql_on() {
let config = LoggerConfig::default();
assert_eq!(config.level, "info");
assert!(config.sql);
assert!(config.file.is_none());
assert_eq!(config.directives(), DEFAULT_DIRECTIVES);
}
#[test]
fn explicit_directives_override_level() {
let config = LoggerConfig {
level: "info".to_string(),
directives: Some("warn,my_app=debug".to_string()),
..LoggerConfig::default()
};
assert_eq!(config.directives(), "warn,my_app=debug");
}
#[test]
fn default_format_is_compact() {
assert_eq!(LoggerConfig::default().format, LogFormat::Compact);
}
#[test]
fn json_response_format_isolates_response_target() {
let config = LoggerConfig {
format: LogFormat::JsonResponse,
..LoggerConfig::default()
};
let directives = config.directives();
assert_eq!(directives, format!("off,{RESPONSE_TARGET}=info"));
assert!(EnvFilter::try_new(&directives).is_ok());
}
#[test]
fn explicit_directives_win_over_json_response_default() {
let config = LoggerConfig {
format: LogFormat::JsonResponse,
directives: Some("info".to_string()),
..LoggerConfig::default()
};
assert_eq!(config.directives(), "info");
}
#[test]
fn format_deserializes_from_snake_case() {
#[derive(serde::Deserialize)]
struct Wrapper {
format: LogFormat,
}
let parsed: Wrapper = serde_norway::from_str("format: json_response\n").unwrap();
assert_eq!(parsed.format, LogFormat::JsonResponse);
let parsed: Wrapper = serde_norway::from_str("format: verbose\n").unwrap();
assert_eq!(parsed.format, LogFormat::Verbose);
}
}