add
This commit is contained in:
@@ -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