Skip to content

feat: add an opt-in structured access log to FastAPI - #241

Merged
lesnik512 merged 3 commits into
mainfrom
feat/180-fastapi-access-log
Sep 20, 2026
Merged

lesnik512 merged 3 commits into
mainfrom
feat/180-fastapi-access-log

Conversation

@lesnik512

@lesnik512 lesnik512 commented Sep 20, 2026

Copy link
Copy Markdown
Member

Closes #180.

FastAPI was the only supported framework whose logging instrument bound nothing framework-specific:
instruments_types listed the base LoggingInstrument, while Litestar, FastStream and FastMCP each
ship a subclass. This fills that cell with FastAPILoggingInstrument, which installs an access-log
middleware when asked.

fastapi_logging_middleware_enabled defaults to False, matching litestar_logging_middleware_enabled.
FastMCP is currently the outlier and is being brought into line in #240.

Why off by default

The issue says "a FastAPI service gets structlog configured process-wide and no structured access
log, while a Litestar service gets both". A Litestar service gets an access log only if it sets
litestar_logging_middleware_enabled=True, so parity means shipping an opt-in one.

Two reasons beyond parity:

  • uvicorn already writes an access line per request, and logging_unset_handlers defaults to
    empty. On by default would hand every FastAPI service two lines per request, one structured and one
    not. The docs show logging_unset_handlers=["uvicorn.access"] for anyone who wants only ours.
  • HTTP request rates are the highest of any supported framework. LoggingInstrument measures at
    +0.1 µs/request while nothing logs; an always-on access log makes every request log. A default that
    multiplies log volume and per-request cost on upgrade is not one a bootstrapper should pick.

What it logs

One http_request line to the http.access logger: method, path, content_type, path_params
and status_code under http, plus duration in nanoseconds. Litestar's hardened field set plus the
duration FastMCP logs. A request that raises logs at exception level and re-raises unchanged, with
status_code: None, because the middleware sits inside ServerErrorMiddleware and sees the raise
before anything turns it into a 500.

Bodies are never read. That is the defect Litestar's own middleware shipped (54c8ad9), and #180 asks
for it to be pinned rather than asserted in prose, so the invariant test pins the field set: adding
headers, cookies or a body later fails the test rather than passing a secret-absence check.

Pure ASGI, and a correction

The middleware is pure ASGI rather than BaseHTTPMiddleware. My first draft justified that with the
usual claim that BaseHTTPMiddleware breaks streaming responses and background tasks. That is
false
on the declared starlette range: I checked at the 0.37.2 floor and at 1.6.0, and both work.

The real reason is cost. BaseHTTPMiddleware builds a task group and a pair of memory object streams
per request, measured at over +150 µs per request in the benchmarks' own in-process harness,
against a pure-ASGI wrapper that stays within noise of no middleware at all. That is more than the
entire OpenTelemetry instrument, for a middleware that needs only the status code and the scope.
(Run-to-run variance was wide, +154 to +303 µs, so that is a floor rather than a figure.)

Shared path policy

_build_excluded_paths is hoisted onto LoggingInstrument rather than copied. My first version was
line-for-line identical to Litestar's; ADR-0002's warning is against spreading policy across sibling
configs, while hoisting into the base instrument is what it prescribes ("base instruments own the
hoisted logic"). The base reads sibling paths through getattr, the defensive read that ADR already
blesses for _build_excluded_urls, and Litestar keeps only its re.escape anchoring. A test pins
every sibling path in the built set, which is the rename guard ADR-0002 asks for.

Testing

Ten tests. Reverting only fastapi_bootstrapper.py to main breaks nine of them.

They assert against a patched fastapi_access_logger rather than captured stdout or
logging_extra_processors. Neither of those is sound here: _configure_foreign_loggers
(logging_instrument.py:181) registers logging_extra_processors a second time inside the root
handler's ProcessorFormatter, so a capture sees either the event dict or the already-rendered JSON
string depending on what an earlier bootstrap left in structlog's global configuration. Three tests
passed alone and failed in a group until that was understood; the reason is in a docstring so it is
not rediscovered.

Covered: off by default, the full field set when enabled, the no-bodies invariant, all four excluded
paths, a lookalike path (/custom-healthy) that must still be logged, every sibling path reaching the
exclusion set, and the raising request.

319 tests, ruff, ty, mkdocs build --strict and lychee all clean.

Beyond the issue text

The config and integration docs are required by a new public field. The paragraph added to the
Performance page is the most arguable: that page exists to say what each instrument costs, and this
adds an opt-in per-request cost, so leaving it out would make the page incomplete.

No ADR: one was drafted and dropped on review. The reasoning for the default and for the pure-ASGI
choice lives here instead.

@lesnik512
lesnik512 merged commit a18f6f0 into main Sep 20, 2026
13 checks passed
@lesnik512
lesnik512 deleted the feat/180-fastapi-access-log branch September 20, 2026 12:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FastAPI has no structured access log

1 participant