Skip to main content

caelix_core/
logging.rs

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