Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
from litestar import Litestar
from litestar.plugins.prometheus import PrometheusConfig, PrometheusController
from litestar.plugins.prometheus import PrometheusController, PrometheusMiddleware


def create_app(group_path: bool = True) -> Litestar:
# Default app name and prefix is litestar.
prometheus_config = PrometheusConfig(group_path=group_path)
prometheus_middleware = PrometheusMiddleware(group_path=group_path)

# By default the metrics are available in prometheus format and the path is set to '/metrics'.
# If you want to change the path and format you can do it by subclassing the PrometheusController class.

# Creating the litestar app instance with our custom PrometheusConfig and PrometheusController.
return Litestar(route_handlers=[PrometheusController], middleware=[prometheus_config.middleware])
# Creating the litestar app instance with our custom PrometheusMiddleware and PrometheusController.
return Litestar(route_handlers=[PrometheusController], middleware=[prometheus_middleware])
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from typing import Any

from litestar import Litestar, Request
from litestar.plugins.prometheus import PrometheusConfig, PrometheusController
from litestar.plugins.prometheus import PrometheusController, PrometheusMiddleware


# We can modify the path of our custom handler and override the metrics format by subclassing the PrometheusController.
Expand Down Expand Up @@ -32,10 +32,10 @@ def custom_exemplar(request: Request[Any, Any, Any]) -> dict[str, str]:
return {"trace_id": "1234"}


# Creating the instance of PrometheusConfig with our own custom options.
# Creating the instance of PrometheusMiddleware with our own custom options.
# The given options are not necessary, you can use the default ones
# as well by just creating a raw instance PrometheusConfig()
prometheus_config = PrometheusConfig(
# as well by just creating a raw instance PrometheusMiddleware()
prometheus_middleware = PrometheusMiddleware(
app_name="litestar-example",
prefix="litestar",
labels=extra_labels,
Expand All @@ -45,5 +45,5 @@ def custom_exemplar(request: Request[Any, Any, Any]) -> dict[str, str]:
)


# Creating the litestar app instance with our custom PrometheusConfig and PrometheusController.
app = Litestar(route_handlers=[CustomPrometheusController], middleware=[prometheus_config.middleware])
# Creating the litestar app instance with our custom PrometheusMiddleware and PrometheusController.
app = Litestar(route_handlers=[CustomPrometheusController], middleware=[prometheus_middleware])
50 changes: 50 additions & 0 deletions docs/release-notes/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,56 @@
.. changelog:: 3.0.0
:date: 2364-01-27

.. change:: Migrate ``PrometheusMiddleware`` to ``ASGIMiddleware``
:type: feature
:pr: 5006
:issue: 4009
:breaking:

``PrometheusMiddleware`` has been moved from the legacy ``AbstractMiddleware``
base to :class:`~litestar.middleware.ASGIMiddleware`, as part of migrating all
built-in middleware off the legacy bases.

Since the middleware is now directly constructible, the
:class:`~litestar.plugins.prometheus.PrometheusConfig` configuration object is
obsolete and its ``middleware`` property is deprecated; it will be removed in
``4.0``. Pass a configured ``PrometheusMiddleware`` instance to the middleware
list instead:

.. code-block:: python

# before
config = PrometheusConfig(app_name="my-app", group_path=True)
app = Litestar(..., middleware=[config.middleware])

# after
app = Litestar(
...,
middleware=[PrometheusMiddleware(app_name="my-app", group_path=True)],
)

Subclasses of ``PrometheusMiddleware`` must rename ``__call__`` to ``handle``,
which receives the next ASGI app as an additional ``next_app`` argument in place
of ``self.app``, and read settings from the corresponding instance attributes
instead of the removed private ``self._config``.

Two behavioural changes for excluded routes:

- :attr:`~litestar.plugins.prometheus.PrometheusConfig.exclude` patterns are
now matched against the **handler's path template** (e.g.
``/user/{user_id:int}``) at startup, instead of the request path (e.g.
``/user/1``) at runtime. Patterns targeting literal handler paths keep
working; patterns written to match expanded path parameters must be rewritten
against the template. For mounted ASGI apps, patterns match the mount path
only, not sub-paths, and a handler registered on multiple paths is excluded as
a whole when any of its paths matches.
- Handlers excluded via ``exclude`` or ``exclude_opt_key`` now bypass the
middleware entirely at startup rather than per request.

Mounted ASGI apps stay wrapped regardless of the configured ``scopes``, with
their connections filtered by scope type at runtime, so restricting the
middleware to e.g. ``{"websocket"}`` also holds for connections through mounts.

.. change:: Migrate ``RateLimitMiddleware`` to ``ASGIMiddleware``
:type: feature
:pr: 5005
Expand Down
24 changes: 13 additions & 11 deletions docs/release-notes/whats-new-3.rst
Original file line number Diff line number Diff line change
Expand Up @@ -302,11 +302,11 @@ Built-in middleware migrated to ``ASGIMiddleware``
Litestar's built-in middleware are being moved from the legacy ``AbstractMiddleware`` and
``MiddlewareProtocol`` bases onto :class:`~litestar.middleware.ASGIMiddleware`.
``CORSMiddleware``, ``ResponseCacheMiddleware``, ``CSRFMiddleware``,
``CompressionMiddleware``, ``AllowedHostsMiddleware`` and ``RateLimitMiddleware`` have
made this move. ``ResponseCacheMiddleware`` and ``CSRFMiddleware`` have also been moved
into ``litestar.middleware._internal``, removing them from the public API; for CSRF this
includes the ``litestar.middleware.csrf`` module and its ``generate_csrf_token`` /
``generate_csrf_hash`` helpers.
``CompressionMiddleware``, ``AllowedHostsMiddleware``, ``RateLimitMiddleware`` and
``PrometheusMiddleware`` have made this move. ``ResponseCacheMiddleware`` and
``CSRFMiddleware`` have also been moved into ``litestar.middleware._internal``, removing
them from the public API; for CSRF this includes the ``litestar.middleware.csrf`` module
and its ``generate_csrf_token`` / ``generate_csrf_hash`` helpers.

These classes are constructed by Litestar itself from their configuration objects (for
CSRF, via a ``from_config`` classmethod on the middleware), so applications that only
Expand Down Expand Up @@ -364,13 +364,15 @@ attribute of each layer.
Subclasses must also rename ``__call__`` to ``handle``, which receives the next ASGI app
as an additional ``next_app`` argument in place of ``self.app``.

For rate limiting, :class:`~litestar.middleware.rate_limit.RateLimitMiddleware` is now
directly constructible, making the
:class:`~litestar.middleware.rate_limit.RateLimitConfig` object obsolete: its
``middleware`` property is deprecated and will be removed in ``4.0``. Pass a configured
middleware instance to the middleware list instead, e.g.
For rate limiting and Prometheus metrics,
:class:`~litestar.middleware.rate_limit.RateLimitMiddleware` and
:class:`~litestar.plugins.prometheus.PrometheusMiddleware` are now directly
constructible, making the :class:`~litestar.middleware.rate_limit.RateLimitConfig` and
:class:`~litestar.plugins.prometheus.PrometheusConfig` objects obsolete: their
``middleware`` properties are deprecated and will be removed in ``4.0``. Pass a
configured middleware instance to the middleware list instead, e.g.
``middleware=[RateLimitMiddleware(rate_limit=("minute", 10))]``. The ``exclude``
patterns are now matched against the **handler's path template** (e.g.
patterns of both middleware are now matched against the **handler's path template** (e.g.
``/user/{user_id:int}``) at startup, instead of the request path (e.g. ``/user/1``) at
runtime, and handlers excluded via ``exclude`` or ``exclude_opt_key`` bypass the
middleware entirely at startup rather than per request.
Expand Down
33 changes: 25 additions & 8 deletions litestar/plugins/prometheus/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
from typing import TYPE_CHECKING

from litestar.exceptions import MissingDependencyException
from litestar.middleware.base import DefineMiddleware
from litestar.plugins.prometheus.middleware import (
PrometheusMiddleware,
)
from litestar.utils.deprecation import warn_deprecation

__all__ = ("PrometheusConfig",)

Expand Down Expand Up @@ -57,13 +57,30 @@ class PrometheusConfig:
"""

@property
def middleware(self) -> DefineMiddleware:
"""Create an instance of :class:`DefineMiddleware <litestar.middleware.base.DefineMiddleware>` that wraps with.

[PrometheusMiddleware][litestar.plugins.prometheus.PrometheusMiddleware]. or a subclass
of this middleware.
def middleware(self) -> PrometheusMiddleware:
"""Create an instance of :class:`PrometheusMiddleware <litestar.plugins.prometheus.PrometheusMiddleware>`,
or a subclass of this middleware, configured from this config instance.

Returns:
An instance of ``DefineMiddleware``.
An instance of :attr:`middleware_class`.
"""
return DefineMiddleware(self.middleware_class, config=self)
warn_deprecation(
version="3.0",
deprecated_name="PrometheusConfig.middleware",
kind="property",
removal_in="4.0",
alternative="PrometheusMiddleware",
info="Construct a PrometheusMiddleware instance directly and pass it to the middleware list",
)
return self.middleware_class(
app_name=self.app_name,
prefix=self.prefix,
labels=self.labels,
exemplars=self.exemplars,
buckets=self.buckets,
excluded_http_methods=self.excluded_http_methods,
exclude=self.exclude,
exclude_opt_key=self.exclude_opt_key,
scopes=self.scopes,
group_path=self.group_path,
)
90 changes: 63 additions & 27 deletions litestar/plugins/prometheus/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from litestar.connection.request import Request
from litestar.enums import ScopeType
from litestar.exceptions import HTTPException, MissingDependencyException
from litestar.middleware.base import AbstractMiddleware
from litestar.middleware.base import ASGIMiddleware

__all__ = ("PrometheusMiddleware",)

Expand All @@ -21,35 +21,70 @@
from prometheus_client import Counter, Gauge, Histogram

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

from prometheus_client.metrics import MetricWrapperBase

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


class PrometheusMiddleware(AbstractMiddleware):
class PrometheusMiddleware(ASGIMiddleware):
"""Prometheus Middleware."""

_metrics: ClassVar[dict[str, MetricWrapperBase]] = {}

def __init__(self, app: ASGIApp, config: PrometheusConfig) -> None:
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: The ``next`` ASGI app to call.
config: An instance of :class:`PrometheusConfig <.plugins.prometheus.PrometheusConfig>`
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.
"""
super().__init__(app=app, scopes=config.scopes, exclude=config.exclude, exclude_opt_key=config.exclude_opt_key)
self._config = config
self._kwargs: dict[str, Any] = {}
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

if self._config.buckets is not None:
self._kwargs["buckets"] = self._config.buckets
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._config.prefix}_requests_total"
metric_name = f"{self.prefix}_requests_total"

if metric_name not in PrometheusMiddleware._metrics:
PrometheusMiddleware._metrics[metric_name] = Counter(
Expand All @@ -61,7 +96,7 @@ def request_count(self, labels: dict[str, str | int | float]) -> Counter:
return cast("Counter", PrometheusMiddleware._metrics[metric_name])

def request_time(self, labels: dict[str, str | int | float]) -> Histogram:
metric_name = f"{self._config.prefix}_request_duration_seconds"
metric_name = f"{self.prefix}_request_duration_seconds"

if metric_name not in PrometheusMiddleware._metrics:
PrometheusMiddleware._metrics[metric_name] = Histogram(
Expand All @@ -73,7 +108,7 @@ def request_time(self, labels: dict[str, str | int | float]) -> Histogram:
return cast("Histogram", PrometheusMiddleware._metrics[metric_name])

def requests_in_progress(self, labels: dict[str, str | int | float]) -> Gauge:
metric_name = f"{self._config.prefix}_requests_in_progress"
metric_name = f"{self.prefix}_requests_in_progress"

if metric_name not in PrometheusMiddleware._metrics:
PrometheusMiddleware._metrics[metric_name] = Gauge(
Expand All @@ -85,7 +120,7 @@ def requests_in_progress(self, labels: dict[str, str | int | float]) -> Gauge:
return cast("Gauge", PrometheusMiddleware._metrics[metric_name])

def requests_error_count(self, labels: dict[str, str | int | float]) -> Counter:
metric_name = f"{self._config.prefix}_requests_error_total"
metric_name = f"{self.prefix}_requests_error_total"

if metric_name not in PrometheusMiddleware._metrics:
PrometheusMiddleware._metrics[metric_name] = Counter(
Expand All @@ -105,7 +140,7 @@ def _get_extra_labels(self, request: Request[Any, Any, Any]) -> dict[str, str]:
A dictionary of extra labels.
"""

return {k: str(v(request) if callable(v) else v) for k, v in (self._config.labels or {}).items()}
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.
Expand All @@ -118,31 +153,32 @@ def _get_default_labels(self, request: Request[Any, Any, Any]) -> dict[str, str
"""

path = request.url.path
if self._config.group_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._config.app_name,
"app_name": self.app_name,
}

async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
"""ASGI callable.
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._config.excluded_http_methods and request.method in self._config.excluded_http_methods:
await self.app(scope, receive, send)
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)}
Expand All @@ -155,7 +191,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:

try:
try:
await self.app(scope, receive, wrapped_send)
await next_app(scope, receive, wrapped_send)
except HTTPException as exc:
request_span["status_code"] = exc.status_code
raise
Expand All @@ -164,8 +200,8 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
raise
finally:
extra: dict[str, Any] = {}
if self._config.exemplars:
extra["exemplar"] = self._config.exemplars(request)
if self.exemplars:
extra["exemplar"] = self.exemplars(request)

self.requests_in_progress(labels).labels(*labels.values()).dec()

Expand Down
Loading