sentinel_proxy/
logging.rs

1//! Logging infrastructure for Sentinel proxy
2//!
3//! This module provides structured logging to files for:
4//! - Access logs (request/response data with trace_id)
5//! - Error logs (errors and warnings)
6//! - Audit logs (security events)
7//!
8//! Access log formats supported:
9//! - `json` (default): Structured JSON with all fields
10//! - `combined`: Apache/nginx Combined Log Format with trace_id extension
11
12use anyhow::{Context, Result};
13use parking_lot::Mutex;
14use serde::Serialize;
15use std::fs::{File, OpenOptions};
16use std::io::{BufWriter, Write};
17use std::path::Path;
18use std::sync::Arc;
19use tracing::{error, warn};
20
21use sentinel_config::{AuditLogConfig, LoggingConfig};
22
23/// Access log format
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum AccessLogFormat {
26    /// Structured JSON format (default)
27    Json,
28    /// Apache/nginx Combined Log Format with trace_id extension
29    Combined,
30}
31
32/// Access log entry with trace_id for request correlation
33#[derive(Debug, Serialize)]
34pub struct AccessLogEntry {
35    /// Timestamp in RFC3339 format
36    pub timestamp: String,
37    /// Unique trace ID for request correlation
38    pub trace_id: String,
39    /// HTTP method
40    pub method: String,
41    /// Request path
42    pub path: String,
43    /// Query string (if any)
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub query: Option<String>,
46    /// HTTP protocol version
47    pub protocol: String,
48    /// Response status code
49    pub status: u16,
50    /// Response body size in bytes
51    pub body_bytes: u64,
52    /// Request duration in milliseconds
53    pub duration_ms: u64,
54    /// Client IP address
55    pub client_ip: String,
56    /// User-Agent header
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub user_agent: Option<String>,
59    /// Referer header
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub referer: Option<String>,
62    /// Host header
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub host: Option<String>,
65    /// Matched route ID
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub route_id: Option<String>,
68    /// Selected upstream
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub upstream: Option<String>,
71    /// Number of upstream attempts
72    pub upstream_attempts: u32,
73    /// Instance ID of the proxy
74    pub instance_id: String,
75}
76
77impl AccessLogEntry {
78    /// Format the entry as a string based on the specified format
79    pub fn format(&self, format: AccessLogFormat) -> String {
80        match format {
81            AccessLogFormat::Json => self.format_json(),
82            AccessLogFormat::Combined => self.format_combined(),
83        }
84    }
85
86    /// Format as JSON
87    fn format_json(&self) -> String {
88        serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
89    }
90
91    /// Format as Combined Log Format with trace_id extension
92    /// Format: client_ip - - [timestamp] "method path?query protocol" status bytes "referer" "user_agent" trace_id duration_ms
93    fn format_combined(&self) -> String {
94        // Parse RFC3339 timestamp to CLF format [day/month/year:hour:min:sec zone]
95        let clf_timestamp = self.format_clf_timestamp();
96
97        // Build request line
98        let request_line = if let Some(ref query) = self.query {
99            format!("{} {}?{} {}", self.method, self.path, query, self.protocol)
100        } else {
101            format!("{} {} {}", self.method, self.path, self.protocol)
102        };
103
104        // Escape and format optional fields
105        let referer = self.referer.as_deref().unwrap_or("-");
106        let user_agent = self.user_agent.as_deref().unwrap_or("-");
107
108        // Combined format with trace_id and duration extensions
109        format!(
110            "{} - - [{}] \"{}\" {} {} \"{}\" \"{}\" {} {}ms",
111            self.client_ip,
112            clf_timestamp,
113            request_line,
114            self.status,
115            self.body_bytes,
116            referer,
117            user_agent,
118            self.trace_id,
119            self.duration_ms
120        )
121    }
122
123    /// Convert RFC3339 timestamp to Common Log Format timestamp
124    fn format_clf_timestamp(&self) -> String {
125        // Try to parse and reformat, fallback to original if parsing fails
126        if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&self.timestamp) {
127            dt.format("%d/%b/%Y:%H:%M:%S %z").to_string()
128        } else {
129            self.timestamp.clone()
130        }
131    }
132}
133
134/// Error log entry
135#[derive(Debug, Serialize)]
136pub struct ErrorLogEntry {
137    /// Timestamp in RFC3339 format
138    pub timestamp: String,
139    /// Trace ID for correlation
140    pub trace_id: String,
141    /// Log level (warn, error)
142    pub level: String,
143    /// Error message
144    pub message: String,
145    /// Route ID if available
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub route_id: Option<String>,
148    /// Upstream if available
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub upstream: Option<String>,
151    /// Error details/context
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub details: Option<String>,
154}
155
156/// Audit event type for categorizing security events
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
158#[serde(rename_all = "snake_case")]
159pub enum AuditEventType {
160    /// Request blocked by policy
161    Blocked,
162    /// Agent made a decision
163    AgentDecision,
164    /// WAF rule matched
165    WafMatch,
166    /// WAF blocked request
167    WafBlock,
168    /// Rate limit exceeded
169    RateLimitExceeded,
170    /// Authentication event
171    AuthEvent,
172    /// Configuration change
173    ConfigChange,
174    /// Certificate reload
175    CertReload,
176    /// Circuit breaker state change
177    CircuitBreakerChange,
178    /// Cache purge request
179    CachePurge,
180    /// Admin action
181    AdminAction,
182    /// Custom event
183    Custom,
184}
185
186impl std::fmt::Display for AuditEventType {
187    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188        match self {
189            AuditEventType::Blocked => write!(f, "blocked"),
190            AuditEventType::AgentDecision => write!(f, "agent_decision"),
191            AuditEventType::WafMatch => write!(f, "waf_match"),
192            AuditEventType::WafBlock => write!(f, "waf_block"),
193            AuditEventType::RateLimitExceeded => write!(f, "rate_limit_exceeded"),
194            AuditEventType::AuthEvent => write!(f, "auth_event"),
195            AuditEventType::ConfigChange => write!(f, "config_change"),
196            AuditEventType::CertReload => write!(f, "cert_reload"),
197            AuditEventType::CircuitBreakerChange => write!(f, "circuit_breaker_change"),
198            AuditEventType::CachePurge => write!(f, "cache_purge"),
199            AuditEventType::AdminAction => write!(f, "admin_action"),
200            AuditEventType::Custom => write!(f, "custom"),
201        }
202    }
203}
204
205/// Audit log entry for security events
206#[derive(Debug, Serialize)]
207pub struct AuditLogEntry {
208    /// Timestamp in RFC3339 format
209    pub timestamp: String,
210    /// Trace ID for correlation
211    pub trace_id: String,
212    /// Event type (blocked, agent_decision, waf_match, etc.)
213    pub event_type: String,
214    /// HTTP method
215    pub method: String,
216    /// Request path
217    pub path: String,
218    /// Client IP
219    pub client_ip: String,
220    /// Route ID
221    #[serde(skip_serializing_if = "Option::is_none")]
222    pub route_id: Option<String>,
223    /// Block reason if blocked
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub reason: Option<String>,
226    /// Agent that made the decision
227    #[serde(skip_serializing_if = "Option::is_none")]
228    pub agent_id: Option<String>,
229    /// WAF rule IDs matched
230    #[serde(skip_serializing_if = "Vec::is_empty")]
231    pub rule_ids: Vec<String>,
232    /// Additional tags
233    #[serde(skip_serializing_if = "Vec::is_empty")]
234    pub tags: Vec<String>,
235    /// Action taken (allow, block, challenge, redirect)
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub action: Option<String>,
238    /// Response status code
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub status_code: Option<u16>,
241    /// User ID if authenticated
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub user_id: Option<String>,
244    /// Session ID if available
245    #[serde(skip_serializing_if = "Option::is_none")]
246    pub session_id: Option<String>,
247    /// Additional metadata as key-value pairs
248    #[serde(skip_serializing_if = "std::collections::HashMap::is_empty")]
249    pub metadata: std::collections::HashMap<String, String>,
250}
251
252impl AuditLogEntry {
253    /// Create a new audit log entry with required fields
254    pub fn new(
255        trace_id: impl Into<String>,
256        event_type: AuditEventType,
257        method: impl Into<String>,
258        path: impl Into<String>,
259        client_ip: impl Into<String>,
260    ) -> Self {
261        Self {
262            timestamp: chrono::Utc::now().to_rfc3339(),
263            trace_id: trace_id.into(),
264            event_type: event_type.to_string(),
265            method: method.into(),
266            path: path.into(),
267            client_ip: client_ip.into(),
268            route_id: None,
269            reason: None,
270            agent_id: None,
271            rule_ids: Vec::new(),
272            tags: Vec::new(),
273            action: None,
274            status_code: None,
275            user_id: None,
276            session_id: None,
277            metadata: std::collections::HashMap::new(),
278        }
279    }
280
281    /// Builder: set route ID
282    pub fn with_route_id(mut self, route_id: impl Into<String>) -> Self {
283        self.route_id = Some(route_id.into());
284        self
285    }
286
287    /// Builder: set reason
288    pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
289        self.reason = Some(reason.into());
290        self
291    }
292
293    /// Builder: set agent ID
294    pub fn with_agent_id(mut self, agent_id: impl Into<String>) -> Self {
295        self.agent_id = Some(agent_id.into());
296        self
297    }
298
299    /// Builder: add rule IDs
300    pub fn with_rule_ids(mut self, rule_ids: Vec<String>) -> Self {
301        self.rule_ids = rule_ids;
302        self
303    }
304
305    /// Builder: add tags
306    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
307        self.tags = tags;
308        self
309    }
310
311    /// Builder: set action
312    pub fn with_action(mut self, action: impl Into<String>) -> Self {
313        self.action = Some(action.into());
314        self
315    }
316
317    /// Builder: set status code
318    pub fn with_status_code(mut self, status_code: u16) -> Self {
319        self.status_code = Some(status_code);
320        self
321    }
322
323    /// Builder: set user ID
324    pub fn with_user_id(mut self, user_id: impl Into<String>) -> Self {
325        self.user_id = Some(user_id.into());
326        self
327    }
328
329    /// Builder: set session ID
330    pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
331        self.session_id = Some(session_id.into());
332        self
333    }
334
335    /// Builder: add metadata
336    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
337        self.metadata.insert(key.into(), value.into());
338        self
339    }
340
341    /// Create an entry for a blocked request
342    pub fn blocked(
343        trace_id: impl Into<String>,
344        method: impl Into<String>,
345        path: impl Into<String>,
346        client_ip: impl Into<String>,
347        reason: impl Into<String>,
348    ) -> Self {
349        Self::new(trace_id, AuditEventType::Blocked, method, path, client_ip)
350            .with_reason(reason)
351            .with_action("block")
352    }
353
354    /// Create an entry for rate limit exceeded
355    pub fn rate_limited(
356        trace_id: impl Into<String>,
357        method: impl Into<String>,
358        path: impl Into<String>,
359        client_ip: impl Into<String>,
360        limit_key: impl Into<String>,
361    ) -> Self {
362        Self::new(
363            trace_id,
364            AuditEventType::RateLimitExceeded,
365            method,
366            path,
367            client_ip,
368        )
369        .with_reason("Rate limit exceeded")
370        .with_action("block")
371        .with_metadata("limit_key", limit_key)
372    }
373
374    /// Create an entry for WAF block
375    pub fn waf_blocked(
376        trace_id: impl Into<String>,
377        method: impl Into<String>,
378        path: impl Into<String>,
379        client_ip: impl Into<String>,
380        rule_ids: Vec<String>,
381    ) -> Self {
382        Self::new(trace_id, AuditEventType::WafBlock, method, path, client_ip)
383            .with_rule_ids(rule_ids)
384            .with_action("block")
385    }
386
387    /// Create an entry for configuration change
388    pub fn config_change(
389        trace_id: impl Into<String>,
390        change_type: impl Into<String>,
391        details: impl Into<String>,
392    ) -> Self {
393        Self::new(
394            trace_id,
395            AuditEventType::ConfigChange,
396            "-",
397            "/-/config",
398            "internal",
399        )
400        .with_reason(change_type)
401        .with_metadata("details", details)
402    }
403
404    /// Create an entry for certificate reload
405    pub fn cert_reload(
406        trace_id: impl Into<String>,
407        listener_id: impl Into<String>,
408        success: bool,
409    ) -> Self {
410        Self::new(
411            trace_id,
412            AuditEventType::CertReload,
413            "-",
414            "/-/certs",
415            "internal",
416        )
417        .with_metadata("listener_id", listener_id)
418        .with_metadata("success", success.to_string())
419    }
420
421    /// Create an entry for cache purge
422    pub fn cache_purge(
423        trace_id: impl Into<String>,
424        method: impl Into<String>,
425        path: impl Into<String>,
426        client_ip: impl Into<String>,
427        pattern: impl Into<String>,
428    ) -> Self {
429        Self::new(
430            trace_id,
431            AuditEventType::CachePurge,
432            method,
433            path,
434            client_ip,
435        )
436        .with_metadata("pattern", pattern)
437        .with_action("purge")
438    }
439
440    /// Create an entry for admin action
441    pub fn admin_action(
442        trace_id: impl Into<String>,
443        method: impl Into<String>,
444        path: impl Into<String>,
445        client_ip: impl Into<String>,
446        action: impl Into<String>,
447    ) -> Self {
448        Self::new(
449            trace_id,
450            AuditEventType::AdminAction,
451            method,
452            path,
453            client_ip,
454        )
455        .with_action(action)
456    }
457}
458
459/// Buffered file writer for log files
460struct LogFileWriter {
461    writer: BufWriter<File>,
462}
463
464impl LogFileWriter {
465    fn new(path: &Path, buffer_size: usize) -> Result<Self> {
466        // Create parent directories if they don't exist
467        if let Some(parent) = path.parent() {
468            std::fs::create_dir_all(parent)
469                .with_context(|| format!("Failed to create log directory: {:?}", parent))?;
470        }
471
472        let file = OpenOptions::new()
473            .create(true)
474            .append(true)
475            .open(path)
476            .with_context(|| format!("Failed to open log file: {:?}", path))?;
477
478        Ok(Self {
479            writer: BufWriter::with_capacity(buffer_size, file),
480        })
481    }
482
483    fn write_line(&mut self, line: &str) -> Result<()> {
484        writeln!(self.writer, "{}", line)?;
485        Ok(())
486    }
487
488    fn flush(&mut self) -> Result<()> {
489        self.writer.flush()?;
490        Ok(())
491    }
492}
493
494/// Log manager handling all log file writers
495pub struct LogManager {
496    access_log: Option<Mutex<LogFileWriter>>,
497    access_log_format: AccessLogFormat,
498    error_log: Option<Mutex<LogFileWriter>>,
499    audit_log: Option<Mutex<LogFileWriter>>,
500    audit_config: Option<AuditLogConfig>,
501}
502
503impl LogManager {
504    /// Create a new log manager from configuration
505    pub fn new(config: &LoggingConfig) -> Result<Self> {
506        let (access_log, access_log_format) = if let Some(ref access_config) = config.access_log {
507            if access_config.enabled {
508                let format = Self::parse_access_format(&access_config.format);
509                let writer = Mutex::new(LogFileWriter::new(
510                    &access_config.file,
511                    access_config.buffer_size,
512                )?);
513                (Some(writer), format)
514            } else {
515                (None, AccessLogFormat::Json)
516            }
517        } else {
518            (None, AccessLogFormat::Json)
519        };
520
521        let error_log = if let Some(ref error_config) = config.error_log {
522            if error_config.enabled {
523                Some(Mutex::new(LogFileWriter::new(
524                    &error_config.file,
525                    error_config.buffer_size,
526                )?))
527            } else {
528                None
529            }
530        } else {
531            None
532        };
533
534        let audit_log = if let Some(ref audit_config) = config.audit_log {
535            if audit_config.enabled {
536                Some(Mutex::new(LogFileWriter::new(
537                    &audit_config.file,
538                    audit_config.buffer_size,
539                )?))
540            } else {
541                None
542            }
543        } else {
544            None
545        };
546
547        Ok(Self {
548            access_log,
549            access_log_format,
550            error_log,
551            audit_log,
552            audit_config: config.audit_log.clone(),
553        })
554    }
555
556    /// Create a disabled log manager (no file logging)
557    pub fn disabled() -> Self {
558        Self {
559            access_log: None,
560            access_log_format: AccessLogFormat::Json,
561            error_log: None,
562            audit_log: None,
563            audit_config: None,
564        }
565    }
566
567    /// Parse access log format from config string
568    fn parse_access_format(format: &str) -> AccessLogFormat {
569        match format.to_lowercase().as_str() {
570            "combined" | "clf" | "common" => AccessLogFormat::Combined,
571            _ => AccessLogFormat::Json, // Default to JSON
572        }
573    }
574
575    /// Write an access log entry
576    pub fn log_access(&self, entry: &AccessLogEntry) {
577        if let Some(ref writer) = self.access_log {
578            let formatted = entry.format(self.access_log_format);
579            let mut guard = writer.lock();
580            if let Err(e) = guard.write_line(&formatted) {
581                error!("Failed to write access log: {}", e);
582            }
583        }
584    }
585
586    /// Write an error log entry
587    pub fn log_error(&self, entry: &ErrorLogEntry) {
588        if let Some(ref writer) = self.error_log {
589            match serde_json::to_string(entry) {
590                Ok(json) => {
591                    let mut guard = writer.lock();
592                    if let Err(e) = guard.write_line(&json) {
593                        error!("Failed to write error log: {}", e);
594                    }
595                }
596                Err(e) => {
597                    error!("Failed to serialize error log entry: {}", e);
598                }
599            }
600        }
601    }
602
603    /// Write an audit log entry
604    pub fn log_audit(&self, entry: &AuditLogEntry) {
605        if let Some(ref writer) = self.audit_log {
606            if let Some(ref config) = self.audit_config {
607                // Check if we should log this event type
608                let should_log = match entry.event_type.as_str() {
609                    "blocked" => config.log_blocked,
610                    "agent_decision" => config.log_agent_decisions,
611                    "waf_match" | "waf_block" => config.log_waf_events,
612                    _ => true, // Log other event types by default
613                };
614
615                if !should_log {
616                    return;
617                }
618            }
619
620            match serde_json::to_string(entry) {
621                Ok(json) => {
622                    let mut guard = writer.lock();
623                    if let Err(e) = guard.write_line(&json) {
624                        error!("Failed to write audit log: {}", e);
625                    }
626                }
627                Err(e) => {
628                    error!("Failed to serialize audit log entry: {}", e);
629                }
630            }
631        }
632    }
633
634    /// Flush all log buffers
635    pub fn flush(&self) {
636        if let Some(ref writer) = self.access_log {
637            if let Err(e) = writer.lock().flush() {
638                warn!("Failed to flush access log: {}", e);
639            }
640        }
641        if let Some(ref writer) = self.error_log {
642            if let Err(e) = writer.lock().flush() {
643                warn!("Failed to flush error log: {}", e);
644            }
645        }
646        if let Some(ref writer) = self.audit_log {
647            if let Err(e) = writer.lock().flush() {
648                warn!("Failed to flush audit log: {}", e);
649            }
650        }
651    }
652
653    /// Check if access logging is enabled
654    pub fn access_log_enabled(&self) -> bool {
655        self.access_log.is_some()
656    }
657
658    /// Check if error logging is enabled
659    pub fn error_log_enabled(&self) -> bool {
660        self.error_log.is_some()
661    }
662
663    /// Check if audit logging is enabled
664    pub fn audit_log_enabled(&self) -> bool {
665        self.audit_log.is_some()
666    }
667}
668
669/// Shared log manager that can be passed around
670pub type SharedLogManager = Arc<LogManager>;
671
672#[cfg(test)]
673mod tests {
674    use super::*;
675    use sentinel_config::{AccessLogConfig, ErrorLogConfig};
676    use tempfile::tempdir;
677
678    #[test]
679    fn test_access_log_entry_serialization() {
680        let entry = AccessLogEntry {
681            timestamp: "2024-01-01T00:00:00Z".to_string(),
682            trace_id: "abc123".to_string(),
683            method: "GET".to_string(),
684            path: "/api/users".to_string(),
685            query: Some("page=1".to_string()),
686            protocol: "HTTP/1.1".to_string(),
687            status: 200,
688            body_bytes: 1024,
689            duration_ms: 50,
690            client_ip: "192.168.1.1".to_string(),
691            user_agent: Some("Mozilla/5.0".to_string()),
692            referer: None,
693            host: Some("example.com".to_string()),
694            route_id: Some("api-route".to_string()),
695            upstream: Some("backend-1".to_string()),
696            upstream_attempts: 1,
697            instance_id: "instance-1".to_string(),
698        };
699
700        let json = serde_json::to_string(&entry).unwrap();
701        assert!(json.contains("\"trace_id\":\"abc123\""));
702        assert!(json.contains("\"status\":200"));
703    }
704
705    #[test]
706    fn test_log_manager_creation() {
707        let dir = tempdir().unwrap();
708        let access_log_path = dir.path().join("access.log");
709        let error_log_path = dir.path().join("error.log");
710        let audit_log_path = dir.path().join("audit.log");
711
712        let config = LoggingConfig {
713            level: "info".to_string(),
714            format: "json".to_string(),
715            timestamps: true,
716            file: None,
717            access_log: Some(AccessLogConfig {
718                enabled: true,
719                file: access_log_path.clone(),
720                format: "json".to_string(),
721                buffer_size: 8192,
722                include_trace_id: true,
723            }),
724            error_log: Some(ErrorLogConfig {
725                enabled: true,
726                file: error_log_path.clone(),
727                level: "warn".to_string(),
728                buffer_size: 8192,
729            }),
730            audit_log: Some(AuditLogConfig {
731                enabled: true,
732                file: audit_log_path.clone(),
733                buffer_size: 8192,
734                log_blocked: true,
735                log_agent_decisions: true,
736                log_waf_events: true,
737            }),
738        };
739
740        let manager = LogManager::new(&config).unwrap();
741        assert!(manager.access_log_enabled());
742        assert!(manager.error_log_enabled());
743        assert!(manager.audit_log_enabled());
744    }
745
746    #[test]
747    fn test_access_log_combined_format() {
748        let entry = AccessLogEntry {
749            timestamp: "2024-01-15T10:30:00+00:00".to_string(),
750            trace_id: "trace-abc123".to_string(),
751            method: "GET".to_string(),
752            path: "/api/users".to_string(),
753            query: Some("page=1".to_string()),
754            protocol: "HTTP/1.1".to_string(),
755            status: 200,
756            body_bytes: 1024,
757            duration_ms: 50,
758            client_ip: "192.168.1.1".to_string(),
759            user_agent: Some("Mozilla/5.0".to_string()),
760            referer: Some("https://example.com/".to_string()),
761            host: Some("api.example.com".to_string()),
762            route_id: Some("api-route".to_string()),
763            upstream: Some("backend-1".to_string()),
764            upstream_attempts: 1,
765            instance_id: "instance-1".to_string(),
766        };
767
768        let combined = entry.format(AccessLogFormat::Combined);
769
770        // Check Combined format structure
771        assert!(combined.starts_with("192.168.1.1 - - ["));
772        assert!(combined.contains("\"GET /api/users?page=1 HTTP/1.1\""));
773        assert!(combined.contains(" 200 1024 "));
774        assert!(combined.contains("\"https://example.com/\""));
775        assert!(combined.contains("\"Mozilla/5.0\""));
776        assert!(combined.contains("trace-abc123"));
777        assert!(combined.ends_with("50ms"));
778    }
779
780    #[test]
781    fn test_access_log_format_parsing() {
782        assert_eq!(
783            LogManager::parse_access_format("json"),
784            AccessLogFormat::Json
785        );
786        assert_eq!(
787            LogManager::parse_access_format("JSON"),
788            AccessLogFormat::Json
789        );
790        assert_eq!(
791            LogManager::parse_access_format("combined"),
792            AccessLogFormat::Combined
793        );
794        assert_eq!(
795            LogManager::parse_access_format("COMBINED"),
796            AccessLogFormat::Combined
797        );
798        assert_eq!(
799            LogManager::parse_access_format("clf"),
800            AccessLogFormat::Combined
801        );
802        assert_eq!(
803            LogManager::parse_access_format("unknown"),
804            AccessLogFormat::Json
805        ); // Default to JSON
806    }
807}