281 lines
9.7 KiB
Python
281 lines
9.7 KiB
Python
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
|