use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Instant;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccessLogEntry {
pub timestamp: String,
pub client_ip: String,
pub method: String,
pub path: String,
pub host: Option<String>,
pub status: u16,
pub response_bytes: u64,
pub duration_ms: u64,
pub backend: Option<String>,
pub router: Option<String>,
pub entrypoint: Option<String>,
pub user_agent: Option<String>,
}
pub struct AccessLog {
total_entries: Arc<AtomicU64>,
}
impl AccessLog {
pub fn new() -> Self {
Self {
total_entries: Arc::new(AtomicU64::new(0)),
}
}
pub fn start_request(&self) -> RequestTracker {
RequestTracker {
start: Instant::now(),
}
}
pub fn record(&self, entry: &AccessLogEntry) {
self.total_entries.fetch_add(1, Ordering::Relaxed);
tracing::info!(
target: "access_log",
client_ip = entry.client_ip,
method = entry.method,
path = entry.path,
status = entry.status,
duration_ms = entry.duration_ms,
response_bytes = entry.response_bytes,
backend = entry.backend.as_deref().unwrap_or("-"),
router = entry.router.as_deref().unwrap_or("-"),
"{}",
serde_json::to_string(entry).unwrap_or_default()
);
}
#[allow(dead_code)]
pub fn total_entries(&self) -> u64 {
self.total_entries.load(Ordering::Relaxed)
}
}
impl Default for AccessLog {
fn default() -> Self {
Self::new()
}
}
pub struct RequestTracker {
start: Instant,
}
impl RequestTracker {
pub fn elapsed_ms(&self) -> u64 {
self.start.elapsed().as_millis() as u64
}
#[allow(clippy::too_many_arguments)]
pub fn build_entry(
&self,
client_ip: String,
method: String,
path: String,
host: Option<String>,
status: u16,
response_bytes: u64,
backend: Option<String>,
router: Option<String>,
entrypoint: Option<String>,
user_agent: Option<String>,
) -> AccessLogEntry {
AccessLogEntry {
timestamp: chrono::Utc::now().to_rfc3339(),
client_ip,
method,
path,
host,
status,
response_bytes,
duration_ms: self.elapsed_ms(),
backend,
router,
entrypoint,
user_agent,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_entry() -> AccessLogEntry {
AccessLogEntry {
timestamp: "2026-01-01T00:00:00Z".to_string(),
client_ip: "10.0.0.1".to_string(),
method: "GET".to_string(),
path: "/api/v1/users".to_string(),
host: Some("api.example.com".to_string()),
status: 200,
response_bytes: 1024,
duration_ms: 42,
backend: Some("http://backend:8080".to_string()),
router: Some("api".to_string()),
entrypoint: Some("websecure".to_string()),
user_agent: Some("curl/8.0".to_string()),
}
}
#[test]
fn test_entry_serialization() {
let entry = sample_entry();
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains("\"method\":\"GET\""));
assert!(json.contains("\"status\":200"));
let parsed: AccessLogEntry = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.method, "GET");
assert_eq!(parsed.status, 200);
assert_eq!(parsed.path, "/api/v1/users");
}
#[test]
fn test_entry_with_none_fields() {
let entry = AccessLogEntry {
timestamp: "2026-01-01T00:00:00Z".to_string(),
client_ip: "10.0.0.1".to_string(),
method: "GET".to_string(),
path: "/".to_string(),
host: None,
status: 404,
response_bytes: 0,
duration_ms: 1,
backend: None,
router: None,
entrypoint: None,
user_agent: None,
};
let json = serde_json::to_string(&entry).unwrap();
let parsed: AccessLogEntry = serde_json::from_str(&json).unwrap();
assert!(parsed.host.is_none());
assert!(parsed.backend.is_none());
}
#[test]
fn test_access_log_total_entries() {
let log = AccessLog::new();
assert_eq!(log.total_entries(), 0);
log.record(&sample_entry());
assert_eq!(log.total_entries(), 1);
log.record(&sample_entry());
assert_eq!(log.total_entries(), 2);
}
#[test]
fn test_access_log_default() {
let log = AccessLog::default();
assert_eq!(log.total_entries(), 0);
}
#[test]
fn test_request_tracker_elapsed() {
let log = AccessLog::new();
let tracker = log.start_request();
std::thread::sleep(std::time::Duration::from_millis(10));
let elapsed = tracker.elapsed_ms();
assert!(elapsed >= 5); }
#[test]
fn test_request_tracker_build_entry() {
let log = AccessLog::new();
let tracker = log.start_request();
let entry = tracker.build_entry(
"10.0.0.1".to_string(),
"POST".to_string(),
"/api/submit".to_string(),
Some("api.example.com".to_string()),
201,
256,
Some("http://backend:8080".to_string()),
Some("api".to_string()),
Some("websecure".to_string()),
None,
);
assert_eq!(entry.method, "POST");
assert_eq!(entry.status, 201);
assert_eq!(entry.response_bytes, 256);
assert!(!entry.timestamp.is_empty());
}
#[test]
fn test_entry_all_status_codes() {
for status in [200u16, 201, 301, 400, 403, 404, 500, 502, 503] {
let entry = AccessLogEntry {
status,
..sample_entry()
};
let json = serde_json::to_string(&entry).unwrap();
let parsed: AccessLogEntry = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.status, status);
}
}
}