From 1d4953d2e528b58cfb09bbc28fb25dcda227c6ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Wed, 30 Sep 2026 11:37:28 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Allow=20startup=20when=20automat?= =?UTF-8?q?ic=20OpenTelemetry=20configuration=20fails=20(#16418)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/telemetry/_runtime.py | 9 +-- tests/test_telemetry/test_runtime.py | 105 ++++++++++++++++++--------- 2 files changed, 74 insertions(+), 40 deletions(-) diff --git a/fastapi/telemetry/_runtime.py b/fastapi/telemetry/_runtime.py index 641aea43c0..d2b9df8646 100644 --- a/fastapi/telemetry/_runtime.py +++ b/fastapi/telemetry/_runtime.py @@ -210,17 +210,16 @@ def _configure_from_environment(config: TelemetryConfig) -> None: async def lifespan( *, config: TelemetryConfig, app: ASGIApp, scope: Scope, receive: Receive, send: Send ) -> None: - # Consume startup only inside the wrapper, and preserve ASGI failure events. - # Initialization exceptions before app(scope) would otherwise look like a - # server's unsupported-lifespan fallback and silently disable telemetry. async def wrapped_receive() -> Message: message = await receive() if message["type"] == "lifespan.startup": try: _configure_from_environment(config) except Exception as exc: - await send({"type": "lifespan.startup.failed", "message": str(exc)}) - raise + # 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: diff --git a/tests/test_telemetry/test_runtime.py b/tests/test_telemetry/test_runtime.py index 5adccf0bfe..c58c6413c1 100644 --- a/tests/test_telemetry/test_runtime.py +++ b/tests/test_telemetry/test_runtime.py @@ -1,5 +1,7 @@ import asyncio +import logging import threading +from contextlib import asynccontextmanager from unittest.mock import Mock import pytest @@ -201,33 +203,50 @@ def test_signal_endpoint_overrides_general_endpoint(monkeypatch, signal): ), ], ) -@run_in_subprocess -def test_invalid_configuration_reports_startup_failure(env, message): - import os +def test_invalid_configuration_warns_without_disabling_providers( + monkeypatch, caplog, telemetry, env, message +): + for name, value in env.items(): + monkeypatch.setenv(name, value) + config, exporter, _ = telemetry + events = [] - os.environ.update(env) + @asynccontextmanager + async def lifespan(app): + events.append("startup") + yield + events.append("shutdown") - import asyncio + app = FastAPI(telemetry=config, lifespan=lifespan) - from fastapi import FastAPI - from fastapi.exceptions import FastAPIError + @app.get("/health") + async def health(): + return {"status": "ok"} - messages = [] + with TestClient(app) as client: + assert client.get("/health").json() == {"status": "ok"} + assert events == ["startup", "shutdown"] + assert exporter.get_finished_spans() + assert not runtime._owned + assert len(caplog.records) == 1 + assert caplog.records[0].levelno == logging.WARNING + assert message in caplog.messages[0] - async def receive(): - return {"type": "lifespan.startup"} - async def send(message): - messages.append(message) +@pytest.mark.parametrize("stage", ["startup", "shutdown"]) +def test_application_lifespan_errors_still_propagate(monkeypatch, stage): + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "not-a-url") - async def run(): - with pytest.raises(FastAPIError) as exc_info: - await FastAPI()({"type": "lifespan", "state": {}}, receive, send) - assert message in str(exc_info.value) + @asynccontextmanager + async def lifespan(app): + if stage == "startup": + raise RuntimeError("application startup failed") + yield + raise RuntimeError("application shutdown failed") - asyncio.run(run()) - assert len(messages) == 1, messages - assert messages[0]["type"] == "lifespan.startup.failed" + with pytest.raises(RuntimeError, match=f"application {stage} failed"): + with TestClient(FastAPI(lifespan=lifespan)) as client: + assert client.get("/").status_code == 404 @pytest.mark.parametrize( @@ -264,7 +283,7 @@ def test_no_implicit_export(env): @run_in_subprocess -def test_missing_sdk_diagnostic(): +def test_missing_sdk_warns_without_preventing_startup(): import os os.environ.update( @@ -280,14 +299,18 @@ def test_missing_sdk_diagnostic(): raise ImportError("SDK absent") sys.meta_path.insert(0, BlockSDK()) + from unittest import TestCase + from fastapi import FastAPI - from fastapi.exceptions import FastAPIError from fastapi.testclient import TestClient - with pytest.raises(FastAPIError) as exc_info: - with TestClient(FastAPI()): - pass # pragma: no cover - assert "fastapi[opentelemetry]" in str(exc_info.value) + with TestCase().assertLogs("fastapi", level="WARNING") as logs: + with TestClient(FastAPI()) as client: + assert client.get("/").status_code == 404 + assert len(logs.records) == 1 + assert logs.records[0].levelno == logging.WARNING + assert "fastapi[opentelemetry]" in logs.output[0] + assert not runtime._owned @run_in_subprocess @@ -570,17 +593,29 @@ def test_real_otlp_exception_export_without_traces_or_metrics(): @pytest.mark.parametrize("name", ["tracer", "meter", "logger"]) -def test_unsupported_provider_reports_configuration_error(monkeypatch, name): - signal = {"tracer": "TRACES", "meter": "METRICS", "logger": "LOGS"}[name] +def test_unsupported_provider_warns_without_preventing_startup( + monkeypatch, caplog, name +): + from opentelemetry._logs import NoOpLoggerProvider + from opentelemetry.metrics import NoOpMeterProvider + from opentelemetry.trace import NoOpTracerProvider + + signal, provider = { + "tracer": ("TRACES", NoOpTracerProvider), + "meter": ("METRICS", NoOpMeterProvider), + "logger": ("LOGS", NoOpLoggerProvider), + }[name] monkeypatch.setenv(f"OTEL_EXPORTER_OTLP_{signal}_ENDPOINT", "http://127.0.0.1:1") - app = FastAPI(telemetry={f"{name}_provider": object()}) - with pytest.raises(FastAPIError, match="does not support.*auto_configure"): - with TestClient(app): - pass # pragma: no cover + app = FastAPI(telemetry={f"{name}_provider": provider()}) + with TestClient(app) as client: + assert client.get("/").status_code == 404 + assert "does not support adding an OTLP exporter" in caplog.text + assert "auto_configure" in caplog.text + assert not runtime._owned @pytest.mark.parametrize("error_type", [ValueError, AttributeError]) -def test_failed_registration_closes_new_exporter(monkeypatch, error_type): +def test_failed_registration_closes_new_exporter(monkeypatch, caplog, error_type): from opentelemetry.exporter.otlp.proto.http import trace_exporter from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( @@ -606,8 +641,8 @@ def test_failed_registration_closes_new_exporter(monkeypatch, error_type): monkeypatch.setattr(trace_exporter, "OTLPSpanExporter", Exporter) monkeypatch.setenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "http://127.0.0.1:1") app = FastAPI(telemetry={"tracer_provider": provider}) - with pytest.raises(error_type, match="registration failed"): - with TestClient(app): - pass # pragma: no cover + with TestClient(app) as client: + assert client.get("/").status_code == 404 + assert "registration failed" in caplog.text assert stopped == [True] provider.shutdown()