This commit is contained in:
macbook_max
2026-10-15 20:36:59 +08:00
commit 6bf949949c
7923 changed files with 1369582 additions and 0 deletions
@@ -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
@@ -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)