diff --git a/CHANGELOG.md b/CHANGELOG.md index cc2e887..6ca1b22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- **FastAPI 0.142 no longer gets to export telemetry on its own.** 0.142 added native + OpenTelemetry. On lifespan startup it reads `OTEL_EXPORTER_OTLP_ENDPOINT`, which is the variable + this router already uses as its tracing switch, and attaches its own OTLP exporter to the + installed TracerProvider without deduplicating. With tracing on, that doubled every exported + span (span processors 1 → 2). It also installed SDK meter and logger providers and started a + metrics stream to the collector that nothing here configured. `create_app` now passes + `telemetry={"auto_configure": False}`. Before 0.142 that keyword lands in `app.extra` and is + inert, so the declared range is unchanged. A regression test asserts the provider still has + exactly one processor after lifespan startup, and that no SDK meter or logger provider appears. + Without the fix, the test fails on that assertion. + +### Changed +- **fastapi 0.141.1 → 0.142.2** in `uv.lock`, which supersedes Dependabot #31. It brings + `opentelemetry-api` in as a base dependency of fastapi. That's the API only, with no SDK or + exporter. 0.142.1 and 0.142.2 are bug fixes (router endpoint wrapping, and startup when + auto-configuration fails). + ## [0.5.1] — 2026-10-06 Dependency release. No application code changed. diff --git a/src/webhook_doorman/app.py b/src/webhook_doorman/app.py index 88d8a45..8c976a7 100644 --- a/src/webhook_doorman/app.py +++ b/src/webhook_doorman/app.py @@ -629,6 +629,14 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: version=__version__, description="A fail-closed inbound webhook router.", lifespan=lifespan, + # tracing.py owns OpenTelemetry export, and it is the only thing that may. FastAPI + # 0.142 added native telemetry that, on lifespan startup, reads the same + # OTEL_EXPORTER_OTLP_ENDPOINT and attaches its OWN exporter to whatever TracerProvider + # is already installed. It does not deduplicate. Measured: with tracing on, span + # processors went from 1 to 2 (every span exported twice), and an SDK MeterProvider + + # LoggerProvider appeared and began exporting metrics nobody configured. Before 0.142 + # this keyword lands in `app.extra` and does nothing, so it is safe across the range. + telemetry={"auto_configure": False}, ) app.state.resolved = resolved app.state.engine = engine diff --git a/tests/test_tracing.py b/tests/test_tracing.py index 4be2018..583ad22 100644 --- a/tests/test_tracing.py +++ b/tests/test_tracing.py @@ -298,3 +298,73 @@ def test_the_service_name_can_be_overridden(self): ) is True ) + + +def _fastapi_has_native_telemetry() -> bool: + try: + return importlib.util.find_spec("fastapi.telemetry") is not None + except ModuleNotFoundError: + return False + + +@pytest.mark.skipif(not OTEL_INSTALLED, reason="the [otel] extra is not installed") +@pytest.mark.skipif( + not _fastapi_has_native_telemetry(), reason="FastAPI < 0.142 has no native telemetry" +) +class TestFastAPIDoesNotExportOnItsOwn: + """tracing.py is the only exporter. FastAPI >= 0.142 must not add a second one. + + FastAPI 0.142's lifespan reads `OTEL_EXPORTER_OTLP_ENDPOINT` — the variable this router + already uses as its tracing switch — and attaches its own OTLP exporter to the installed + TracerProvider, plus SDK meter and logger providers that export too. Unguarded, that + doubled every span and started a metrics stream to the collector (measured: 1 -> 2 span + processors, `/v1/metrics` POSTs appearing). + + The ENDPOINT is set in the real process environment, because that is what both + `create_app` and FastAPI read; passing `env=` to `tracing.configure` would leave FastAPI + nothing to react to, and this test would pass on the bug. + """ + + @staticmethod + def _reset_global_providers(): + # Each OTel global is one-shot per process; see `trace_one_request` above. FastAPI's own + # registry of providers it has configured is reset too, or a provider from an earlier + # test would be skipped as "already configured" and the assertion would pass vacuously. + import fastapi.telemetry._runtime as fastapi_runtime + from opentelemetry import trace as otel_trace + from opentelemetry._logs import _internal as otel_logs + from opentelemetry.metrics import _internal as otel_metrics + + otel_trace._TRACER_PROVIDER = None + otel_trace._TRACER_PROVIDER_SET_ONCE._done = False + otel_metrics._METER_PROVIDER = None + otel_metrics._METER_PROVIDER_SET_ONCE._done = False + otel_logs._LOGGER_PROVIDER = None + otel_logs._LOGGER_PROVIDER_SET_ONCE._done = False + fastapi_runtime._configured.clear() + fastapi_runtime._owned.clear() + + def test_lifespan_adds_no_exporter_and_no_providers(self, monkeypatch): + from opentelemetry import _logs, metrics, trace + from opentelemetry.sdk._logs import LoggerProvider + from opentelemetry.sdk.metrics import MeterProvider + + self._reset_global_providers() + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", ENDPOINT) + try: + app = build() + assert tracing.enabled() is True, "precondition: create_app turned tracing on" + provider = trace.get_tracer_provider() + assert len(provider._active_span_processor._span_processors) == 1 + + with TestClient(app) as client: # runs lifespan startup, where FastAPI configures + assert post(client).status_code == 200 + + assert trace.get_tracer_provider() is provider + assert len(provider._active_span_processor._span_processors) == 1, ( + "FastAPI attached a second span exporter: every span would be exported twice" + ) + assert not isinstance(metrics.get_meter_provider(), MeterProvider) + assert not isinstance(_logs.get_logger_provider(), LoggerProvider) + finally: + self._reset_global_providers() diff --git a/uv.lock b/uv.lock index 7a21316..fc7a202 100644 --- a/uv.lock +++ b/uv.lock @@ -398,18 +398,19 @@ wheels = [ [[package]] name = "fastapi" -version = "0.141.1" +version = "0.142.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, { name = "starlette" }, { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } +sdist = { url = "https://files.pythonhosted.org/packages/56/4f/f7c30a73127e0a8bbffe788369b8359e530b01ae06e2757936fa35bc5e6d/fastapi-0.142.2.tar.gz", hash = "sha256:06366626f2e70576367714d9ab2fe8472e6c8456dba69b399f9f797ab5e92570", size = 466721, upload-time = "2026-09-30T09:51:11.277Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b6/78aaf9141fb46742928c113f3cf6ef2259d538cb02b604b7656c1dc9883c/fastapi-0.142.2-py3-none-any.whl", hash = "sha256:bd5f4d81f1e93a88bcd77caf4dfe3c2dbffc3805407a0007e9a114c18b3a670b", size = 144409, upload-time = "2026-09-30T09:51:09.8Z" }, ] [[package]]