Skip to main content

caelix_core/
logging.rs

1use std::{
2    env, fmt,
3    io::{self, BufWriter, Write},
4    panic::{self, PanicHookInfo},
5    process,
6    sync::{
7        Arc, Once, OnceLock,
8        atomic::{AtomicBool, AtomicU64, Ordering},
9    },
10    thread,
11    time::{Duration, Instant, SystemTime, UNIX_EPOCH},
12};
13
14use crossbeam_channel::{Receiver, Sender, TrySendError};
15use tracing::{
16    Event, Level, Metadata, Subscriber,
17    field::{Field, Visit},
18};
19use tracing_subscriber::{
20    fmt::{FmtContext, FormatEvent, FormatFields, MakeWriter, format::Writer},
21    registry::LookupSpan,
22};
23
24use crate::{Controller, HttpException, Module, RouteDef};
25
26const RESET: &str = "\x1b[0m";
27const BRAND: &str = "\x1b[38;5;45m";
28const PID: &str = "\x1b[38;5;141m";
29const TIME: &str = "\x1b[38;5;245m";
30const CONTEXT: &str = "\x1b[38;5;111m";
31const INFO: &str = "\x1b[38;5;39m";
32const WARN: &str = "\x1b[38;5;214m";
33const ERROR: &str = "\x1b[38;5;203m";
34const DEBUG: &str = "\x1b[38;5;177m";
35const OK_STATUS: &str = "\x1b[38;5;82m";
36const ACCESS_LOG_QUEUE_CAPACITY: usize = 65_536;
37const ACCESS_LOG_BATCH_SIZE: usize = 1_024;
38const ACCESS_LOG_FLUSH_INTERVAL: Duration = Duration::from_millis(100);
39const ACCESS_LOG_DROP_REPORT_INTERVAL: Duration = Duration::from_secs(1);
40
41static TRACING_INIT: Once = Once::new();
42static PANIC_HOOK_INIT: Once = Once::new();
43static LOG_PRIORITY: OnceLock<u8> = OnceLock::new();
44static HTTP_REQUEST_LOGGING: OnceLock<bool> = OnceLock::new();
45static CAELIX_TRACING_SUBSCRIBER: AtomicBool = AtomicBool::new(false);
46static ACCESS_LOG_WRITER: OnceLock<AccessLogWriter> = OnceLock::new();
47
48#[derive(Clone, Copy, Debug, Eq, PartialEq)]
49/// Public Caelix enumeration `LogLevel`.
50pub enum LogLevel {
51    /// Public Caelix API.
52    Info,
53    /// Public Caelix API.
54    Log,
55    /// Public Caelix API.
56    Warn,
57    /// Public Caelix API.
58    Error,
59    /// Public Caelix API.
60    Debug,
61}
62
63impl LogLevel {
64    fn label(self) -> &'static str {
65        match self {
66            Self::Info | Self::Log => "INFO",
67            Self::Warn => "WARN",
68            Self::Error => "ERROR",
69            Self::Debug => "DEBUG",
70        }
71    }
72
73    fn color(self) -> &'static str {
74        match self {
75            Self::Info | Self::Log => INFO,
76            Self::Warn => WARN,
77            Self::Error => ERROR,
78            Self::Debug => DEBUG,
79        }
80    }
81
82    fn priority(self) -> u8 {
83        match self {
84            Self::Error => 1,
85            Self::Warn => 2,
86            Self::Info | Self::Log => 3,
87            Self::Debug => 4,
88        }
89    }
90}
91
92#[derive(Clone, Debug)]
93/// Public Caelix type `Logger`.
94pub struct Logger {
95    context: String,
96}
97
98impl Logger {
99    /// Runs the `new` public API operation.
100    pub fn new(context: impl Into<String>) -> Self {
101        Self {
102            context: short_type_name(&context.into()).to_string(),
103        }
104    }
105
106    /// Runs the `for_type` public API operation.
107    pub fn for_type<T: ?Sized>() -> Self {
108        Self::new(std::any::type_name::<T>())
109    }
110
111    /// Runs the `context` public API operation.
112    pub fn context(&self) -> &str {
113        &self.context
114    }
115
116    /// Runs the `info` public API operation.
117    pub fn info(&self, message: impl AsRef<str>) {
118        self.write(LogLevel::Info, message, None);
119    }
120
121    /// Runs the `warn` public API operation.
122    pub fn warn(&self, message: impl AsRef<str>) {
123        self.write(LogLevel::Warn, message, None);
124    }
125
126    /// Runs the `error` public API operation.
127    pub fn error(&self, message: impl AsRef<str>) {
128        self.write(LogLevel::Error, message, None);
129    }
130
131    /// Runs the `debug` public API operation.
132    pub fn debug(&self, message: impl AsRef<str>) {
133        self.write(LogLevel::Debug, message, None);
134    }
135
136    pub(crate) fn write(
137        &self,
138        level: LogLevel,
139        message: impl AsRef<str>,
140        elapsed: Option<Duration>,
141    ) {
142        self.write_inner(level, message, elapsed, false);
143    }
144
145    fn write_forced(&self, level: LogLevel, message: impl AsRef<str>) {
146        self.write_inner(level, message, None, true);
147    }
148
149    fn write_inner(
150        &self,
151        level: LogLevel,
152        message: impl AsRef<str>,
153        elapsed: Option<Duration>,
154        force: bool,
155    ) {
156        if !force && !log_level_enabled(level) {
157            return;
158        }
159
160        init_logging();
161
162        let line = format_log_line(&self.context, level, message.as_ref(), elapsed);
163
164        match level {
165            LogLevel::Info | LogLevel::Log => {
166                tracing::info!(target: "caelix", message = line.as_str())
167            }
168            LogLevel::Warn => tracing::warn!(target: "caelix", message = line.as_str()),
169            LogLevel::Error => tracing::error!(target: "caelix", message = line.as_str()),
170            LogLevel::Debug => tracing::debug!(target: "caelix", message = line.as_str()),
171        }
172    }
173}
174
175fn format_log_line(
176    context: &str,
177    level: LogLevel,
178    message: &str,
179    elapsed: Option<Duration>,
180) -> String {
181    let pid = process::id();
182    let timestamp = current_timestamp();
183    let level_label = format!("{:>5}", level.label());
184    let elapsed = elapsed
185        .map(|duration| format!(" {}", format_elapsed(duration)))
186        .unwrap_or_default();
187    let message = match level {
188        LogLevel::Error => format!("{}{}{}", ERROR, message, RESET),
189        _ => message.to_string(),
190    };
191
192    format!(
193        "{}[Caelix]{} {}{}{}  - {}{}{} {}{}{} {}[{}]{} {}{}",
194        BRAND,
195        RESET,
196        PID,
197        pid,
198        RESET,
199        TIME,
200        timestamp,
201        RESET,
202        level.color(),
203        level_label,
204        RESET,
205        CONTEXT,
206        context,
207        RESET,
208        message,
209        elapsed
210    )
211}
212
213fn format_elapsed(duration: Duration) -> String {
214    let milliseconds = duration.as_millis();
215
216    if milliseconds > 0 {
217        format!("+{milliseconds}ms")
218    } else {
219        format!("+{}µs", duration.as_micros())
220    }
221}
222
223fn current_timestamp() -> String {
224    let duration = SystemTime::now()
225        .duration_since(UNIX_EPOCH)
226        .unwrap_or(Duration::ZERO);
227    let total_seconds = duration.as_secs();
228    let days = (total_seconds / 86_400) as i64;
229    let seconds_of_day = total_seconds % 86_400;
230    let (year, month, day) = civil_from_days(days);
231    let hour_24 = seconds_of_day / 3_600;
232    let minute = (seconds_of_day % 3_600) / 60;
233    let second = seconds_of_day % 60;
234    let period = if hour_24 < 12 { "AM" } else { "PM" };
235    let hour_12 = match hour_24 % 12 {
236        0 => 12,
237        hour => hour,
238    };
239
240    format!("{month:02}/{day:02}/{year:04}, {hour_12:02}:{minute:02}:{second:02} {period}")
241}
242
243fn civil_from_days(days_since_epoch: i64) -> (i64, u32, u32) {
244    let z = days_since_epoch + 719_468;
245    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
246    let doe = z - era * 146_097;
247    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
248    let year = yoe + era * 400;
249    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
250    let mp = (5 * doy + 2) / 153;
251    let day = doy - (153 * mp + 2) / 5 + 1;
252    let month = mp + if mp < 10 { 3 } else { -9 };
253    let year = year + if month <= 2 { 1 } else { 0 };
254
255    (year, month as u32, day as u32)
256}
257
258impl crate::Injectable for Logger {
259    fn dependencies() -> Vec<crate::ProviderDependency> {
260        crate::provider_dependencies![]
261    }
262
263    fn create(_container: &crate::Container) -> crate::BoxFuture<'_, crate::Result<Self>> {
264        Box::pin(async move { Ok(Self::new("Application")) })
265    }
266}
267
268/// Runs the `log` public API operation.
269pub fn log(context: &str, level: LogLevel, message: impl AsRef<str>, elapsed: Option<Duration>) {
270    Logger::new(context).write(level, message, elapsed);
271}
272
273/// Runs the `log_http_exception` public API operation.
274pub fn log_http_exception(exception: &HttpException) {
275    if !exception.status.is_server_error() {
276        return;
277    }
278
279    let message = match &exception.source {
280        Some(source) => format!(
281            "{} {}: {} | source: {source:#}",
282            exception.status.as_u16(),
283            exception.error,
284            exception.message
285        ),
286        None => format!(
287            "{} {}: {}",
288            exception.status.as_u16(),
289            exception.error,
290            exception.message
291        ),
292    };
293
294    Logger::new("ExceptionHandler").error(message);
295}
296
297pub(crate) fn init_logging() {
298    install_panic_hook();
299    init_tracing();
300}
301
302fn init_tracing() {
303    TRACING_INIT.call_once(|| {
304        let subscriber = tracing_subscriber::fmt()
305            .without_time()
306            .with_level(false)
307            .with_target(false)
308            .with_ansi(false)
309            .with_writer(CaelixMakeWriter)
310            .event_format(CaelixFormatter)
311            .finish();
312
313        if tracing::subscriber::set_global_default(subscriber).is_ok() {
314            CAELIX_TRACING_SUBSCRIBER.store(true, Ordering::Release);
315        }
316    });
317}
318
319fn install_panic_hook() {
320    PANIC_HOOK_INIT.call_once(|| {
321        panic::set_hook(Box::new(|info| {
322            Logger::new("ExceptionHandler").write_forced(LogLevel::Error, panic_message(info));
323        }));
324    });
325}
326
327fn panic_message(info: &PanicHookInfo<'_>) -> String {
328    let payload = if let Some(message) = info.payload().downcast_ref::<&str>() {
329        *message
330    } else if let Some(message) = info.payload().downcast_ref::<String>() {
331        message.as_str()
332    } else {
333        "panic occurred"
334    };
335
336    if let Some(location) = info.location() {
337        format!(
338            "panic: {} (at {}:{}:{})",
339            payload,
340            location.file(),
341            location.line(),
342            location.column()
343        )
344    } else {
345        format!("panic: {}", payload)
346    }
347}
348
349struct CaelixFormatter;
350
351impl<S, N> FormatEvent<S, N> for CaelixFormatter
352where
353    S: Subscriber + for<'span> LookupSpan<'span>,
354    N: for<'writer> FormatFields<'writer> + 'static,
355{
356    fn format_event(
357        &self,
358        _ctx: &FmtContext<'_, S, N>,
359        mut writer: Writer<'_>,
360        event: &Event<'_>,
361    ) -> fmt::Result {
362        if event.metadata().target() != "caelix" {
363            return Ok(());
364        }
365
366        let mut visitor = MessageVisitor::default();
367        event.record(&mut visitor);
368
369        writeln!(writer, "{}", visitor.message)
370    }
371}
372
373struct CaelixMakeWriter;
374
375impl<'a> MakeWriter<'a> for CaelixMakeWriter {
376    type Writer = CaelixStreamWriter;
377
378    fn make_writer(&'a self) -> Self::Writer {
379        CaelixStreamWriter::Stdout(io::stdout())
380    }
381
382    fn make_writer_for(&'a self, meta: &Metadata<'_>) -> Self::Writer {
383        if *meta.level() == Level::ERROR {
384            CaelixStreamWriter::Stderr(io::stderr())
385        } else {
386            CaelixStreamWriter::Stdout(io::stdout())
387        }
388    }
389}
390
391enum CaelixStreamWriter {
392    Stdout(io::Stdout),
393    Stderr(io::Stderr),
394}
395
396impl Write for CaelixStreamWriter {
397    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
398        match self {
399            Self::Stdout(writer) => writer.write(buf),
400            Self::Stderr(writer) => writer.write(buf),
401        }
402    }
403
404    fn flush(&mut self) -> io::Result<()> {
405        match self {
406            Self::Stdout(writer) => writer.flush(),
407            Self::Stderr(writer) => writer.flush(),
408        }
409    }
410}
411
412#[derive(Default)]
413struct MessageVisitor {
414    message: String,
415}
416
417impl Visit for MessageVisitor {
418    fn record_str(&mut self, field: &Field, value: &str) {
419        if field.name() == "message" {
420            self.message = value.to_string();
421        }
422    }
423
424    fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
425        if field.name() == "message" && self.message.is_empty() {
426            self.message = format!("{value:?}");
427        }
428    }
429}
430
431/// Runs the `log_application_starting` public API operation.
432pub fn log_application_starting() {
433    log(
434        "Application",
435        LogLevel::Info,
436        "Starting Caelix application...",
437        None,
438    );
439}
440
441/// Runs the `log_application_started` public API operation.
442pub fn log_application_started(elapsed: Duration) {
443    log(
444        "Application",
445        LogLevel::Info,
446        "Caelix application successfully started",
447        Some(elapsed),
448    );
449}
450
451/// Runs the `log_listening` public API operation.
452pub fn log_listening(addr: &str) {
453    log(
454        "Application",
455        LogLevel::Info,
456        format!("Caelix application listening on {}", addr),
457        None,
458    );
459}
460
461/// Runs the `log_module_initialized` public API operation.
462pub fn log_module_initialized(module: &str, elapsed: Duration) {
463    log(
464        "InstanceLoader",
465        LogLevel::Info,
466        format!("{} dependencies initialized", short_type_name(module)),
467        Some(elapsed),
468    );
469}
470
471/// Runs the `log_provider_initialized` public API operation.
472pub fn log_provider_initialized(provider: &str, elapsed: Duration) {
473    log(
474        "ProviderLoader",
475        LogLevel::Info,
476        format!("{} initialized", short_type_name(provider)),
477        Some(elapsed),
478    );
479}
480
481/// Runs the `log_controller_routes` public API operation.
482pub fn log_controller_routes<C: Controller>() {
483    let routes = C::routes();
484
485    if routes.is_empty() {
486        return;
487    }
488
489    log(
490        "RoutesResolver",
491        LogLevel::Info,
492        format!(
493            "{} {{{}}}:",
494            short_type_name(std::any::type_name::<C>()),
495            C::base_path()
496        ),
497        Some(Duration::ZERO),
498    );
499
500    for route in routes {
501        log_route_mapped(route);
502    }
503}
504
505/// Runs the `log_route_mapped` public API operation.
506pub fn log_route_mapped(route: &RouteDef) {
507    log(
508        "RouterExplorer",
509        LogLevel::Info,
510        format!(
511            "Mapped {{{}, {}}} route",
512            route.path,
513            route.method.to_uppercase()
514        ),
515        Some(Duration::ZERO),
516    );
517}
518
519/// Runs the `log_http_request` public API operation.
520pub fn log_http_request(method: &str, path: &str, status: u16, elapsed: Duration) {
521    let level = match status {
522        500..=599 => LogLevel::Error,
523        _ => LogLevel::Info,
524    };
525    if !log_level_enabled(level) {
526        return;
527    }
528
529    init_logging();
530
531    if CAELIX_TRACING_SUBSCRIBER.load(Ordering::Acquire) {
532        access_log_writer().write(method, path, status, elapsed);
533    } else {
534        let status = color_status(status);
535        log(
536            "HTTP",
537            level,
538            format!("{} {} {}", method, path, status),
539            Some(elapsed),
540        );
541    }
542}
543
544/// Records an Actix-compatible detailed HTTP access log entry.
545///
546/// This is used by the Actix runtime for `Logging::info()`.
547#[doc(hidden)]
548#[allow(clippy::too_many_arguments)]
549/// Runs the `log_http_request_info` public API operation.
550pub fn log_http_request_info(
551    client_address: &str,
552    method: &str,
553    path_and_query: &str,
554    protocol: &str,
555    status: u16,
556    response_size: Option<u64>,
557    referrer: &str,
558    user_agent: &str,
559    elapsed: Duration,
560) {
561    let level = match status {
562        500..=599 => LogLevel::Error,
563        _ => LogLevel::Info,
564    };
565    if !log_level_enabled(level) {
566        return;
567    }
568
569    init_logging();
570
571    if CAELIX_TRACING_SUBSCRIBER.load(Ordering::Acquire) {
572        access_log_writer().write_info(HttpAccessLogInfo::new(
573            client_address,
574            method,
575            path_and_query,
576            protocol,
577            status,
578            response_size,
579            referrer,
580            user_agent,
581            elapsed,
582        ));
583    } else {
584        log(
585            "HTTP",
586            level,
587            format_http_request_info(
588                client_address,
589                method,
590                path_and_query,
591                protocol,
592                status,
593                response_size,
594                referrer,
595                user_agent,
596                elapsed,
597            ),
598            None,
599        );
600    }
601}
602
603/// Returns the number of enabled HTTP access-log entries dropped because the
604/// asynchronous writer queue was full.
605pub fn dropped_http_request_logs() -> u64 {
606    ACCESS_LOG_WRITER
607        .get()
608        .map(|writer| writer.dropped.load(Ordering::Relaxed))
609        .unwrap_or(0)
610}
611
612struct AccessLogWriter {
613    sender: Sender<AccessLogEvent>,
614    dropped: Arc<AtomicU64>,
615    pending_drop_report: Arc<AtomicU64>,
616}
617
618enum AccessLogEvent {
619    Compact {
620        request: String,
621        status: u16,
622        elapsed: Duration,
623    },
624    Info(HttpAccessLogInfo),
625}
626
627struct HttpAccessLogInfo {
628    client_address: String,
629    method: String,
630    path_and_query: String,
631    protocol: String,
632    status: u16,
633    response_size: Option<u64>,
634    referrer: String,
635    user_agent: String,
636    elapsed: Duration,
637}
638
639impl HttpAccessLogInfo {
640    #[allow(clippy::too_many_arguments)]
641    fn new(
642        client_address: &str,
643        method: &str,
644        path_and_query: &str,
645        protocol: &str,
646        status: u16,
647        response_size: Option<u64>,
648        referrer: &str,
649        user_agent: &str,
650        elapsed: Duration,
651    ) -> Self {
652        Self {
653            client_address: client_address.to_string(),
654            method: method.to_string(),
655            path_and_query: path_and_query.to_string(),
656            protocol: protocol.to_string(),
657            status,
658            response_size,
659            referrer: referrer.to_string(),
660            user_agent: user_agent.to_string(),
661            elapsed,
662        }
663    }
664}
665
666impl AccessLogWriter {
667    fn new() -> Self {
668        let (sender, receiver) = crossbeam_channel::bounded(ACCESS_LOG_QUEUE_CAPACITY);
669        let dropped = Arc::new(AtomicU64::new(0));
670        let pending_drop_report = Arc::new(AtomicU64::new(0));
671        let worker_drop_report = Arc::clone(&pending_drop_report);
672
673        thread::Builder::new()
674            .name("caelix-access-log".to_string())
675            .spawn(move || write_access_logs(receiver, worker_drop_report))
676            .expect("failed to start the Caelix access-log writer");
677
678        Self {
679            sender,
680            dropped,
681            pending_drop_report,
682        }
683    }
684
685    fn write(&self, method: &str, path: &str, status: u16, elapsed: Duration) {
686        if self.sender.is_full() {
687            self.record_drop();
688            return;
689        }
690
691        let mut request = String::with_capacity(method.len() + path.len() + 1);
692        request.push_str(method);
693        request.push(' ');
694        request.push_str(path);
695
696        let event = AccessLogEvent::Compact {
697            request,
698            status,
699            elapsed,
700        };
701
702        if matches!(self.sender.try_send(event), Err(TrySendError::Full(_))) {
703            self.record_drop();
704        }
705    }
706
707    fn write_info(&self, event: HttpAccessLogInfo) {
708        if self.sender.is_full() {
709            self.record_drop();
710            return;
711        }
712
713        if matches!(
714            self.sender.try_send(AccessLogEvent::Info(event)),
715            Err(TrySendError::Full(_))
716        ) {
717            self.record_drop();
718        }
719    }
720
721    fn record_drop(&self) {
722        self.dropped.fetch_add(1, Ordering::Relaxed);
723        self.pending_drop_report.fetch_add(1, Ordering::Relaxed);
724    }
725}
726
727fn access_log_writer() -> &'static AccessLogWriter {
728    ACCESS_LOG_WRITER.get_or_init(AccessLogWriter::new)
729}
730
731fn write_access_logs(receiver: Receiver<AccessLogEvent>, dropped: Arc<AtomicU64>) {
732    let stdout = io::stdout();
733    let stderr = io::stderr();
734    let mut stdout = BufWriter::with_capacity(256 * 1024, stdout.lock());
735    let mut stderr = BufWriter::with_capacity(16 * 1024, stderr.lock());
736    let mut last_drop_report = Instant::now();
737
738    loop {
739        match receiver.recv_timeout(ACCESS_LOG_FLUSH_INTERVAL) {
740            Ok(event) => {
741                write_access_log(&mut stdout, &mut stderr, event);
742
743                for event in receiver.try_iter().take(ACCESS_LOG_BATCH_SIZE - 1) {
744                    write_access_log(&mut stdout, &mut stderr, event);
745                }
746            }
747            Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
748            Err(crossbeam_channel::RecvTimeoutError::Disconnected) => break,
749        }
750
751        if last_drop_report.elapsed() >= ACCESS_LOG_DROP_REPORT_INTERVAL {
752            report_dropped_access_logs(&mut stderr, &dropped);
753            last_drop_report = Instant::now();
754        }
755
756        let _ = stdout.flush();
757        let _ = stderr.flush();
758    }
759
760    report_dropped_access_logs(&mut stderr, &dropped);
761    let _ = stdout.flush();
762    let _ = stderr.flush();
763}
764
765fn write_access_log(
766    stdout: &mut BufWriter<io::StdoutLock<'_>>,
767    stderr: &mut BufWriter<io::StderrLock<'_>>,
768    event: AccessLogEvent,
769) {
770    let (level, message, elapsed) = match event {
771        AccessLogEvent::Compact {
772            request,
773            status,
774            elapsed,
775        } => {
776            let level = log_level_for_status(status);
777            (
778                level,
779                format!("{} {}", request, color_status(status)),
780                Some(elapsed),
781            )
782        }
783        AccessLogEvent::Info(info) => {
784            let level = log_level_for_status(info.status);
785            (
786                level,
787                format_http_request_info(
788                    &info.client_address,
789                    &info.method,
790                    &info.path_and_query,
791                    &info.protocol,
792                    info.status,
793                    info.response_size,
794                    &info.referrer,
795                    &info.user_agent,
796                    info.elapsed,
797                ),
798                None,
799            )
800        }
801    };
802    let line = format_log_line("HTTP", level, &message, elapsed);
803
804    if level == LogLevel::Error {
805        let _ = writeln!(stderr, "{line}");
806    } else {
807        let _ = writeln!(stdout, "{line}");
808    }
809}
810
811fn log_level_for_status(status: u16) -> LogLevel {
812    match status {
813        500..=599 => LogLevel::Error,
814        _ => LogLevel::Info,
815    }
816}
817
818#[allow(clippy::too_many_arguments)]
819fn format_http_request_info(
820    client_address: &str,
821    method: &str,
822    path_and_query: &str,
823    protocol: &str,
824    status: u16,
825    response_size: Option<u64>,
826    referrer: &str,
827    user_agent: &str,
828    elapsed: Duration,
829) -> String {
830    let request = if path_and_query.is_empty() {
831        method.to_string()
832    } else {
833        format!("{method} {path_and_query}")
834    };
835    let response_size = response_size
836        .map(|size| size.to_string())
837        .unwrap_or_else(|| "-".to_string());
838
839    format!(
840        "{client_address} \"{request} {protocol}\" {} {response_size} \"{referrer}\" \"{user_agent}\" {:.6}",
841        color_status(status),
842        elapsed.as_secs_f64(),
843    )
844}
845
846fn report_dropped_access_logs(stderr: &mut BufWriter<io::StderrLock<'_>>, dropped: &AtomicU64) {
847    let dropped = dropped.swap(0, Ordering::Relaxed);
848
849    if dropped > 0 {
850        let _ = writeln!(
851            stderr,
852            "[Caelix] access-log writer queue was full; dropped {dropped} entries"
853        );
854    }
855}
856
857/// Runs the `http_request_logging_enabled` public API operation.
858pub fn http_request_logging_enabled() -> bool {
859    *HTTP_REQUEST_LOGGING.get_or_init(|| {
860        env::var("CAELIX_HTTP_LOG")
861            .or_else(|_| env::var("CAELIX_ACCESS_LOG"))
862            .ok()
863            .and_then(|value| parse_bool(&value))
864            .unwrap_or(false)
865    })
866}
867
868/// Runs the `log_module_routes` public API operation.
869pub fn log_module_routes<M: Module>() {
870    let metadata = M::register();
871
872    for import in &metadata.imports {
873        (import.route_log_fn)();
874    }
875
876    for controller in &metadata.controllers {
877        (controller.route_log_fn)();
878    }
879}
880
881fn short_type_name(name: &str) -> &str {
882    name.rsplit("::").next().unwrap_or(name)
883}
884
885fn color_status(status: u16) -> String {
886    let color = match status {
887        500..=599 => ERROR,
888        400..=499 => WARN,
889        _ => OK_STATUS,
890    };
891
892    format!("{}{}{}", color, status, RESET)
893}
894
895fn log_level_enabled(level: LogLevel) -> bool {
896    level.priority() <= configured_log_priority()
897}
898
899fn configured_log_priority() -> u8 {
900    *LOG_PRIORITY.get_or_init(|| {
901        env::var("CAELIX_LOG")
902            .ok()
903            .and_then(|value| parse_log_level(&value))
904            .or_else(|| {
905                env::var("RUST_LOG")
906                    .ok()
907                    .and_then(|value| parse_rust_log_level(&value))
908            })
909            .unwrap_or(LogLevel::Info.priority())
910    })
911}
912
913fn parse_rust_log_level(value: &str) -> Option<u8> {
914    value.split(',').find_map(|directive| {
915        let directive = directive.trim();
916
917        if let Some((target, level)) = directive.split_once('=') {
918            let target = target.trim();
919            if matches!(target, "caelix" | "caelix_core" | "caelix-core") {
920                return parse_log_level(level);
921            }
922
923            return None;
924        }
925
926        parse_log_level(directive)
927    })
928}
929
930fn parse_log_level(value: &str) -> Option<u8> {
931    match value.trim().to_ascii_lowercase().as_str() {
932        "debug" | "trace" => Some(LogLevel::Debug.priority()),
933        "info" | "log" => Some(LogLevel::Info.priority()),
934        "warn" | "warning" => Some(LogLevel::Warn.priority()),
935        "error" => Some(LogLevel::Error.priority()),
936        "off" => Some(0),
937        _ => None,
938    }
939}
940
941fn parse_bool(value: &str) -> Option<bool> {
942    match value.trim().to_ascii_lowercase().as_str() {
943        "1" | "true" | "yes" | "y" | "on" => Some(true),
944        "0" | "false" | "no" | "n" | "off" => Some(false),
945        _ => None,
946    }
947}
948
949#[cfg(test)]
950mod tests {
951    use super::*;
952
953    #[test]
954    fn elapsed_format_preserves_sub_millisecond_detail() {
955        assert_eq!(format_elapsed(Duration::ZERO), "+0µs");
956        assert_eq!(format_elapsed(Duration::from_micros(742)), "+742µs");
957        assert_eq!(format_elapsed(Duration::from_micros(1_200)), "+1ms");
958        assert_eq!(format_elapsed(Duration::from_millis(42)), "+42ms");
959    }
960
961    #[test]
962    fn parse_bool_accepts_common_env_values() {
963        assert_eq!(parse_bool("1"), Some(true));
964        assert_eq!(parse_bool("on"), Some(true));
965        assert_eq!(parse_bool("false"), Some(false));
966        assert_eq!(parse_bool("off"), Some(false));
967        assert_eq!(parse_bool("sometimes"), None);
968    }
969
970    #[test]
971    fn detailed_http_log_includes_actix_default_fields() {
972        let line = format_http_request_info(
973            "127.0.0.1:43120",
974            "GET",
975            "/hello?name=Caelix",
976            "HTTP/1.1",
977            200,
978            Some(42),
979            "https://example.com",
980            "CaelixTest/1.0",
981            Duration::from_micros(1_250),
982        );
983
984        assert!(line.starts_with("127.0.0.1:43120 \"GET /hello?name=Caelix HTTP/1.1\""));
985        assert!(line.contains(" 42 \"https://example.com\" \"CaelixTest/1.0\" 0.001250"));
986    }
987}