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