1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum AccessLogFormat {
26 Json,
28 Combined,
30}
31
32#[derive(Debug, Serialize)]
34pub struct AccessLogEntry {
35 pub timestamp: String,
37 pub trace_id: String,
39 pub method: String,
41 pub path: String,
43 #[serde(skip_serializing_if = "Option::is_none")]
45 pub query: Option<String>,
46 pub protocol: String,
48 pub status: u16,
50 pub body_bytes: u64,
52 pub duration_ms: u64,
54 pub client_ip: String,
56 #[serde(skip_serializing_if = "Option::is_none")]
58 pub user_agent: Option<String>,
59 #[serde(skip_serializing_if = "Option::is_none")]
61 pub referer: Option<String>,
62 #[serde(skip_serializing_if = "Option::is_none")]
64 pub host: Option<String>,
65 #[serde(skip_serializing_if = "Option::is_none")]
67 pub route_id: Option<String>,
68 #[serde(skip_serializing_if = "Option::is_none")]
70 pub upstream: Option<String>,
71 pub upstream_attempts: u32,
73 pub instance_id: String,
75 #[serde(skip_serializing_if = "Option::is_none")]
77 pub namespace: Option<String>,
78 #[serde(skip_serializing_if = "Option::is_none")]
80 pub service: Option<String>,
81 pub body_bytes_sent: u64,
83 #[serde(skip_serializing_if = "Option::is_none")]
85 pub upstream_addr: Option<String>,
86 pub connection_reused: bool,
88 pub rate_limit_hit: bool,
90 #[serde(skip_serializing_if = "Option::is_none")]
92 pub geo_country: Option<String>,
93}
94
95impl AccessLogEntry {
96 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 fn format_json(&self) -> String {
106 serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
107 }
108
109 fn format_combined(&self) -> String {
112 let clf_timestamp = self.format_clf_timestamp();
114
115 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 let referer = self.referer.as_deref().unwrap_or("-");
124 let user_agent = self.user_agent.as_deref().unwrap_or("-");
125
126 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 fn format_clf_timestamp(&self) -> String {
143 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#[derive(Debug, Serialize)]
154pub struct ErrorLogEntry {
155 pub timestamp: String,
157 pub trace_id: String,
159 pub level: String,
161 pub message: String,
163 #[serde(skip_serializing_if = "Option::is_none")]
165 pub route_id: Option<String>,
166 #[serde(skip_serializing_if = "Option::is_none")]
168 pub upstream: Option<String>,
169 #[serde(skip_serializing_if = "Option::is_none")]
171 pub details: Option<String>,
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
176#[serde(rename_all = "snake_case")]
177pub enum AuditEventType {
178 Blocked,
180 AgentDecision,
182 WafMatch,
184 WafBlock,
186 RateLimitExceeded,
188 AuthEvent,
190 ConfigChange,
192 CertReload,
194 CircuitBreakerChange,
196 CachePurge,
198 AdminAction,
200 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#[derive(Debug, Serialize)]
225pub struct AuditLogEntry {
226 pub timestamp: String,
228 pub trace_id: String,
230 pub event_type: String,
232 pub method: String,
234 pub path: String,
236 pub client_ip: String,
238 #[serde(skip_serializing_if = "Option::is_none")]
240 pub route_id: Option<String>,
241 #[serde(skip_serializing_if = "Option::is_none")]
243 pub reason: Option<String>,
244 #[serde(skip_serializing_if = "Option::is_none")]
246 pub agent_id: Option<String>,
247 #[serde(skip_serializing_if = "Vec::is_empty")]
249 pub rule_ids: Vec<String>,
250 #[serde(skip_serializing_if = "Vec::is_empty")]
252 pub tags: Vec<String>,
253 #[serde(skip_serializing_if = "Option::is_none")]
255 pub action: Option<String>,
256 #[serde(skip_serializing_if = "Option::is_none")]
258 pub status_code: Option<u16>,
259 #[serde(skip_serializing_if = "Option::is_none")]
261 pub user_id: Option<String>,
262 #[serde(skip_serializing_if = "Option::is_none")]
264 pub session_id: Option<String>,
265 #[serde(skip_serializing_if = "std::collections::HashMap::is_empty")]
267 pub metadata: std::collections::HashMap<String, String>,
268 #[serde(skip_serializing_if = "Option::is_none")]
270 pub namespace: Option<String>,
271 #[serde(skip_serializing_if = "Option::is_none")]
273 pub service: Option<String>,
274}
275
276impl AuditLogEntry {
277 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 pub fn with_namespace(mut self, namespace: impl Into<String>) -> Self {
309 self.namespace = Some(namespace.into());
310 self
311 }
312
313 pub fn with_service(mut self, service: impl Into<String>) -> Self {
315 self.service = Some(service.into());
316 self
317 }
318
319 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 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 pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
334 self.reason = Some(reason.into());
335 self
336 }
337
338 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 pub fn with_rule_ids(mut self, rule_ids: Vec<String>) -> Self {
346 self.rule_ids = rule_ids;
347 self
348 }
349
350 pub fn with_tags(mut self, tags: Vec<String>) -> Self {
352 self.tags = tags;
353 self
354 }
355
356 pub fn with_action(mut self, action: impl Into<String>) -> Self {
358 self.action = Some(action.into());
359 self
360 }
361
362 pub fn with_status_code(mut self, status_code: u16) -> Self {
364 self.status_code = Some(status_code);
365 self
366 }
367
368 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 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 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 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 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 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 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 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 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 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
504struct LogFileWriter {
506 writer: BufWriter<File>,
507}
508
509impl LogFileWriter {
510 fn new(path: &Path, buffer_size: usize) -> Result<Self> {
511 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
539pub 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 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 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 fn parse_access_format(format: &str) -> AccessLogFormat {
617 match format.to_lowercase().as_str() {
618 "combined" | "clf" | "common" => AccessLogFormat::Combined,
619 _ => AccessLogFormat::Json, }
621 }
622
623 pub fn log_access(&self, entry: &AccessLogEntry) {
625 if let Some(ref writer) = self.access_log {
626 if let Some(ref config) = self.access_log_config {
628 let should_log = if config.sample_errors_always && entry.status >= 400 {
630 true
632 } else {
633 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; }
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 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 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 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, };
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 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 pub fn access_log_enabled(&self) -> bool {
722 self.access_log.is_some()
723 }
724
725 pub fn error_log_enabled(&self) -> bool {
727 self.error_log.is_some()
728 }
729
730 pub fn audit_log_enabled(&self) -> bool {
732 self.audit_log.is_some()
733 }
734}
735
736pub 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 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 ); }
925}