Source code for litestar.plugins.prometheus.middleware

from __future__ import annotations

import time
from functools import wraps
from typing import TYPE_CHECKING, Any, ClassVar, cast

from litestar.connection.request import Request
from litestar.enums import ScopeType
from litestar.exceptions import HTTPException, MissingDependencyException
from litestar.middleware.base import ASGIMiddleware

__all__ = ("PrometheusMiddleware",)

from litestar.status_codes import HTTP_500_INTERNAL_SERVER_ERROR

try:
    import prometheus_client  # noqa: F401
except ImportError as e:
    raise MissingDependencyException("prometheus_client", "prometheus-client", "prometheus") from e

from prometheus_client import Counter, Gauge, Histogram

if TYPE_CHECKING:
    from collections.abc import Callable, Mapping, Sequence

    from prometheus_client.metrics import MetricWrapperBase

    from litestar.types import ASGIApp, Message, Method, Receive, Scope, Scopes, Send


[docs] class PrometheusMiddleware(ASGIMiddleware): """Prometheus Middleware.""" _metrics: ClassVar[dict[str, MetricWrapperBase]] = {}
[docs] def __init__( self, *, app_name: str = "litestar", prefix: str = "litestar", labels: Mapping[str, str | Callable] | None = None, exemplars: Callable[[Request], dict] | None = None, buckets: Sequence[str | float] | None = None, excluded_http_methods: Method | Sequence[Method] | None = None, exclude: str | list[str] | None = None, exclude_opt_key: str | None = None, scopes: Scopes | None = None, group_path: bool = True, ) -> None: """Middleware that adds Prometheus instrumentation to the application. Args: app_name: The name of the application to use in the metrics. prefix: The prefix to use for the metrics. labels: A mapping of labels to add to the metrics. The values can be either a string or a callable that returns a string. exemplars: A callable that returns a list of exemplars to add to the metrics. Only supported in openmetrics-text exposition format. buckets: A list of buckets to use for the histogram. excluded_http_methods: A list of http methods to exclude from the metrics. exclude: A pattern or list of patterns for routes to exclude from the metrics, matched against the handler path. exclude_opt_key: A key in ``opt`` with which a route handler can "opt-out" of the middleware. scopes: ASGI scopes processed by the middleware; if ``None`` or empty, ``http``, ``websocket`` and ASGI route handlers are all processed. Mounted ASGI apps stay wrapped regardless, with their connections filtered by scope type. group_path: Whether to group paths in the metrics to avoid cardinality explosion. """ self.app_name = app_name self.prefix = prefix self.labels = labels self.exemplars = exemplars self.excluded_http_methods = excluded_http_methods self.exclude_path_pattern = tuple(exclude) if isinstance(exclude, list) else exclude self.exclude_opt_key = exclude_opt_key self.group_path = group_path if scopes: scope_types = frozenset(scopes) self.scopes = (*(s for s in (ScopeType.HTTP, ScopeType.WEBSOCKET) if s in scope_types), ScopeType.ASGI) self.should_bypass_for_scope = lambda scope: scope["type"] not in scope_types self._kwargs: dict[str, Any] = {} if buckets is not None: self._kwargs["buckets"] = buckets
def request_count(self, labels: dict[str, str | int | float]) -> Counter: metric_name = f"{self.prefix}_requests_total" if metric_name not in PrometheusMiddleware._metrics: PrometheusMiddleware._metrics[metric_name] = Counter( name=metric_name, documentation="Total requests", labelnames=[*labels.keys()], ) return cast("Counter", PrometheusMiddleware._metrics[metric_name]) def request_time(self, labels: dict[str, str | int | float]) -> Histogram: metric_name = f"{self.prefix}_request_duration_seconds" if metric_name not in PrometheusMiddleware._metrics: PrometheusMiddleware._metrics[metric_name] = Histogram( name=metric_name, documentation="Request duration, in seconds", labelnames=[*labels.keys()], **self._kwargs, ) return cast("Histogram", PrometheusMiddleware._metrics[metric_name]) def requests_in_progress(self, labels: dict[str, str | int | float]) -> Gauge: metric_name = f"{self.prefix}_requests_in_progress" if metric_name not in PrometheusMiddleware._metrics: PrometheusMiddleware._metrics[metric_name] = Gauge( name=metric_name, documentation="Total requests currently in progress", labelnames=[*labels.keys()], multiprocess_mode="livesum", ) return cast("Gauge", PrometheusMiddleware._metrics[metric_name]) def requests_error_count(self, labels: dict[str, str | int | float]) -> Counter: metric_name = f"{self.prefix}_requests_error_total" if metric_name not in PrometheusMiddleware._metrics: PrometheusMiddleware._metrics[metric_name] = Counter( name=metric_name, documentation="Total errors in requests", labelnames=[*labels.keys()], ) return cast("Counter", PrometheusMiddleware._metrics[metric_name]) def _get_extra_labels(self, request: Request[Any, Any, Any]) -> dict[str, str]: """Get extra labels provided by the config and if they are callable, parse them. Args: request: The request object. Returns: A dictionary of extra labels. """ return {k: str(v(request) if callable(v) else v) for k, v in (self.labels or {}).items()} def _get_default_labels(self, request: Request[Any, Any, Any]) -> dict[str, str | int | float]: """Get default label values from the request. Args: request: The request object. Returns: A dictionary of default labels. """ path = request.url.path if self.group_path: path = request.scope["path_template"] return { "method": request.method if request.scope["type"] == ScopeType.HTTP else request.scope["type"], "path": path, "status_code": 200, "app_name": self.app_name, }
[docs] async def handle(self, scope: Scope, receive: Receive, send: Send, next_app: ASGIApp) -> None: """Handle ASGI call. Args: scope: The ASGI connection scope. receive: The ASGI receive function. send: The ASGI send function. next_app: The next ASGI application in the middleware stack to call. Returns: None """ request = Request[Any, Any, Any](scope, receive) if self.excluded_http_methods and request.method in self.excluded_http_methods: await next_app(scope, receive, send) return labels = {**self._get_default_labels(request), **self._get_extra_labels(request)} request_span = {"start_time": time.perf_counter(), "end_time": 0, "duration": 0, "status_code": 200} wrapped_send = self._get_wrapped_send(send, request_span) self.requests_in_progress(labels).labels(*labels.values()).inc() try: try: await next_app(scope, receive, wrapped_send) except HTTPException as exc: request_span["status_code"] = exc.status_code raise except Exception: request_span["status_code"] = HTTP_500_INTERNAL_SERVER_ERROR raise finally: extra: dict[str, Any] = {} if self.exemplars: extra["exemplar"] = self.exemplars(request) self.requests_in_progress(labels).labels(*labels.values()).dec() labels["status_code"] = request_span["status_code"] label_values = [*labels.values()] if request_span["status_code"] >= HTTP_500_INTERNAL_SERVER_ERROR: self.requests_error_count(labels).labels(*label_values).inc(**extra) self.request_count(labels).labels(*label_values).inc(**extra) self.request_time(labels).labels(*label_values).observe(request_span["duration"], **extra)
def _get_wrapped_send(self, send: Send, request_span: dict[str, float]) -> Callable: @wraps(send) async def wrapped_send(message: Message) -> None: if message["type"] == "http.response.start": request_span["status_code"] = message["status"] if message["type"] == "http.response.body": end = time.perf_counter() request_span["duration"] = end - request_span["start_time"] request_span["end_time"] = end await send(message) return wrapped_send