239 lines
9.8 KiB
Python
239 lines
9.8 KiB
Python
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)
|