1#![deny(missing_docs)]
206
207use log::warn;
208use metrics::{counter, describe_counter, describe_histogram, histogram};
209use std::collections::{HashMap, HashSet};
210use std::future::{ready, Future, Ready};
211use std::marker::PhantomData;
212use std::pin::Pin;
213use std::sync::Arc;
214use std::task::{Context, Poll};
215use std::time::Instant;
216
217use actix_web::{
218 body::{BodySize, MessageBody},
219 dev::{self, Service, ServiceRequest, ServiceResponse, Transform},
220 http::{Method, StatusCode, Version},
221 web::Bytes,
222 Error, HttpMessage,
223};
224use futures_core::ready;
225use pin_project_lite::pin_project;
226
227use regex::RegexSet;
228use strfmt::strfmt;
229
230#[derive(Debug, Clone)]
233pub struct ActixWebMetricsExtension {
234 pub cardinality_keep_params: Vec<String>,
236}
237
238#[derive(Debug)]
240pub struct ActixWebMetricsBuilder {
241 namespace: Option<String>,
242 const_labels: HashMap<String, String>,
243 exclude: HashSet<String>,
244 exclude_regex: RegexSet,
245 exclude_status: HashSet<StatusCode>,
246 unmatched_patterns_mask: Option<String>,
247 metrics_config: ActixWebMetricsConfig,
248}
249
250impl ActixWebMetricsBuilder {
251 pub fn new() -> Self {
253 Self {
254 namespace: None,
255 const_labels: HashMap::new(),
256 exclude: HashSet::new(),
257 exclude_regex: RegexSet::empty(),
258 exclude_status: HashSet::new(),
259 unmatched_patterns_mask: Some("UNKNOWN".to_string()),
260 metrics_config: ActixWebMetricsConfig::default(),
261 }
262 }
263
264 pub fn const_labels(mut self, value: HashMap<String, String>) -> Self {
266 self.const_labels = value;
267 self
268 }
269
270 pub fn namespace<T: Into<String>>(mut self, value: T) -> Self {
272 self.namespace = Some(value.into());
273 self
274 }
275
276 pub fn exclude<T: Into<String>>(mut self, path: T) -> Self {
278 self.exclude.insert(path.into());
279 self
280 }
281
282 pub fn exclude_regex<T: Into<String>>(mut self, path: T) -> Self {
284 let mut patterns = self.exclude_regex.patterns().to_vec();
285 patterns.push(path.into());
286 self.exclude_regex = RegexSet::new(patterns).unwrap();
287 self
288 }
289
290 pub fn exclude_status<T: Into<StatusCode>>(mut self, status: T) -> Self {
292 self.exclude_status.insert(status.into());
293 self
294 }
295
296 pub fn mask_unmatched_patterns<T: Into<String>>(mut self, mask: T) -> Self {
300 self.unmatched_patterns_mask = Some(mask.into());
301 self
302 }
303
304 pub fn disable_unmatched_pattern_masking(mut self) -> Self {
308 self.unmatched_patterns_mask = None;
309 self
310 }
311
312 pub fn metrics_config(mut self, value: ActixWebMetricsConfig) -> Self {
314 self.metrics_config = value;
315 self
316 }
317
318 pub fn build(self) -> ActixWebMetrics {
323 let namespace_prefix = if let Some(ns) = self.namespace {
324 format!("{ns}_")
325 } else {
326 "".to_string()
327 };
328
329 let http_requests_duration_seconds_name = format!(
330 "{namespace_prefix}{}",
331 self.metrics_config.http_requests_duration_seconds_name
332 );
333 describe_histogram!(
334 http_requests_duration_seconds_name.clone(),
335 "HTTP request duration in seconds for all requests"
336 );
337 let http_requests_total_name = format!(
338 "{namespace_prefix}{}",
339 self.metrics_config.http_requests_total_name
340 );
341 describe_counter!(
342 http_requests_total_name.clone(),
343 "Total number of HTTP requests"
344 );
345
346 let version: Option<&'static str> = if let Some(ref v) = self.metrics_config.labels.version
347 {
348 Some(Box::leak(Box::new(v.clone())))
349 } else {
350 None
351 };
352
353 let mut const_labels: Vec<(&'static str, String)> = self
354 .const_labels
355 .iter()
356 .map(|(k, v)| {
357 let k: &'static str = Box::leak(Box::new(k.clone()));
358 (k, v.clone())
359 })
360 .collect();
361 const_labels.sort_by_key(|v| v.0);
362
363 ActixWebMetrics {
364 exclude: self.exclude,
365 exclude_regex: self.exclude_regex,
366 exclude_status: self.exclude_status,
367 enable_http_version_label: self.metrics_config.labels.version.is_some(),
368 unmatched_patterns_mask: self.unmatched_patterns_mask,
369 names: MetricsMetadata {
370 http_requests_total: Box::leak(Box::new(http_requests_total_name)),
371 http_requests_duration_seconds: Box::leak(Box::new(
372 http_requests_duration_seconds_name,
373 )),
374 endpoint: Box::leak(Box::new(self.metrics_config.labels.endpoint)),
375 method: Box::leak(Box::new(self.metrics_config.labels.method)),
376 status: Box::leak(Box::new(self.metrics_config.labels.status)),
377 version,
378 const_labels,
379 },
380 }
381 }
382}
383
384impl Default for ActixWebMetricsBuilder {
385 fn default() -> Self {
386 Self::new()
387 }
388}
389
390#[derive(Debug, Clone)]
392pub struct LabelsConfig {
393 endpoint: String,
394 method: String,
395 status: String,
396 version: Option<String>,
397}
398
399impl Default for LabelsConfig {
400 fn default() -> Self {
401 Self {
402 endpoint: String::from("endpoint"),
403 method: String::from("method"),
404 status: String::from("status"),
405 version: None,
406 }
407 }
408}
409
410impl LabelsConfig {
411 pub fn method<T: Into<String>>(mut self, name: T) -> Self {
413 self.method = name.into();
414 self
415 }
416
417 pub fn endpoint<T: Into<String>>(mut self, name: T) -> Self {
419 self.endpoint = name.into();
420 self
421 }
422
423 pub fn status<T: Into<String>>(mut self, name: T) -> Self {
425 self.status = name.into();
426 self
427 }
428
429 pub fn version<T: Into<String>>(mut self, name: T) -> Self {
431 self.version = Some(name.into());
432 self
433 }
434}
435
436#[derive(Debug, Clone)]
440pub struct ActixWebMetricsConfig {
441 http_requests_total_name: String,
442 http_requests_duration_seconds_name: String,
443 labels: LabelsConfig,
444}
445
446impl Default for ActixWebMetricsConfig {
447 fn default() -> Self {
448 Self {
449 http_requests_total_name: String::from("http_requests_total"),
450 http_requests_duration_seconds_name: String::from("http_requests_duration_seconds"),
451 labels: LabelsConfig::default(),
452 }
453 }
454}
455
456impl ActixWebMetricsConfig {
457 pub fn labels(mut self, labels: LabelsConfig) -> Self {
459 self.labels = labels;
460 self
461 }
462
463 pub fn http_requests_total_name<T: Into<String>>(mut self, name: T) -> Self {
465 self.http_requests_total_name = name.into();
466 self
467 }
468
469 pub fn http_requests_duration_seconds_name<T: Into<String>>(mut self, name: T) -> Self {
471 self.http_requests_duration_seconds_name = name.into();
472 self
473 }
474}
475
476#[derive(Debug, Clone)]
479struct MetricsMetadata {
480 http_requests_total: &'static str,
481 http_requests_duration_seconds: &'static str,
482 endpoint: &'static str,
483 method: &'static str,
484 status: &'static str,
485 version: Option<&'static str>,
486 const_labels: Vec<(&'static str, String)>,
487}
488
489#[derive(Clone)]
497#[must_use = "must be set up as middleware for actix-web"]
498pub struct ActixWebMetrics {
499 pub(crate) names: MetricsMetadata,
500
501 pub(crate) exclude: HashSet<String>,
502 pub(crate) exclude_regex: RegexSet,
503 pub(crate) exclude_status: HashSet<StatusCode>,
504 pub(crate) enable_http_version_label: bool,
505 pub(crate) unmatched_patterns_mask: Option<String>,
506}
507
508impl ActixWebMetrics {
509 #[allow(clippy::too_many_arguments)]
510 fn update_metrics(
511 &self,
512 http_version: Version,
513 mixed_pattern: &str,
514 fallback_pattern: &str,
515 method: &Method,
516 status: StatusCode,
517 clock: Instant,
518 was_path_matched: bool,
519 ) {
520 if self.exclude.contains(mixed_pattern)
521 || self.exclude_regex.is_match(mixed_pattern)
522 || self.exclude_status.contains(&status)
523 {
524 return;
525 }
526
527 let final_pattern = if fallback_pattern != mixed_pattern && (status == 404 || status == 405)
529 {
530 fallback_pattern
531 } else {
532 mixed_pattern
533 };
534
535 let final_pattern = if was_path_matched {
536 final_pattern
537 } else if let Some(mask) = &self.unmatched_patterns_mask {
538 mask
539 } else {
540 final_pattern
541 };
542
543 let mut labels = Vec::with_capacity(4 + self.names.const_labels.len());
544 labels.push((self.names.endpoint, final_pattern.to_string()));
545 labels.push((self.names.method, method.as_str().to_string()));
546 labels.push((self.names.status, status.as_str().to_string()));
547
548 if self.enable_http_version_label {
549 labels.push((
550 self.names.version.unwrap(),
551 Self::http_version_label(http_version).to_string(),
552 ));
553 }
554
555 for (k, v) in &self.names.const_labels {
556 labels.push((k, v.clone()));
557 }
558
559 let elapsed = clock.elapsed();
560 let duration =
561 (elapsed.as_secs() as f64) + f64::from(elapsed.subsec_nanos()) / 1_000_000_000_f64;
562 histogram!(self.names.http_requests_duration_seconds, &labels).record(duration);
563
564 counter!(self.names.http_requests_total, &labels).increment(1);
565 }
566
567 fn http_version_label(version: Version) -> &'static str {
568 match version {
569 v if v == Version::HTTP_09 => "HTTP/0.9",
570 v if v == Version::HTTP_10 => "HTTP/1.0",
571 v if v == Version::HTTP_11 => "HTTP/1.1",
572 v if v == Version::HTTP_2 => "HTTP/2.0",
573 v if v == Version::HTTP_3 => "HTTP/3.0",
574 _ => "<unrecognized>",
575 }
576 }
577}
578
579impl<S, B> Transform<S, ServiceRequest> for ActixWebMetrics
580where
581 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
582{
583 type Response = ServiceResponse<StreamLog<B>>;
584 type Error = Error;
585 type InitError = ();
586 type Transform = MetricsMiddleware<S>;
587 type Future = Ready<Result<Self::Transform, Self::InitError>>;
588
589 fn new_transform(&self, service: S) -> Self::Future {
590 ready(Ok(MetricsMiddleware {
591 service,
592 inner: Arc::new(self.clone()),
593 }))
594 }
595}
596
597pin_project! {
598 #[doc(hidden)]
599 pub struct LoggerResponse<S>
600 where
601 S: Service<ServiceRequest>,
602 {
603 #[pin]
604 fut: S::Future,
605 time: Instant,
606 inner: Arc<ActixWebMetrics>,
607 _t: PhantomData<()>,
608 }
609}
610
611impl<S, B> Future for LoggerResponse<S>
612where
613 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
614{
615 type Output = Result<ServiceResponse<StreamLog<B>>, Error>;
616
617 fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
618 let this = self.project();
619
620 let res = match ready!(this.fut.poll(cx)) {
621 Ok(res) => res,
622 Err(e) => return Poll::Ready(Err(e)),
623 };
624
625 let time = *this.time;
626 let req = res.request();
627 let method = req.method().clone();
628 let version = req.version();
629 let was_path_matched = req.match_pattern().is_some();
630
631 let params_keep_path_cardinality =
634 match req.extensions_mut().get::<ActixWebMetricsExtension>() {
635 Some(config) => config.cardinality_keep_params.clone(),
636 None => vec![],
637 };
638
639 let full_pattern = req.match_pattern();
640 let path = req.path().to_string();
641 let fallback_pattern = full_pattern.clone().unwrap_or(path.clone());
642
643 let mixed_pattern = match full_pattern {
645 None => path.clone(),
646 Some(full_pattern) => {
647 let mut params: HashMap<String, String> = HashMap::new();
648
649 for (key, val) in req.match_info().iter() {
650 if params_keep_path_cardinality.contains(&key.to_string()) {
651 params.insert(key.to_string(), val.to_string());
652 continue;
653 }
654 params.insert(key.to_string(), format!("{{{key}}}"));
655 }
656
657 if let Ok(mixed_cardinality_pattern) = strfmt(&full_pattern, ¶ms) {
658 mixed_cardinality_pattern
659 } else {
660 warn!("Cannot build mixed cardinality pattern {full_pattern}, with params {params:?}");
661 full_pattern
662 }
663 }
664 };
665
666 let inner = this.inner.clone();
667 Poll::Ready(Ok(res.map_body(move |head, body| StreamLog {
668 body,
669 size: 0,
670 clock: time,
671 inner,
672 status: head.status,
673 mixed_pattern,
674 fallback_pattern,
675 method,
676 version,
677 was_path_matched,
678 })))
679 }
680}
681
682#[doc(hidden)]
684pub struct MetricsMiddleware<S> {
685 service: S,
686 inner: Arc<ActixWebMetrics>,
687}
688
689impl<S, B> Service<ServiceRequest> for MetricsMiddleware<S>
690where
691 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
692{
693 type Response = ServiceResponse<StreamLog<B>>;
694 type Error = S::Error;
695 type Future = LoggerResponse<S>;
696
697 dev::forward_ready!(service);
698
699 fn call(&self, req: ServiceRequest) -> Self::Future {
700 LoggerResponse {
701 fut: self.service.call(req),
702 time: Instant::now(),
703 inner: self.inner.clone(),
704 _t: PhantomData,
705 }
706 }
707}
708
709pin_project! {
710 #[doc(hidden)]
711 pub struct StreamLog<B> {
712 #[pin]
713 body: B,
714 size: usize,
715 clock: Instant,
716 inner: Arc<ActixWebMetrics>,
717 status: StatusCode,
718 mixed_pattern: String,
720 fallback_pattern: String,
721 method: Method,
722 version: Version,
723 was_path_matched: bool
724 }
725
726
727 impl<B> PinnedDrop for StreamLog<B> {
728 fn drop(this: Pin<&mut Self>) {
729 this.inner
731 .update_metrics(this.version, &this.mixed_pattern, &this.fallback_pattern, &this.method, this.status, this.clock, this.was_path_matched);
732 }
733 }
734}
735
736impl<B: MessageBody> MessageBody for StreamLog<B> {
737 type Error = B::Error;
738
739 fn size(&self) -> BodySize {
740 self.body.size()
741 }
742
743 fn poll_next(
744 self: Pin<&mut Self>,
745 cx: &mut Context<'_>,
746 ) -> Poll<Option<Result<Bytes, Self::Error>>> {
747 let this = self.project();
748 match ready!(this.body.poll_next(cx)) {
749 Some(Ok(chunk)) => {
750 *this.size += chunk.len();
751 Poll::Ready(Some(Ok(chunk)))
752 }
753 Some(Err(err)) => Poll::Ready(Some(Err(err))),
754 None => Poll::Ready(None),
755 }
756 }
757}