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)]
47pub enum LogLevel {
49 Info,
51 Log,
53 Warn,
55 Error,
57 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)]
91pub struct Logger {
93 context: String,
94}
95
96impl Logger {
97 pub fn new(context: impl Into<String>) -> Self {
99 Self {
100 context: context.into(),
101 }
102 }
103
104 pub fn for_type<T: ?Sized>() -> Self {
106 Self::new(std::any::type_name::<T>())
107 }
108
109 pub fn context(&self) -> &str {
111 &self.context
112 }
113
114 pub fn info(&self, message: impl AsRef<str>) {
116 self.write(LogLevel::Info, message, None);
117 }
118
119 pub fn warn(&self, message: impl AsRef<str>) {
121 self.write(LogLevel::Warn, message, None);
122 }
123
124 pub fn error(&self, message: impl AsRef<str>) {
126 self.write(LogLevel::Error, message, None);
127 }
128
129 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 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
267pub fn log(context: &str, level: LogLevel, message: impl AsRef<str>, elapsed: Option<Duration>) {
269 Logger::new(context).write(level, message, elapsed);
270}
271
272pub fn log_http_exception(exception: &HttpException) {
274 log_http_exception_message(exception, None);
275}
276
277pub 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#[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
505pub fn log_application_starting() {
507 log("bootstrap", LogLevel::Info, "Building application", None);
508}
509
510pub 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
520pub 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
530pub 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
540pub 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
564pub 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
574pub 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#[doc(hidden)]
603#[allow(clippy::too_many_arguments)]
604pub 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
658pub 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
912pub 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
923pub 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}