Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions src/webhook_doorman/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
70 changes: 70 additions & 0 deletions tests/test_tracing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
7 changes: 4 additions & 3 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading