from collections.abc import Callable, Iterator, MutableMapping, Sequence from contextlib import AbstractContextManager, contextmanager, nullcontext from dataclasses import dataclass, field from time import time_ns from typing import Annotated, Any from annotated_doc import Doc from fastapi.exceptions import RequestValidationError, WebSocketRequestValidationError from opentelemetry import context as otel_context from opentelemetry._logs import Logger, LoggerProvider, SeverityNumber from opentelemetry.context import Context from opentelemetry.metrics import MeterProvider from opentelemetry.trace import Span, StatusCode, Tracer, TracerProvider from starlette.exceptions import HTTPException, WebSocketException from starlette.requests import Request from starlette.websockets import WebSocket, WebSocketDisconnect from typing_extensions import TypedDict _HTTP_METHODS = frozenset( { "CONNECT", "DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT", "QUERY", "TRACE", } ) class TelemetryConfig(TypedDict, total=False): """Optional settings for `FastAPI(telemetry={...})`. Omitted settings keep their defaults. FastAPI preserves existing providers and their exporters. Environment setup can add exporters when `auto_configure` is `True`. Providers supplied by the application are never shut down by FastAPI. """ tracer_provider: Annotated[ TracerProvider | None, Doc("Use this provider instead of the global tracer provider."), ] meter_provider: Annotated[ MeterProvider | None, Doc("Use this provider instead of the global meter provider."), ] logger_provider: Annotated[ LoggerProvider | None, Doc("Use this provider instead of the global logger provider."), ] tracing: Annotated[ bool, Doc("Enable HTTP request and WebSocket connection spans. Defaults to `True`."), ] metrics: Annotated[bool, Doc("Enable HTTP request metrics. Defaults to `True`.")] logs: Annotated[ bool, Doc( "Record validation failures and unhandled exceptions, including exception messages and stack traces. Defaults to `True`." ), ] operation_spans: Annotated[ bool, Doc( "Trace dependency resolution, endpoints, serialization, and background tasks. Defaults to `True`." ), ] auto_configure: Annotated[ bool, Doc( "Add OTLP exporters from environment variables. Defaults to `False` unless " "`FASTAPI_OTEL_AUTO_CONFIGURE=true` is set. An explicit value overrides " "the environment variable." ), ] exclude: Annotated[ Callable[[MutableMapping[str, Any]], bool] | None, Doc( "A function that receives the ASGI scope. Return `True` to skip FastAPI telemetry for that request or connection." ), ] @dataclass(kw_only=True) class TelemetryData: """Local request or connection data for synchronous OpenTelemetry processors. FastAPI populates these fields as it handles the request or connection. Integrations should treat the data as read-only and apply their capture and redaction settings before exporting any of it. """ request: Annotated[ Request | None, Doc("The request, available when its route handler starts.") ] = None websocket: Annotated[ WebSocket | None, Doc("The WebSocket, available when its route handler starts."), ] = None body: Annotated[ Any, Doc("The body read by FastAPI, before parameter validation.") ] = None values: Annotated[ dict[str, Any] | None, Doc("The original parsed arguments, available after dependency resolution."), ] = None errors: Annotated[ Sequence[Any] | None, Doc("Request validation errors, including the original input values."), ] = None @dataclass(kw_only=True) class _RequestTelemetry: span: Span | None span_method: str tracer: Tracer | None = None logger: Logger | None = None route: str | None = None status_code: int | None = None data: TelemetryData | None = field(default_factory=TelemetryData) _mount_prefix: str = "" _exceptions: list[BaseException] = field(default_factory=list, repr=False) _REQUEST_TELEMETRY_KEY = otel_context.create_key("fastapi.request") _NO_OPERATION = nullcontext() def _get_request_telemetry(context: Context | None = None) -> _RequestTelemetry | None: value = otel_context.get_value(_REQUEST_TELEMETRY_KEY, context) return value if isinstance(value, _RequestTelemetry) else None def get_telemetry_data(context: Context | None = None) -> TelemetryData | None: """Read local FastAPI data from an OpenTelemetry context. Defaults to the current context. Log processors can pass `record.context`. Read the data synchronously while FastAPI handles the request or connection. After it finishes, this returns `None`, including for retained contexts. This data is not automatically added to exported spans or logs. """ request_telemetry = _get_request_telemetry(context) return request_telemetry.data if request_telemetry is not None else None def _validation_failed( exc: RequestValidationError | WebSocketRequestValidationError, ) -> None: request_telemetry = _get_request_telemetry() if request_telemetry is None: return if request_telemetry.data is not None: if isinstance(exc, RequestValidationError): request_telemetry.data.body = exc.body request_telemetry.data.errors = exc.errors() if request_telemetry.logger is not None: attributes: dict[str, Any] = { "fastapi.validation.error_count": len(exc.errors()) } if request_telemetry.route is not None: attributes["http.route"] = request_telemetry.route request_telemetry.logger.emit( event_name="fastapi.validation.failed", timestamp=time_ns(), severity_number=SeverityNumber.WARN, severity_text="WARN", body="Request validation failed", attributes=attributes, ) def _exception_type(exc: BaseException) -> str: cls = type(exc) return ( f"{cls.__module__}.{cls.__qualname__}" if cls.__module__ != "builtins" else cls.__qualname__ ) def _normal_websocket_disconnect(exc: BaseException) -> bool: return isinstance(exc, WebSocketDisconnect) and exc.code in (1000, 1001) def _operation( *, name: str, function: Callable[..., Any] | None = None ) -> AbstractContextManager[None]: request_telemetry = _get_request_telemetry() if request_telemetry is None or request_telemetry.tracer is None: return _NO_OPERATION return _traced_operation( request_telemetry=request_telemetry, name=name, function=function ) @contextmanager def _traced_operation( *, request_telemetry: _RequestTelemetry, name: str, function: Callable[..., Any] | None, ) -> Iterator[None]: assert request_telemetry.tracer is not None attributes = {} if function is not None: module = getattr(function, "__module__", type(function).__module__) qualname = getattr(function, "__qualname__", type(function).__qualname__) attributes["code.function.name"] = f"{module}.{qualname}" with request_telemetry.tracer.start_as_current_span( f"fastapi.{name}", attributes=attributes, record_exception=False, set_status_on_exception=False, ) as span: try: yield except Exception as exc: if name == "background_task" or ( not isinstance( exc, ( HTTPException, RequestValidationError, WebSocketException, WebSocketRequestValidationError, ), ) and not _normal_websocket_disconnect(exc) ): span.set_attribute("error.type", _exception_type(exc)) span.set_status(StatusCode.ERROR) raise def _run_sync_endpoint( *, function: Callable[..., Any], arguments: dict[str, Any] ) -> Any: with _operation(name="endpoint", function=function): return function(**arguments) def _route_selected( *, scope: MutableMapping[str, Any], path: str | None, mount: bool = False ) -> None: request_telemetry = scope.get("fastapi.telemetry") if not isinstance(request_telemetry, _RequestTelemetry) or path is None: return # Mount's path_format ends in /{path}. The child contributes its own path. if mount: path = path.removesuffix("/{path}") request_telemetry._mount_prefix += path request_telemetry.route = request_telemetry._mount_prefix + "/{path}" else: request_telemetry.route = request_telemetry._mount_prefix + path if request_telemetry.span is not None: request_telemetry.span.set_attribute("http.route", request_telemetry.route) request_telemetry.span.update_name( f"{request_telemetry.span_method} {request_telemetry.route}" ) _DEFERRED_PROVIDERS = frozenset( { ("opentelemetry.trace", "ProxyTracerProvider"), ("opentelemetry.metrics._internal", "_ProxyMeterProvider"), ("opentelemetry._logs._internal", "ProxyLoggerProvider"), } ) def _unconfigured(provider: Any) -> bool: # Python's API has no public "is configured" query. Limit this bridge to its # known deferred providers, never SDK implementations or vendor proxies. cls = type(provider) return (cls.__module__, cls.__name__) in _DEFERRED_PROVIDERS