add
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
from ._api import TelemetryConfig as TelemetryConfig
|
||||
from ._api import TelemetryData as TelemetryData
|
||||
from ._api import get_telemetry_data as get_telemetry_data
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,280 @@
|
||||
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
|
||||
@@ -0,0 +1,457 @@
|
||||
import os
|
||||
from contextlib import nullcontext
|
||||
from time import perf_counter, time_ns
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit
|
||||
|
||||
from fastapi import __version__
|
||||
from fastapi.telemetry._api import (
|
||||
_HTTP_METHODS,
|
||||
_REQUEST_TELEMETRY_KEY,
|
||||
TelemetryConfig,
|
||||
_exception_type,
|
||||
_normal_websocket_disconnect,
|
||||
_RequestTelemetry,
|
||||
_unconfigured,
|
||||
)
|
||||
from opentelemetry import _logs, metrics, propagate, trace
|
||||
from opentelemetry import context as otel_context
|
||||
from opentelemetry._logs import SeverityNumber
|
||||
from opentelemetry.metrics import Histogram, UpDownCounter
|
||||
from opentelemetry.propagators.textmap import Getter
|
||||
from opentelemetry.trace import SpanKind, StatusCode
|
||||
from starlette.datastructures import Headers
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
_DURATION_BUCKETS = (
|
||||
0.005,
|
||||
0.01,
|
||||
0.025,
|
||||
0.05,
|
||||
0.075,
|
||||
0.1,
|
||||
0.25,
|
||||
0.5,
|
||||
0.75,
|
||||
1.0,
|
||||
2.5,
|
||||
5.0,
|
||||
7.5,
|
||||
10.0,
|
||||
)
|
||||
_SCHEMA_URL = "https://opentelemetry.io/schemas/1.44.0"
|
||||
# https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-server-span
|
||||
_SENSITIVE_QUERY_PARAMETERS = frozenset(
|
||||
{
|
||||
"X-Amz-Signature",
|
||||
"X-Amz-Credential",
|
||||
"X-Amz-Security-Token",
|
||||
"sig",
|
||||
"X-Goog-Signature",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class _HeadersGetter(Getter[Headers]):
|
||||
def get(self, carrier: Headers, key: str) -> list[str] | None:
|
||||
values = carrier.getlist(key)
|
||||
if not values:
|
||||
return None
|
||||
if key.lower() == "baggage":
|
||||
# The baggage propagator reads only the first value.
|
||||
return [",".join(values)]
|
||||
return values
|
||||
|
||||
def keys(self, carrier: Headers) -> list[str]:
|
||||
return carrier.keys()
|
||||
|
||||
|
||||
_HEADERS_GETTER = _HeadersGetter()
|
||||
|
||||
|
||||
def _legacy_otel(stack: Any) -> bool:
|
||||
# Test the built stack, not contrib's flag: that flag also exists when its
|
||||
# stack patch failed, or when instrument_app was called after stack creation.
|
||||
seen: set[int] = set()
|
||||
while stack is not None and id(stack) not in seen:
|
||||
seen.add(id(stack))
|
||||
if (
|
||||
type(stack).__module__ == "opentelemetry.instrumentation.asgi"
|
||||
and type(stack).__name__ == "OpenTelemetryMiddleware"
|
||||
):
|
||||
return True
|
||||
stack = getattr(stack, "app", None)
|
||||
return False
|
||||
|
||||
|
||||
def _exception(*, scope: Scope, exc: BaseException) -> None:
|
||||
if scope["type"] == "websocket" and _normal_websocket_disconnect(exc):
|
||||
return
|
||||
request_telemetry = scope.get("fastapi.telemetry")
|
||||
if isinstance(request_telemetry, _RequestTelemetry):
|
||||
seen = request_telemetry._exceptions
|
||||
if not any(previous is exc for previous in seen):
|
||||
seen.append(exc)
|
||||
if request_telemetry.logger is not None and isinstance(exc, Exception):
|
||||
attributes = (
|
||||
{"http.route": request_telemetry.route}
|
||||
if request_telemetry.route
|
||||
else None
|
||||
)
|
||||
request_telemetry.logger.emit(
|
||||
exception=exc,
|
||||
event_name="fastapi.websocket.exception"
|
||||
if scope["type"] == "websocket"
|
||||
else "http.server.request.exception",
|
||||
timestamp=time_ns(),
|
||||
severity_number=SeverityNumber.ERROR,
|
||||
severity_text="ERROR",
|
||||
body="Unhandled exception in FastAPI WebSocket connection"
|
||||
if scope["type"] == "websocket"
|
||||
else "Unhandled exception in FastAPI request",
|
||||
attributes=attributes,
|
||||
)
|
||||
|
||||
|
||||
def _server_attributes(scope: Scope) -> dict[str, Any]:
|
||||
# ASGI exposes HTTP/2 and HTTP/3 authority as Host. Proxy normalization is
|
||||
# handled by the server or middleware, as it is for the request URL.
|
||||
authority = Headers(scope=scope).get("host")
|
||||
if authority is not None:
|
||||
try:
|
||||
url = urlsplit("//" + authority)
|
||||
address, port = url.hostname, url.port
|
||||
except ValueError:
|
||||
# Malformed authority must not prevent handling the request.
|
||||
return {}
|
||||
if url.username is not None or url.path or url.query or url.fragment:
|
||||
return {}
|
||||
if port is None:
|
||||
port = {"http": 80, "https": 443, "ws": 80, "wss": 443}.get(
|
||||
scope.get("scheme", "http")
|
||||
)
|
||||
else:
|
||||
address, port = scope.get("server") or (None, None)
|
||||
if address is None:
|
||||
return {}
|
||||
attributes: dict[str, Any] = {"server.address": address}
|
||||
if port is not None:
|
||||
attributes["server.port"] = port
|
||||
return attributes
|
||||
|
||||
|
||||
class ExceptionTelemetryMiddleware:
|
||||
"""Observe exceptions before error handlers send and complete a response."""
|
||||
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
try:
|
||||
await self.app(scope, receive, send)
|
||||
except Exception as exc:
|
||||
_exception(scope=scope, exc=exc)
|
||||
raise
|
||||
|
||||
|
||||
class NativeTelemetry:
|
||||
def __init__(self, config: TelemetryConfig) -> None:
|
||||
self.config = config
|
||||
known_methods = os.getenv("OTEL_INSTRUMENTATION_HTTP_KNOWN_METHODS", "").strip()
|
||||
self._known_methods = (
|
||||
frozenset(
|
||||
method.strip() for method in known_methods.split(",") if method.strip()
|
||||
)
|
||||
if known_methods
|
||||
else _HTTP_METHODS
|
||||
)
|
||||
self._provider: metrics.MeterProvider | None = None
|
||||
self._duration: Histogram | None = None
|
||||
self._active: UpDownCounter | None = None
|
||||
self._tracer_provider: trace.TracerProvider | None = None
|
||||
self._tracer: trace.Tracer | None = None
|
||||
|
||||
def enabled(self) -> bool:
|
||||
config = self.config
|
||||
return bool(
|
||||
(
|
||||
config["tracing"]
|
||||
and (
|
||||
config["tracer_provider"] is not None
|
||||
or not _unconfigured(trace.get_tracer_provider())
|
||||
)
|
||||
)
|
||||
or (
|
||||
config["metrics"]
|
||||
and (
|
||||
config["meter_provider"] is not None
|
||||
or not _unconfigured(metrics.get_meter_provider())
|
||||
)
|
||||
)
|
||||
or (
|
||||
config["logs"]
|
||||
and (
|
||||
config["logger_provider"] is not None
|
||||
or not _unconfigured(_logs.get_logger_provider())
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def _instruments(self) -> tuple[Histogram, UpDownCounter]:
|
||||
provider = self.config["meter_provider"] or metrics.get_meter_provider()
|
||||
if provider is not self._provider:
|
||||
meter = provider.get_meter("fastapi", __version__, schema_url=_SCHEMA_URL)
|
||||
self._duration = meter.create_histogram(
|
||||
"http.server.request.duration",
|
||||
unit="s",
|
||||
description="Duration of HTTP server requests.",
|
||||
explicit_bucket_boundaries_advisory=_DURATION_BUCKETS,
|
||||
)
|
||||
self._active = meter.create_up_down_counter(
|
||||
"http.server.active_requests",
|
||||
unit="{request}",
|
||||
description="Number of active HTTP server requests.",
|
||||
)
|
||||
self._provider = provider
|
||||
assert self._duration is not None and self._active is not None
|
||||
return self._duration, self._active
|
||||
|
||||
def _get_tracer(self, *, provider: trace.TracerProvider) -> trace.Tracer:
|
||||
if provider is not self._tracer_provider:
|
||||
self._tracer = provider.get_tracer(
|
||||
"fastapi", __version__, schema_url=_SCHEMA_URL
|
||||
)
|
||||
self._tracer_provider = provider
|
||||
assert self._tracer is not None
|
||||
return self._tracer
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
*,
|
||||
app: ASGIApp,
|
||||
scope: Scope,
|
||||
receive: Receive,
|
||||
send: Send,
|
||||
legacy_otel: bool = False,
|
||||
) -> None:
|
||||
config = self.config
|
||||
if config["exclude"] is not None and config["exclude"](scope):
|
||||
# Keep mounted FastAPI apps from observing this excluded request.
|
||||
scope["fastapi.telemetry"] = None
|
||||
# An in-process request can inherit another request's telemetry.
|
||||
token = otel_context.attach(
|
||||
otel_context.set_value(_REQUEST_TELEMETRY_KEY, None)
|
||||
)
|
||||
try:
|
||||
await app(scope, receive, send)
|
||||
finally:
|
||||
otel_context.detach(token)
|
||||
scope.pop("fastapi.telemetry", None)
|
||||
return
|
||||
tracing = config["tracing"] and not legacy_otel
|
||||
logging = config["logs"] and not legacy_otel
|
||||
if logging:
|
||||
logger_provider = config["logger_provider"] or _logs.get_logger_provider()
|
||||
logging = not (
|
||||
(config["logger_provider"] is None and _unconfigured(logger_provider))
|
||||
or isinstance(logger_provider, _logs.NoOpLoggerProvider)
|
||||
)
|
||||
is_websocket = scope["type"] == "websocket"
|
||||
metering = config["metrics"] and not legacy_otel and not is_websocket
|
||||
if tracing:
|
||||
provider = config["tracer_provider"] or trace.get_tracer_provider()
|
||||
tracing = not (
|
||||
(config["tracer_provider"] is None and _unconfigured(provider))
|
||||
or isinstance(provider, trace.NoOpTracerProvider)
|
||||
)
|
||||
if metering:
|
||||
meter_provider = config["meter_provider"] or metrics.get_meter_provider()
|
||||
metering = not (
|
||||
(config["meter_provider"] is None and _unconfigured(meter_provider))
|
||||
or isinstance(meter_provider, metrics.NoOpMeterProvider)
|
||||
)
|
||||
if not tracing and not metering and not logging:
|
||||
await app(scope, receive, send)
|
||||
return
|
||||
original_method = scope.get("method", "")
|
||||
method = original_method if original_method in self._known_methods else "_OTHER"
|
||||
span_method = "WS" if is_websocket else "HTTP" if method == "_OTHER" else method
|
||||
attributes: dict[str, Any] = {
|
||||
"url.scheme": scope.get("scheme", "ws" if is_websocket else "http"),
|
||||
}
|
||||
if is_websocket:
|
||||
attributes["network.protocol.name"] = "websocket"
|
||||
else:
|
||||
attributes["http.request.method"] = method
|
||||
if scope.get("http_version"):
|
||||
attributes["network.protocol.version"] = scope["http_version"]
|
||||
duration, active = self._instruments() if metering else (None, None)
|
||||
active_attributes = {
|
||||
k: v for k, v in attributes.items() if k != "network.protocol.version"
|
||||
}
|
||||
if active is not None:
|
||||
active.add(1, active_attributes)
|
||||
started = perf_counter()
|
||||
span = None
|
||||
tracer = None
|
||||
parent_token = None
|
||||
if tracing:
|
||||
parent = propagate.extract(Headers(scope=scope), getter=_HEADERS_GETTER)
|
||||
parent_token = otel_context.attach(parent)
|
||||
tracer = self._get_tracer(provider=provider)
|
||||
span_attributes = {**attributes, **_server_attributes(scope)}
|
||||
if not is_websocket:
|
||||
span_attributes["url.path"] = scope["path"]
|
||||
if scope.get("query_string"):
|
||||
span_attributes["url.query"] = urlencode(
|
||||
[
|
||||
(
|
||||
key,
|
||||
"REDACTED"
|
||||
if key in _SENSITIVE_QUERY_PARAMETERS
|
||||
else value,
|
||||
)
|
||||
for key, value in parse_qsl(
|
||||
scope["query_string"].decode("latin-1"),
|
||||
keep_blank_values=True,
|
||||
)
|
||||
]
|
||||
)
|
||||
if original_method != method:
|
||||
span_attributes["http.request.method_original"] = original_method
|
||||
span = tracer.start_span(
|
||||
span_method,
|
||||
context=parent,
|
||||
kind=SpanKind.SERVER,
|
||||
attributes=span_attributes,
|
||||
)
|
||||
request_telemetry = _RequestTelemetry(
|
||||
span=span,
|
||||
span_method=span_method,
|
||||
tracer=tracer if config["operation_spans"] else None,
|
||||
logger=_logs.get_logger(
|
||||
"fastapi",
|
||||
__version__,
|
||||
config["logger_provider"],
|
||||
schema_url=_SCHEMA_URL,
|
||||
)
|
||||
if logging
|
||||
else None,
|
||||
_mount_prefix=scope.get("app_root_path", scope.get("root_path", "")).rstrip(
|
||||
"/"
|
||||
),
|
||||
)
|
||||
scope["fastapi.telemetry"] = request_telemetry
|
||||
token = otel_context.attach(
|
||||
otel_context.set_value(_REQUEST_TELEMETRY_KEY, request_telemetry)
|
||||
)
|
||||
finished = False
|
||||
trailers = False
|
||||
disconnected = False
|
||||
|
||||
def finish(error: BaseException | None = None) -> None:
|
||||
nonlocal finished
|
||||
if finished:
|
||||
return
|
||||
finished = True
|
||||
if request_telemetry.route is not None:
|
||||
attributes["http.route"] = request_telemetry.route
|
||||
if request_telemetry.status_code is not None:
|
||||
attributes["http.response.status_code"] = request_telemetry.status_code
|
||||
failed = (
|
||||
error is not None
|
||||
or not is_websocket
|
||||
and (
|
||||
request_telemetry.status_code is None
|
||||
or request_telemetry.status_code >= 500
|
||||
)
|
||||
)
|
||||
if failed:
|
||||
errors = request_telemetry._exceptions
|
||||
failure = error or (errors[-1] if errors else None)
|
||||
attributes["error.type"] = (
|
||||
_exception_type(failure)
|
||||
if failure
|
||||
else str(request_telemetry.status_code or "incomplete_response")
|
||||
)
|
||||
if span is not None:
|
||||
span.set_attributes(attributes)
|
||||
if failed:
|
||||
span.set_status(StatusCode.ERROR)
|
||||
# Record while the span is current, so exemplars can correlate it.
|
||||
if duration is not None:
|
||||
duration.record(max(0, perf_counter() - started), attributes)
|
||||
if active is not None:
|
||||
active.add(-1, active_attributes)
|
||||
if span is not None:
|
||||
span.end()
|
||||
|
||||
async def wrapped_send(message: Message) -> None:
|
||||
nonlocal trailers
|
||||
if message["type"] == "http.response.start":
|
||||
request_telemetry.status_code = message["status"]
|
||||
trailers = message.get("trailers", False)
|
||||
await send(message)
|
||||
if (
|
||||
(
|
||||
message["type"] == "http.response.body"
|
||||
and not message.get("more_body", False)
|
||||
and not trailers
|
||||
)
|
||||
or (
|
||||
message["type"] == "http.response.trailers"
|
||||
and not message.get("more_trailers", False)
|
||||
)
|
||||
or message["type"] == "http.response.pathsend"
|
||||
):
|
||||
finish()
|
||||
|
||||
async def wrapped_receive() -> Message:
|
||||
nonlocal disconnected
|
||||
message = await receive()
|
||||
if message["type"] == "http.disconnect":
|
||||
disconnected = True
|
||||
return message
|
||||
|
||||
context = (
|
||||
trace.use_span(
|
||||
span,
|
||||
end_on_exit=False,
|
||||
record_exception=False,
|
||||
set_status_on_exception=False,
|
||||
)
|
||||
if span is not None
|
||||
else nullcontext()
|
||||
)
|
||||
try:
|
||||
with context:
|
||||
try:
|
||||
await app(
|
||||
scope,
|
||||
receive if is_websocket else wrapped_receive,
|
||||
send if is_websocket else wrapped_send,
|
||||
)
|
||||
except BaseException as exc:
|
||||
_exception(scope=scope, exc=exc)
|
||||
finish(
|
||||
None
|
||||
if is_websocket and _normal_websocket_disconnect(exc)
|
||||
else exc
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
if not finished:
|
||||
finish(
|
||||
None
|
||||
if is_websocket
|
||||
else ConnectionError("Client disconnected")
|
||||
if disconnected
|
||||
else RuntimeError("Incomplete ASGI response")
|
||||
)
|
||||
finally:
|
||||
request_telemetry.data = None
|
||||
request_telemetry._exceptions.clear()
|
||||
otel_context.detach(token)
|
||||
scope.pop("fastapi.telemetry", None)
|
||||
if parent_token is not None:
|
||||
otel_context.detach(parent_token)
|
||||
@@ -0,0 +1,238 @@
|
||||
import atexit
|
||||
import os
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from typing import Any, cast
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from anyio.to_thread import run_sync
|
||||
from fastapi.exceptions import FastAPIError
|
||||
from fastapi.logger import logger
|
||||
from fastapi.telemetry._api import TelemetryConfig, _unconfigured
|
||||
from opentelemetry import _logs, metrics, trace
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
_lock = threading.RLock()
|
||||
_owned: list[Any] = []
|
||||
_configured: list[tuple[str, Any]] = []
|
||||
|
||||
|
||||
def _flush() -> None:
|
||||
for component in tuple(_owned):
|
||||
try:
|
||||
component.force_flush()
|
||||
except Exception:
|
||||
logger.exception("FastAPI telemetry cleanup failed")
|
||||
|
||||
|
||||
def _shutdown() -> None:
|
||||
with _lock:
|
||||
owned = tuple(_owned)
|
||||
_owned.clear()
|
||||
for component in owned:
|
||||
try:
|
||||
component.shutdown()
|
||||
except Exception:
|
||||
logger.exception("FastAPI telemetry cleanup failed")
|
||||
|
||||
|
||||
atexit.register(_shutdown)
|
||||
|
||||
|
||||
def _export_endpoint(signal: str) -> str | None:
|
||||
exporter = (os.getenv(f"OTEL_{signal}_EXPORTER") or "otlp").strip().lower()
|
||||
endpoint = os.getenv(f"OTEL_EXPORTER_OTLP_{signal}_ENDPOINT")
|
||||
if not endpoint:
|
||||
endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
|
||||
if endpoint:
|
||||
endpoint = f"{endpoint.removesuffix('/')}/v1/{signal.lower()}"
|
||||
if not endpoint or exporter == "none":
|
||||
return None
|
||||
if exporter != "otlp":
|
||||
raise FastAPIError(
|
||||
f"FastAPI automatic telemetry supports OTEL_{signal}_EXPORTER=otlp or none. "
|
||||
"Configure other exporters explicitly and pass telemetry={'auto_configure': False} to FastAPI()."
|
||||
)
|
||||
protocol = (
|
||||
os.getenv(f"OTEL_EXPORTER_OTLP_{signal}_PROTOCOL")
|
||||
or os.getenv("OTEL_EXPORTER_OTLP_PROTOCOL")
|
||||
or "http/protobuf"
|
||||
)
|
||||
if protocol != "http/protobuf":
|
||||
raise FastAPIError(
|
||||
"FastAPI automatic telemetry requires the OTLP http/protobuf protocol. Configure other transports explicitly and pass telemetry={'auto_configure': False} to FastAPI()."
|
||||
)
|
||||
parsed = urlsplit(endpoint)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
||||
raise FastAPIError(
|
||||
f"Invalid OTLP {signal.lower()} endpoint. Use an absolute HTTP or HTTPS URL."
|
||||
)
|
||||
return endpoint
|
||||
|
||||
|
||||
def _registration_provider(provider: Any) -> Any:
|
||||
# Older Logfire meter wrappers do not expose add_metric_reader(). Attach
|
||||
# the reader to their SDK provider. Measurements still use the original wrapper.
|
||||
if (
|
||||
type(provider).__module__ == "logfire._internal.metrics"
|
||||
and type(provider).__name__ == "ProxyMeterProvider"
|
||||
and not hasattr(provider, "add_metric_reader")
|
||||
):
|
||||
return provider.provider
|
||||
return provider
|
||||
|
||||
|
||||
def _configure_from_environment(config: TelemetryConfig) -> None:
|
||||
"""Add OTLP exporters from the environment to the selected providers.
|
||||
|
||||
Called automatically before ASGI lifespan startup. Existing providers and
|
||||
their exporters are preserved. FastAPI registers its own export components once
|
||||
per provider. It does not inspect or deduplicate other components' exporters.
|
||||
Enable with `FASTAPI_OTEL_AUTO_CONFIGURE=true` or pass
|
||||
`telemetry={"auto_configure": True}` to `FastAPI()`.
|
||||
"""
|
||||
if (
|
||||
not config["auto_configure"]
|
||||
or os.getenv("OTEL_SDK_DISABLED", "").lower() == "true"
|
||||
):
|
||||
return
|
||||
with _lock:
|
||||
signals = [
|
||||
("TRACES", config["tracing"], config["tracer_provider"]),
|
||||
("METRICS", config["metrics"], config["meter_provider"]),
|
||||
("LOGS", config["logs"], config["logger_provider"]),
|
||||
]
|
||||
requested = [
|
||||
(signal, provider, endpoint)
|
||||
for signal, enabled, provider in signals
|
||||
if enabled and (endpoint := _export_endpoint(signal)) is not None
|
||||
]
|
||||
if not requested:
|
||||
return
|
||||
try:
|
||||
from opentelemetry.exporter.otlp.proto.http._log_exporter import (
|
||||
OTLPLogExporter,
|
||||
)
|
||||
from opentelemetry.exporter.otlp.proto.http.metric_exporter import (
|
||||
OTLPMetricExporter,
|
||||
)
|
||||
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
|
||||
OTLPSpanExporter,
|
||||
)
|
||||
from opentelemetry.sdk._logs import LoggerProvider
|
||||
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
|
||||
from opentelemetry.sdk.metrics import MeterProvider
|
||||
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||||
except ImportError as exc:
|
||||
raise FastAPIError(
|
||||
"Automatic OpenTelemetry export requires fastapi[opentelemetry] or fastapi[standard]. Install the extra, configure providers yourself, or pass telemetry={'auto_configure': False} to FastAPI()."
|
||||
) from exc
|
||||
# The SDK's registration methods also allow vendor wrappers to forward
|
||||
# additions. Keep a record of our own registrations, without examining
|
||||
# another component's exporters or their destinations.
|
||||
providers = {
|
||||
"TRACES": ("tracer", trace.get_tracer_provider),
|
||||
"METRICS": ("meter", metrics.get_meter_provider),
|
||||
"LOGS": ("logger", _logs.get_logger_provider),
|
||||
}
|
||||
|
||||
def create_component(*, signal: str, endpoint: str) -> Any:
|
||||
if signal == "TRACES":
|
||||
return BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint))
|
||||
if signal == "METRICS":
|
||||
return PeriodicExportingMetricReader(
|
||||
OTLPMetricExporter(endpoint=endpoint)
|
||||
)
|
||||
return BatchLogRecordProcessor(OTLPLogExporter(endpoint=endpoint))
|
||||
|
||||
for signal, explicit, endpoint in requested:
|
||||
name, get_provider = providers[signal]
|
||||
provider = _registration_provider(
|
||||
explicit if explicit is not None else get_provider()
|
||||
)
|
||||
if any(s == signal and p is provider for s, p in _configured):
|
||||
continue
|
||||
if explicit is None and _unconfigured(provider):
|
||||
component = create_component(signal=signal, endpoint=endpoint)
|
||||
if signal == "TRACES":
|
||||
provider = TracerProvider(shutdown_on_exit=False)
|
||||
provider.add_span_processor(component)
|
||||
trace.set_tracer_provider(provider)
|
||||
elif signal == "METRICS":
|
||||
provider = MeterProvider(
|
||||
metric_readers=[component], shutdown_on_exit=False
|
||||
)
|
||||
metrics.set_meter_provider(provider)
|
||||
else:
|
||||
provider = LoggerProvider(shutdown_on_exit=False)
|
||||
provider.add_log_record_processor(component)
|
||||
_logs.set_logger_provider(provider)
|
||||
current = get_provider()
|
||||
if current is provider:
|
||||
_configured.append((signal, provider))
|
||||
_owned.append(provider)
|
||||
continue
|
||||
# Another component configured the global provider concurrently.
|
||||
# Close the unused pipeline and attach a new component to theirs.
|
||||
provider.shutdown()
|
||||
provider = _registration_provider(current)
|
||||
# Vendor wrappers can expose the SDK registration methods without
|
||||
# inheriting its provider classes. Check each method against its SDK
|
||||
# class statically and its availability on the provider at runtime.
|
||||
register: Callable[[Any], None] | None
|
||||
try:
|
||||
if signal == "TRACES":
|
||||
register = cast(TracerProvider, provider).add_span_processor
|
||||
elif signal == "METRICS":
|
||||
register = cast(MeterProvider, provider).add_metric_reader
|
||||
else:
|
||||
register = cast(LoggerProvider, provider).add_log_record_processor
|
||||
except AttributeError:
|
||||
register = None
|
||||
if not callable(register):
|
||||
raise FastAPIError(
|
||||
f"The configured OpenTelemetry {name} provider does not support "
|
||||
"adding an OTLP exporter. Configure environment export through "
|
||||
"that provider and pass telemetry={'auto_configure': False} to FastAPI()."
|
||||
)
|
||||
component = create_component(signal=signal, endpoint=endpoint)
|
||||
try:
|
||||
register(component)
|
||||
except Exception:
|
||||
component.shutdown()
|
||||
raise
|
||||
_configured.append((signal, provider))
|
||||
_owned.append(component)
|
||||
|
||||
|
||||
async def lifespan(
|
||||
*, config: TelemetryConfig, app: ASGIApp, scope: Scope, receive: Receive, send: Send
|
||||
) -> None:
|
||||
async def wrapped_receive() -> Message:
|
||||
message = await receive()
|
||||
if message["type"] == "lifespan.startup":
|
||||
try:
|
||||
_configure_from_environment(config)
|
||||
except Exception as exc:
|
||||
# Optional telemetry setup must not prevent application startup.
|
||||
logger.warning(
|
||||
"FastAPI automatic telemetry configuration failed: %s", exc
|
||||
)
|
||||
return message
|
||||
|
||||
async def wrapped_send(message: Message) -> None:
|
||||
if (
|
||||
message["type"]
|
||||
in {
|
||||
"lifespan.shutdown.complete",
|
||||
"lifespan.shutdown.failed",
|
||||
"lifespan.startup.failed",
|
||||
}
|
||||
and _owned
|
||||
):
|
||||
await run_sync(_flush)
|
||||
await send(message)
|
||||
|
||||
await app(scope, wrapped_receive, wrapped_send)
|
||||
Reference in New Issue
Block a user