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
3 changes: 2 additions & 1 deletion architecture/config-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ early before `super()` silently blocks the rest of the chain.
`BaseConfig.__post_init__` is a deliberate no-op that **terminates** the
cascade; without it the chain would raise `AttributeError` on `object`.

`FastAPIConfig` uses the explicit `super(FastAPIConfig, self).__post_init__()`
`FastAPIConfig` and `LitestarConfig` use the explicit
`super(FastAPIConfig, self).__post_init__()` / `super(LitestarConfig, self).__post_init__()`
form rather than bare `super()`. Under `@dataclass(slots=True)` the decorator
replaces the class object after the body compiles, which breaks the bare-`super()`
`__class__` cell; the explicit form is required.
8 changes: 7 additions & 1 deletion architecture/instruments.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,13 @@ single `bootstrap_config: ConfigT`. Subclasses implement:
One file per instrument:

- `logging_instrument.py` — structlog setup (`LoggingInstrument`), skipped when
`logging_enabled=False`.
`logging_enabled=False`. The Litestar subclass also owns Litestar's
`LoggingMiddleware`: it is off unless `litestar_logging_middleware_enabled`
is set, and when on it logs request/response metadata only (never bodies,
headers, cookies or query strings) and excludes the swagger, static,
health-check and metrics paths, matched as the path itself or a sub-path. A
caller-supplied `litestar_logging_middleware_config` replaces those defaults
wholesale.
- `opentelemetry_instrument.py` — OTel tracer provider + span export.
- `sentry_instrument.py` — Sentry SDK init, skipped when `sentry_dsn` empty.
- `prometheus_instrument.py` — Prometheus metrics; framework variants wrap it.
Expand Down
41 changes: 41 additions & 0 deletions docs/integrations/litestar.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,47 @@ async def list_items(request: Request) -> list[str]:
return []
```

Litestar's own `LoggingMiddleware` is **off by default** here. Its defaults log
full request and response bodies, which puts credentials and the whole offline
Swagger bundle into your logs. Turn it on explicitly:

```python
LitestarConfig(
service_name="microservice",
litestar_logging_middleware_enabled=True,
)
```

Enabled this way, it logs metadata only — `path`, `method`, `content_type`,
`path_params` for requests and `status_code` for responses — and skips
`swagger_path`, `swagger_static_path` (when `swagger_offline_docs` is on),
`health_checks_path` and `prometheus_metrics_path`. Those four paths are
excluded whether or not the corresponding instrument is actually configured —
so if you disable health checks but still serve your own route at
`health_checks_path`, that route is not access-logged either.

`path` and `path_params` are logged, so a secret embedded in the URL itself
(e.g. `/reset-password/{token}`) is recorded. Keep secrets in the request
body, which is never logged.

To take full control, pass your own config (it replaces the defaults above
entirely, including the path exclusions):

```python
from litestar.middleware.logging import LoggingMiddlewareConfig

LitestarConfig(
service_name="microservice",
litestar_logging_middleware_enabled=True,
litestar_logging_middleware_config=LoggingMiddlewareConfig(request_log_fields=("path", "method", "content_type")),
)
```

A bare `LoggingMiddlewareConfig()` restores Litestar's own defaults wholesale
— including full request/response body logging — so pass explicit
`request_log_fields` / `response_log_fields` rather than relying on the
built-in default.

## Prometheus

`prometheus_group_path` defaults to `True`, so the `path` metric label uses the
Expand Down
7 changes: 7 additions & 0 deletions docs/introduction/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,13 @@ async def handler(request: Request) -> dict[str, str]:
return {"status": "ok"}
```

Additional parameters for Litestar's access-log middleware:

- `litestar_logging_middleware_enabled` - turn on request/response access logging (default: `False`).
- `litestar_logging_middleware_config` - a caller-supplied `LoggingMiddlewareConfig` that replaces the built-in defaults wholesale.

See [the Litestar integration guide](../integrations/litestar.md#logging) for what gets logged and why access logging defaults to off.

### Structlog FastStream

When using FastStream, the structlog logger is automatically injected into the broker so that all broker
Expand Down
54 changes: 54 additions & 0 deletions lite_bootstrap/bootstrappers/litestar_bootstrapper.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import contextlib
import dataclasses
import pathlib
import re
import typing
import warnings
import weakref

from lite_bootstrap import import_checker
Expand Down Expand Up @@ -31,6 +33,7 @@
from litestar.config.app import AppConfig
from litestar.config.cors import CORSConfig
from litestar.logging.config import StructLoggingConfig
from litestar.middleware.logging import LoggingMiddlewareConfig
from litestar.openapi import OpenAPIConfig
from litestar.openapi.plugins import SwaggerRenderPlugin
from litestar.plugins.structlog import StructlogConfig, StructlogPlugin
Expand Down Expand Up @@ -62,6 +65,13 @@ def build_span_name(method: str, route: str) -> str:
return f"{method} {route}"


# Litestar's own defaults include `body`, `headers`, `cookies` and `query`, which leak
# credentials and dump static Swagger assets into the log. `path` is scope["path"],
# so dropping `query` costs only the query string.
_LOGGING_MIDDLEWARE_REQUEST_LOG_FIELDS: typing.Final = ("path", "method", "content_type", "path_params")
_LOGGING_MIDDLEWARE_RESPONSE_LOG_FIELDS: typing.Final = ("status_code",)


def build_litestar_route_details_from_scope(
scope: typing.MutableMapping[str, typing.Any],
) -> tuple[str, dict[str, str]]:
Expand Down Expand Up @@ -123,11 +133,23 @@ class LitestarConfig(
SwaggerConfig,
):
application_config: "AppConfig" = dataclasses.field(default_factory=lambda: AppConfig()) # noqa: PLW0108
litestar_logging_middleware_config: "LoggingMiddlewareConfig | None" = None
litestar_logging_middleware_enabled: bool = False
prometheus_additional_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict)
# Bounds path-label cardinality (Litestar defaults False -> raw URLs leak memory). See litestar#4891.
prometheus_group_path: bool = True
swagger_extra_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict)

def __post_init__(self) -> None:
# @dataclass(slots=True) replaces the class object, breaking bare super().
super(LitestarConfig, self).__post_init__()
if self.litestar_logging_middleware_config is not None and not self.litestar_logging_middleware_enabled:
warnings.warn(
"litestar_logging_middleware_config is ignored while litestar_logging_middleware_enabled is False; "
"set litestar_logging_middleware_enabled=True to turn access logging on.",
stacklevel=2,
)


@dataclasses.dataclass(kw_only=True, slots=True)
class LitestarCorsInstrument(CorsInstrument):
Expand Down Expand Up @@ -169,6 +191,35 @@ def bootstrap(self) -> None:
class LitestarLoggingInstrument(LoggingInstrument):
bootstrap_config: LitestarConfig

def _build_logging_middleware_excluded_paths(self) -> list[str]:
"""Regex-escaped path prefixes for infrastructure routes not worth an access log line."""
candidate_paths: typing.Final = (
self.bootstrap_config.swagger_path,
self.bootstrap_config.swagger_static_path if self.bootstrap_config.swagger_offline_docs else "",
self.bootstrap_config.health_checks_path,
self.bootstrap_config.prometheus_metrics_path,
)
excluded_paths: list[str] = []
for candidate_path in candidate_paths:
# A bare "/" would exclude every route, so it is dropped along with empty values.
normalized_path = candidate_path.rstrip("/")
if normalized_path and normalized_path not in excluded_paths:
excluded_paths.append(normalized_path)
# Litestar matches exclude patterns with an unanchored search, so anchor each one to the
# path itself or a sub-path; a bare prefix would also suppress an unrelated /custom-healthy.
return [rf"^{re.escape(excluded_path)}(?:/|$)" for excluded_path in excluded_paths]

def _build_logging_middleware_config(self) -> "LoggingMiddlewareConfig":
# A caller-supplied config replaces the hardened defaults wholesale, no merging.
if self.bootstrap_config.litestar_logging_middleware_config is not None:
return self.bootstrap_config.litestar_logging_middleware_config
excluded_paths: typing.Final = self._build_logging_middleware_excluded_paths()
return LoggingMiddlewareConfig(
request_log_fields=_LOGGING_MIDDLEWARE_REQUEST_LOG_FIELDS,
response_log_fields=_LOGGING_MIDDLEWARE_RESPONSE_LOG_FIELDS,
exclude=excluded_paths or None,
)

def bootstrap(self) -> None:
self._unset_handlers()
self.bootstrap_config.application_config.plugins.append(
Expand All @@ -182,6 +233,9 @@ def bootstrap(self) -> None:
pretty_print_tty=False,
standard_lib_logging_config=None,
),
# Litestar defaults this to True, which logs full request/response bodies.
enable_middleware_logging=self.bootstrap_config.litestar_logging_middleware_enabled,
middleware_logging_config=self._build_logging_middleware_config(),
),
)
)
Expand Down
154 changes: 154 additions & 0 deletions planning/changes/2026-08-10.01-litestar-middleware-logging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
---
summary: Litestar access logging is now off by default (`enable_middleware_logging=False`); `litestar_logging_middleware_enabled` turns it back on with metadata-only fields (`path`, `method`, `content_type`, `path_params`, `status_code`) and swagger/static/health/metrics exclusions, and `litestar_logging_middleware_config` replaces those defaults wholesale.
---

# Design: Litestar access logging off by default, hardened when on

## Summary

`LitestarLoggingInstrument` builds a `StructlogConfig` with only
`structlog_logging_config` set, so Litestar's own defaults switch on
`LoggingMiddleware` with a `LoggingMiddlewareConfig` that logs full request and
response bodies. Every credential posted to the service and every byte of the
offline Swagger bundle lands in stdout. This change turns middleware logging off
by default (matching every other bootstrapper) and, for services that want
access logs back, supplies a metadata-only config behind an explicit flag.

## Motivation

Reproduced on litestar 2.24.0 with a bootstrapper built from
`LitestarConfig(swagger_offline_docs=True)`:

- `POST /login` with `{"username": "u", "password": "hunter2"}` emits an
`HTTP Request` line whose `body` field contains the password verbatim.
Litestar obfuscates only the `Authorization` / `X-API-KEY` headers and the
`session` cookie; bodies are never obfuscated.
- `GET <swagger_static_path>/swagger-ui.css` emits an `HTTP Response` line
carrying the whole 150 KB stylesheet as `body`. The offline Swagger assets
registered by `LitestarSwaggerInstrument` go through the same ASGI stack, so
`swagger-ui-bundle.js`, `swagger-ui.css` and the favicon are logged as
ordinary response bodies — including the truncated multi-byte sequences that
first surfaced the problem.
- `health_checks_path` and `prometheus_metrics_path` are logged on every k8s
probe and every Prometheus scrape.

None of this is opt-in: it is a side effect of adding `StructlogPlugin`. The
FastAPI, FastStream, FastMCP and Free bootstrappers add no request/response
logging middleware at all, so Litestar is also the odd one out.

## Design

### 1. Config: explicit opt-in plus escape hatch

```python
# LitestarConfig
litestar_logging_middleware_enabled: bool = False
litestar_logging_middleware_config: "LoggingMiddlewareConfig | None" = None
```

The flag is the only enable switch. `litestar_logging_middleware_config`, when
given, replaces the hardened defaults wholesale — no merging, the caller owns
the whole config. Supplying a config while the flag is `False` would be a silent
no-op, so `LitestarConfig.__post_init__` warns, following the precedent set by
`OpenTelemetryConfig.__post_init__`. `LitestarConfig` is `slots=True`, so the
cascade call takes the explicit `super(LitestarConfig, self).__post_init__()`
form, as `FastAPIConfig` already does. `LoggingMiddlewareConfig` joins the
existing `if import_checker.is_litestar_installed:` import block.

### 2. Instrument: pass both remaining `StructlogConfig` fields

```python
# litestar_bootstrapper.py, module level
_LOGGING_MIDDLEWARE_REQUEST_LOG_FIELDS: typing.Final = ("path", "method", "content_type", "path_params")
_LOGGING_MIDDLEWARE_RESPONSE_LOG_FIELDS: typing.Final = ("status_code",)
```

```python
# LitestarLoggingInstrument.bootstrap()
StructlogConfig(
structlog_logging_config=StructLoggingConfig(...), # unchanged
enable_middleware_logging=self.bootstrap_config.litestar_logging_middleware_enabled,
middleware_logging_config=self._build_logging_middleware_config(),
)
```

`_build_logging_middleware_config()` returns the user's config when set,
otherwise `LoggingMiddlewareConfig(request_log_fields=…, response_log_fields=…,
exclude=…)`. No `body`, `headers`, `cookies` or `query`: bodies and headers are
where secrets live, and query strings carry JWTs and API keys often enough to
not be worth the diagnostic value. `path` is `scope["path"]` in Litestar's
`ConnectionDataExtractor`, so dropping `query` loses only the query string.

`_build_logging_middleware_excluded_paths()` collects `swagger_path`,
`swagger_static_path` (only under `swagger_offline_docs`), `health_checks_path`
and `prometheus_metrics_path`, each with the trailing slash stripped and
`re.escape`d into `^<path>(?:/|$)`, skipping empty values and a degenerate `/`.
Litestar matches `exclude` with an unanchored search, so the anchor and the
segment boundary are what keep a lookalike route such as `/custom-healthy` out
of the exclusion. `LitestarLoggingInstrument` is typed on `LitestarConfig`, so
these read directly — no `getattr` fallbacks like the shared
`OpenTelemetryInstrument._build_excluded_urls` needs. Only config values are
read, so the instrument's position in `instruments_types` (before
`LitestarSwaggerInstrument`) does not matter.

`LoggingMiddleware` subclasses `AbstractMiddleware`, whose wrapper calls
`should_bypass_middleware` -> `should_bypass_for_path_pattern`, matching
`exclude` against `scope["path"]` alone (not the route handler's path
template — `AbstractMiddleware.__init_subclass__` is what can emit a
Litestar-3 migration `DeprecationWarning`, and it doesn't here because
`LoggingMiddleware` is itself defined inside Litestar). That still covers the
static assets: `create_static_files_router` builds a plain `Router` with
`@get("{file_path:path}")` / `@head(...)` handlers rather than a mount, so
`scope["path"]` for a request to `/doc/static/swagger-ui.css` is the literal
asset path, and `^/doc/static(?:/|$)` matches it directly.

## Non-goals

- Merging user-supplied `LoggingMiddlewareConfig` with our defaults. Half-owned
config is harder to reason about than either extreme.
- Obfuscation lists, body size caps, or per-route logging controls. Litestar's
own config already exposes them for callers who take the escape hatch.
- The unrelated `request_max_body_size` defect found while reproducing this
(`LitestarConfig.application_config` defaults to a bare `AppConfig()`, whose
`request_max_body_size` is `Empty`, and `Litestar.from_config()` does not apply
the 10 MB default `Litestar(...)` uses, so body-reading handlers 500). Its own
change file.

## Testing

`just test -k litestar_access_logging`, added to
`tests/test_litestar_bootstrap.py`. Neither `capsys` nor `capfd` can observe
this project's structlog output — `_MemoryLoggerFactoryConfig.log_stream` binds
`sys.stdout` at import time, which under pytest is already the global capture
object — so the tests attach their own `logging.Handler` to the `litestar`
logger, inside the `TestClient` context because Litestar's `dictConfig` at
startup drops handlers attached earlier:

- default config, `POST` a body containing a password: no `HTTP Request` /
`HTTP Response` line recorded, and the password string absent.
- flag on: access lines present, with no `body` / `headers` / `cookies` /
`query` keys and no secret value.
- flag on, requests to swagger docs, swagger static, health and metrics: no
access line for any of them.
- flag on, request to `/custom-healthy`: still logged, pinning the anchored
exclude patterns against prefix over-matching.
- flag on plus a caller-supplied `LoggingMiddlewareConfig`: the caller's fields
win, our defaults are not applied.
- config supplied with the flag off: `pytest.warns`.

Then `just lint-ci` and the full `just test`.

## Risk

**Services relying on the current access logs lose them silently.** Likely, low
impact, and the point of the change. Mitigated by the release note and the
docs subsection; re-enabling is one flag.

**A caller's `health_checks_path="/"` (or similar) would exclude everything.**
Unlikely. The degenerate `/` is skipped when building `exclude`, and Litestar
independently warns when a pattern matches all routes.

**Promotion:** `architecture/instruments.md` records the new invariant (Litestar
access logging is off by default and metadata-only when enabled);
`docs/integrations/litestar.md` gains the opt-in subsection. Ships as a minor
release (1.4.0) with an explicit behavior-change note.
Loading