from __future__ import annotations import re from collections.abc import Sequence try: from opentelemetry import metrics, propagate, trace from opentelemetry.trace import SpanKind, Status, StatusCode except ImportError: # pragma: no cover raise ImportError("The `opentelemetry-api` package is required to use `OpenTelemetryMiddleware`.") from None from starlette import __version__ from starlette.datastructures import URL from starlette.routing import Mount from starlette.types import ASGIApp, Message, Receive, Scope, Send class OpenTelemetryMiddleware: """Create OpenTelemetry server spans for incoming HTTP requests. This middleware is experimental. Its API and emitted telemetry may change in minor releases without a deprecation period. Args: app: The ASGI application to wrap. excluded_urls: Regular expressions matched against the full request URL. Pass a comma-separated string or a sequence. By default, exclude no URLs. tracer_provider: Optional tracer provider. If omitted, use the global tracer provider. meter_provider: Optional meter provider. If omitted, use the global meter provider. """ def __init__( self, app: ASGIApp, *, excluded_urls: str | Sequence[str] = (), tracer_provider: trace.TracerProvider | None = None, meter_provider: metrics.MeterProvider | None = None, ) -> None: self.app = app self._meter_provider = meter_provider if isinstance(excluded_urls, str): excluded_urls = [pattern.strip() for pattern in excluded_urls.split(",")] if excluded_urls else () self._excluded_urls = tuple(re.compile(pattern) for pattern in excluded_urls) self._tracer_provider = tracer_provider or trace.get_tracer_provider() async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if scope["type"] != "http" or scope.get("starlette.opentelemetry"): return await self.app(scope, receive, send) scope["starlette.opentelemetry"] = True try: tracer_provider = self._tracer_provider if isinstance(tracer_provider, trace.NoOpTracerProvider): return await self.app(scope, receive, send) url = URL(scope=scope) if any(pattern.search(str(url)) for pattern in self._excluded_urls): return await self.app(scope, receive, send) original_method = scope.get("method", "") method = original_method.upper() headers: dict[str, list[str]] = {} for name, value in scope.get("headers", []): headers.setdefault(name.decode("latin-1").lower(), []).append(value.decode("latin-1")) attributes: dict[str, str | int] = { "http.request.method": method, "url.path": scope.get("path", ""), "url.scheme": scope.get("scheme", "http"), } if original_method != method: attributes["http.request.method_original"] = original_method if url.query: attributes["url.query"] = url.query if url.hostname is not None: attributes["server.address"] = url.hostname server = scope.get("server") server_port = url.port if url.port is not None else server[1] if server is not None else None if server_port is not None: attributes["server.port"] = server_port if scope.get("http_version"): attributes["network.protocol.version"] = scope["http_version"] if scope.get("client") is not None: attributes["client.address"] = scope["client"][0] if headers.get("user-agent"): attributes["user_agent.original"] = headers["user-agent"][0] with tracer_provider.get_tracer("starlette", __version__).start_as_current_span( method, context=propagate.extract(headers), kind=SpanKind.SERVER, attributes=attributes, ) as span: async def send_with_telemetry(message: Message) -> None: if message["type"] == "http.response.start": status_code = message["status"] span.set_attribute("http.response.status_code", status_code) if status_code >= 500: span.set_attribute("error.type", str(status_code)) span.set_status(Status(StatusCode.ERROR)) await send(message) try: await self.app(scope, receive, send_with_telemetry) except Exception as exc: span.set_attribute("error.type", type(exc).__qualname__) raise finally: route = scope.get("route") if isinstance(route, Mount): route_path = scope.get("root_path") or "/" else: path_format = getattr(route, "path_format", None) route_path = ( scope.get("root_path", "").rstrip("/") + path_format or "/" if isinstance(path_format, str) else None ) if route_path is not None: span.update_name(f"{method} {route_path}") span.set_attribute("http.route", route_path) finally: del scope["starlette.opentelemetry"]