Implement structured logging with tracing, dual output, and fail2ban-compatible format

- Add logging::init() with dual output (file + stdout) via tracing-subscriber Layer composition
- Support configurable log level via LoggingConfig.level and JSON/text format via LoggingConfig.format
- Create log file and parent directories when log_file_path is configured
- Add KvVisitor for custom key=value event field formatting
- Add log_request!, log_rate_limit!, log_upstream_error!, log_config_reload! macros
  with REQUEST, RATE_LIMIT, UPSTREAM_ERROR, CONFIG_RELOAD prefixes
- Add format_event_fields() for extracting structured fields from tracing events
- Add tracing-subscriber env-filter and json features to Cargo.toml
- Add unit tests for KvVisitor formatting, log macros, and init function
- Apply cargo fmt to existing tls/config.rs tests
This commit is contained in:
2026-06-11 12:47:19 +00:00
parent 468adb21de
commit 36319db10e
5 changed files with 400 additions and 11 deletions

View File

@@ -1,2 +1,187 @@
#[allow(dead_code)]
pub struct LogFormat;
#[derive(Default)]
struct KvVisitor {
pairs: Vec<(String, String)>,
}
impl KvVisitor {
fn format(&self) -> String {
let parts: Vec<String> = self
.pairs
.iter()
.map(|(k, v)| {
if v.contains(' ') || v.contains('"') {
format!(r#"{}="{}""#, k, v.replace('"', "\\\""))
} else {
format!("{}={}", k, v)
}
})
.collect();
parts.join(" ")
}
}
impl tracing::field::Visit for KvVisitor {
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
self.pairs
.push((field.name().to_string(), value.to_string()));
}
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
self.pairs
.push((field.name().to_string(), format!("{:?}", value)));
}
fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
self.pairs
.push((field.name().to_string(), value.to_string()));
}
fn record_i64(&mut self, field: &tracing::field::Field, value: i64) {
self.pairs
.push((field.name().to_string(), value.to_string()));
}
fn record_bool(&mut self, field: &tracing::field::Field, value: bool) {
self.pairs
.push((field.name().to_string(), value.to_string()));
}
}
pub fn format_event_fields(event: &tracing::Event<'_>) -> String {
let mut visitor = KvVisitor::default();
event.record(&mut visitor);
visitor.format()
}
#[macro_export]
macro_rules! log_request {
($client_ip:expr, $host:expr, $method:expr, $path:expr, $status:expr, $upstream:expr, $duration_ms:expr) => {
tracing::info!(
prefix = "REQUEST",
client_ip = %$client_ip,
host = %$host,
method = %$method,
path = %$path,
status = %$status,
upstream = %$upstream,
duration_ms = %$duration_ms,
)
};
}
#[macro_export]
macro_rules! log_rate_limit {
($client_ip:expr, $host:expr, $path:expr, $status:expr) => {
tracing::warn!(
prefix = "RATE_LIMIT",
client_ip = %$client_ip,
host = %$host,
path = %$path,
status = %$status,
)
};
}
#[macro_export]
macro_rules! log_upstream_error {
($host:expr, $upstream:expr, $error:expr) => {
tracing::warn!(
prefix = "UPSTREAM_ERROR",
host = %$host,
upstream = %$upstream,
error = %$error,
)
};
}
#[macro_export]
macro_rules! log_config_reload {
($status:expr, $sites:expr) => {
tracing::info!(
prefix = "CONFIG_RELOAD",
status = %$status,
sites = %$sites,
)
};
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kv_visitor_formats_simple_key_value() {
let mut visitor = KvVisitor::default();
visitor
.pairs
.push(("client_ip".to_string(), "203.0.113.50".to_string()));
visitor
.pairs
.push(("status".to_string(), "200".to_string()));
assert_eq!(visitor.format(), "client_ip=203.0.113.50 status=200");
}
#[test]
fn kv_visitor_quotes_values_with_spaces() {
let mut visitor = KvVisitor::default();
visitor
.pairs
.push(("error".to_string(), "connection refused".to_string()));
assert_eq!(visitor.format(), r#"error="connection refused""#);
}
#[test]
fn kv_visitor_escapes_quotes_in_values() {
let mut visitor = KvVisitor::default();
visitor
.pairs
.push(("error".to_string(), r#"connection "timeout""#.to_string()));
let formatted = visitor.format();
assert!(
formatted.contains(r#"connection \"timeout\""#),
"got: {}",
formatted
);
}
#[test]
fn kv_visitor_handles_numeric_values() {
let mut visitor = KvVisitor::default();
visitor
.pairs
.push(("status".to_string(), "200".to_string()));
visitor
.pairs
.push(("duration_ms".to_string(), "45".to_string()));
let formatted = visitor.format();
assert!(formatted.contains("status=200"));
assert!(formatted.contains("duration_ms=45"));
}
#[test]
fn kv_visitor_handles_empty() {
let visitor = KvVisitor::default();
assert_eq!(visitor.format(), "");
}
#[test]
fn log_macros_compile() {
use tracing_subscriber::layer::SubscriberExt;
let subscriber = tracing_subscriber::registry().with(tracing_subscriber::fmt::layer());
let _guard = tracing::subscriber::set_default(subscriber);
log_request!(
"203.0.113.50",
"git.alk.dev",
"GET",
"/user/repo",
200,
"127.0.0.1:3000",
45u64
);
log_rate_limit!("10.0.0.1", "example.com", "/login", 429u16);
log_upstream_error!("git.alk.dev", "127.0.0.1:3000", "connection refused");
log_config_reload!("success", 1u32);
}
}

View File

@@ -1 +1,112 @@
pub mod format;
use crate::config::static_config::LoggingConfig;
use anyhow::Result;
use std::fs::File;
use std::sync::Arc;
use tracing::Level;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::EnvFilter;
use tracing_subscriber::Layer;
pub fn init(config: &LoggingConfig) -> Result<()> {
let level = config.level.parse::<Level>().unwrap_or(Level::INFO);
let env_filter = make_env_filter(level);
match config.format.as_str() {
"json" => init_json(env_filter, &config.log_file_path, level),
_ => init_text(env_filter, &config.log_file_path, level),
}
}
fn make_env_filter(level: Level) -> EnvFilter {
EnvFilter::from_default_env().add_directive(level.into())
}
fn init_json(env_filter: EnvFilter, log_file_path: &Option<String>, level: Level) -> Result<()> {
match log_file_path {
Some(path) => {
if let Some(parent) = std::path::Path::new(path).parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
let file = File::create(path)?;
let file_writer = Arc::new(file);
let file_env_filter = make_env_filter(level);
let stdout_layer = tracing_subscriber::fmt::layer()
.json()
.with_filter(env_filter);
let file_layer = tracing_subscriber::fmt::layer()
.json()
.with_writer(file_writer)
.with_filter(file_env_filter);
tracing_subscriber::registry()
.with(stdout_layer)
.with(file_layer)
.try_init()?;
}
None => {
let layer = tracing_subscriber::fmt::layer()
.json()
.with_filter(env_filter);
tracing_subscriber::registry().with(layer).try_init()?;
}
}
Ok(())
}
fn init_text(env_filter: EnvFilter, log_file_path: &Option<String>, level: Level) -> Result<()> {
match log_file_path {
Some(path) => {
if let Some(parent) = std::path::Path::new(path).parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
let file = File::create(path)?;
let file_writer = Arc::new(file);
let file_env_filter = make_env_filter(level);
let stdout_layer = tracing_subscriber::fmt::layer().with_filter(env_filter);
let file_layer = tracing_subscriber::fmt::layer()
.with_writer(file_writer)
.with_filter(file_env_filter);
tracing_subscriber::registry()
.with(stdout_layer)
.with(file_layer)
.try_init()?;
}
None => {
let layer = tracing_subscriber::fmt::layer().with_filter(env_filter);
tracing_subscriber::registry().with(layer).try_init()?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::static_config::LoggingConfig;
#[test]
fn init_creates_log_directory_and_file() {
let dir = tempfile::tempdir().unwrap();
let log_path = dir.path().join("logs").join("access.log");
let config = LoggingConfig {
level: "info".to_string(),
format: "text".to_string(),
log_file_path: Some(log_path.to_string_lossy().to_string()),
};
let result = init(&config);
assert!(result.is_ok(), "init failed: {:?}", result.err());
assert!(log_path.exists(), "log file should be created");
}
}

View File

@@ -207,13 +207,27 @@ mod tests {
.map(|cs| format!("{cs:?}"))
.collect();
assert!(cipher_suites.iter().any(|cs| cs.contains("AES_256_GCM_SHA384")));
assert!(cipher_suites.iter().any(|cs| cs.contains("AES_128_GCM_SHA256")));
assert!(cipher_suites.iter().any(|cs| cs.contains("CHACHA20_POLY1305_SHA256")));
assert!(cipher_suites.iter().any(|cs| cs.contains("ECDHE_ECDSA_WITH_AES_256_GCM_SHA384")));
assert!(cipher_suites.iter().any(|cs| cs.contains("ECDHE_ECDSA_WITH_AES_128_GCM_SHA256")));
assert!(cipher_suites.iter().any(|cs| cs.contains("ECDHE_RSA_WITH_AES_256_GCM_SHA384")));
assert!(cipher_suites.iter().any(|cs| cs.contains("ECDHE_RSA_WITH_AES_128_GCM_SHA256")));
assert!(cipher_suites
.iter()
.any(|cs| cs.contains("AES_256_GCM_SHA384")));
assert!(cipher_suites
.iter()
.any(|cs| cs.contains("AES_128_GCM_SHA256")));
assert!(cipher_suites
.iter()
.any(|cs| cs.contains("CHACHA20_POLY1305_SHA256")));
assert!(cipher_suites
.iter()
.any(|cs| cs.contains("ECDHE_ECDSA_WITH_AES_256_GCM_SHA384")));
assert!(cipher_suites
.iter()
.any(|cs| cs.contains("ECDHE_ECDSA_WITH_AES_128_GCM_SHA256")));
assert!(cipher_suites
.iter()
.any(|cs| cs.contains("ECDHE_RSA_WITH_AES_256_GCM_SHA384")));
assert!(cipher_suites
.iter()
.any(|cs| cs.contains("ECDHE_RSA_WITH_AES_128_GCM_SHA256")));
}
#[test]
@@ -310,4 +324,4 @@ mod tests {
let result = load_private_key("/nonexistent/path/key.pem");
assert!(result.is_err());
}
}
}