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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user