From be4a59c63094945748d1724f2f47e5b8f5b652d0 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 10 Aug 2026 16:49:11 +0300 Subject: [PATCH 01/12] docs(planning): design for Litestar access-log body leak fix LitestarLoggingInstrument leaves Litestar's LoggingMiddleware defaults in place, logging full request and response bodies: credentials from API calls and the whole offline Swagger bundle. Design turns middleware logging off by default and adds an opt-in flag with metadata-only, path-excluding defaults. --- ...26-08-10.01-litestar-middleware-logging.md | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 planning/changes/2026-08-10.01-litestar-middleware-logging.md diff --git a/planning/changes/2026-08-10.01-litestar-middleware-logging.md b/planning/changes/2026-08-10.01-litestar-middleware-logging.md new file mode 100644 index 0000000..af87881 --- /dev/null +++ b/planning/changes/2026-08-10.01-litestar-middleware-logging.md @@ -0,0 +1,138 @@ +--- +summary: Stop leaking request/response bodies from the Litestar bootstrapper — set `enable_middleware_logging=False` by default and add `litestar_logging_middleware_enabled` / `litestar_logging_middleware_config` so opting back in yields metadata-only access logs that skip docs, static, health and metrics paths. +--- + +# 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-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 `re.escape`d with the trailing slash +stripped, skipping empty values and a degenerate `/`. `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. + +Litestar 2.24 matches `exclude` with `re.search` against both the request path +and the route handler's paths, so a prefix such as `/doc/static` also matches the +static router's `/doc/static/{file_path:path}` and no Litestar-3 migration +`DeprecationWarning` fires. + +## 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_logging_middleware`, added to +`tests/test_litestar_bootstrap.py`: + +- default config, `POST` a body containing a password: no `HTTP Request` / + `HTTP Response` line in captured stdout, 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 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. From ce0255bd4c7ddb8580e53f3efc9a79942b9e060e Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 10 Aug 2026 17:36:00 +0300 Subject: [PATCH 02/12] fix(litestar): disable request/response body logging by default Litestar's StructlogPlugin defaults enable_middleware_logging to True, so every request body (credentials included) and every response body reached stdout. Set it from the new litestar_logging_middleware_enabled config field, which defaults to False, matching the other bootstrappers. --- .../bootstrappers/litestar_bootstrapper.py | 3 + tests/test_litestar_bootstrap.py | 66 +++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/lite_bootstrap/bootstrappers/litestar_bootstrapper.py b/lite_bootstrap/bootstrappers/litestar_bootstrapper.py index 6509e77..7ccef7b 100644 --- a/lite_bootstrap/bootstrappers/litestar_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/litestar_bootstrapper.py @@ -123,6 +123,7 @@ class LitestarConfig( SwaggerConfig, ): application_config: "AppConfig" = dataclasses.field(default_factory=lambda: AppConfig()) # noqa: PLW0108 + 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 @@ -182,6 +183,8 @@ 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, ), ) ) diff --git a/tests/test_litestar_bootstrap.py b/tests/test_litestar_bootstrap.py index 3d00f72..e679daf 100644 --- a/tests/test_litestar_bootstrap.py +++ b/tests/test_litestar_bootstrap.py @@ -1,6 +1,10 @@ +import contextlib import dataclasses import gc +import json +import logging import sys +import typing import warnings import weakref @@ -279,3 +283,65 @@ def test_litestar_bootstrap_without_prometheus_client() -> None: assert import_checker.is_prometheus_client_installed is False finally: sys.modules.update(saved) + + +class _RecordingHandler(logging.Handler): + def __init__(self) -> None: + super().__init__() + self.lines: list[str] = [] + + def emit(self, record: logging.LogRecord) -> None: + self.lines.append(record.getMessage()) + + +@contextlib.contextmanager +def _recorded_litestar_logs() -> typing.Iterator[list[str]]: + """Record Litestar's rendered log lines. + + Enter this inside the TestClient context: Litestar's StructLoggingConfig runs dictConfig at + startup, which drops handlers attached earlier, and MemoryLoggerFactory sets propagate=False, + so the handler has to sit on the "litestar" logger itself. + """ + handler = _RecordingHandler() + litestar_logger = logging.getLogger("litestar") + litestar_logger.addHandler(handler) + try: + yield handler.lines + finally: + litestar_logger.removeHandler(handler) + + +def _access_log_records(log_lines: list[str]) -> list[dict[str, typing.Any]]: + """Return the LoggingMiddleware lines among the recorded structlog lines.""" + records = [json.loads(log_line) for log_line in log_lines] + return [record for record in records if record.get("event") in {"HTTP Request", "HTTP Response"}] + + +@litestar.post("/login", request_max_body_size=1000) +async def _login_handler(data: dict[str, str]) -> dict[str, str]: + return data + + +def _post_password(config: LitestarConfig) -> list[str]: + """Bootstrap, POST credentials, and return the log lines Litestar emitted for that request.""" + config = dataclasses.replace(config, application_config=AppConfig(route_handlers=[_login_handler])) + application = LitestarBootstrapper(bootstrap_config=config).bootstrap() + with TestClient(app=application) as client, _recorded_litestar_logs() as log_lines: + response = client.post("/login", json={"username": "user", "password": "hunter2"}) + assert response.status_code == status_codes.HTTP_201_CREATED + return log_lines + + +def test_litestar_access_logging_disabled_by_default(litestar_config: LitestarConfig) -> None: + log_lines = _post_password(litestar_config) + + assert _access_log_records(log_lines) == [] + assert not any("hunter2" in log_line for log_line in log_lines) + + +def test_litestar_access_logging_opt_in_emits_access_logs(litestar_config: LitestarConfig) -> None: + log_lines = _post_password(dataclasses.replace(litestar_config, litestar_logging_middleware_enabled=True)) + + events = [record["event"] for record in _access_log_records(log_lines)] + assert "HTTP Request" in events + assert "HTTP Response" in events From dfd6561471ada7179fcdbfc791696e1a9e38d6c6 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 10 Aug 2026 17:41:46 +0300 Subject: [PATCH 03/12] fix(litestar): log only request metadata when access logging is on Replace Litestar's LoggingMiddlewareConfig defaults with path, method, content_type, path_params and status_code. Bodies, headers, cookies and query strings are where credentials live, so none of them are logged. --- .../bootstrappers/litestar_bootstrapper.py | 15 +++++++++++++++ tests/test_litestar_bootstrap.py | 14 ++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/lite_bootstrap/bootstrappers/litestar_bootstrapper.py b/lite_bootstrap/bootstrappers/litestar_bootstrapper.py index 7ccef7b..b2c3704 100644 --- a/lite_bootstrap/bootstrappers/litestar_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/litestar_bootstrapper.py @@ -31,6 +31,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 @@ -62,6 +63,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]]: @@ -170,6 +178,12 @@ def bootstrap(self) -> None: class LitestarLoggingInstrument(LoggingInstrument): bootstrap_config: LitestarConfig + def _build_logging_middleware_config(self) -> "LoggingMiddlewareConfig": + return LoggingMiddlewareConfig( + request_log_fields=_LOGGING_MIDDLEWARE_REQUEST_LOG_FIELDS, + response_log_fields=_LOGGING_MIDDLEWARE_RESPONSE_LOG_FIELDS, + ) + def bootstrap(self) -> None: self._unset_handlers() self.bootstrap_config.application_config.plugins.append( @@ -185,6 +199,7 @@ def bootstrap(self) -> 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(), ), ) ) diff --git a/tests/test_litestar_bootstrap.py b/tests/test_litestar_bootstrap.py index e679daf..3e1de99 100644 --- a/tests/test_litestar_bootstrap.py +++ b/tests/test_litestar_bootstrap.py @@ -345,3 +345,17 @@ def test_litestar_access_logging_opt_in_emits_access_logs(litestar_config: Lites events = [record["event"] for record in _access_log_records(log_lines)] assert "HTTP Request" in events assert "HTTP Response" in events + + +def test_litestar_access_logging_logs_metadata_only(litestar_config: LitestarConfig) -> None: + log_lines = _post_password(dataclasses.replace(litestar_config, litestar_logging_middleware_enabled=True)) + + assert not any("hunter2" in log_line for log_line in log_lines) + records = _access_log_records(log_lines) + assert records + for record in records: + assert not {"body", "headers", "cookies", "query"} & record.keys() + request_records = [record for record in records if record["event"] == "HTTP Request"] + assert request_records + assert request_records[0]["path"] == "/login" + assert request_records[0]["method"] == "POST" From a1376fb3b83e9a49d97f1e487b119295baa81c68 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 10 Aug 2026 17:43:06 +0300 Subject: [PATCH 04/12] docs(planning): fix code-block formatting in the change file --- planning/changes/2026-08-10.01-litestar-middleware-logging.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/planning/changes/2026-08-10.01-litestar-middleware-logging.md b/planning/changes/2026-08-10.01-litestar-middleware-logging.md index af87881..85703b5 100644 --- a/planning/changes/2026-08-10.01-litestar-middleware-logging.md +++ b/planning/changes/2026-08-10.01-litestar-middleware-logging.md @@ -66,7 +66,7 @@ _LOGGING_MIDDLEWARE_RESPONSE_LOG_FIELDS: typing.Final = ("status_code",) ```python # LitestarLoggingInstrument.bootstrap() StructlogConfig( - structlog_logging_config=StructLoggingConfig(...), # unchanged + structlog_logging_config=StructLoggingConfig(...), # unchanged enable_middleware_logging=self.bootstrap_config.litestar_logging_middleware_enabled, middleware_logging_config=self._build_logging_middleware_config(), ) From bf16ef933e901db46ec3b8e5cd8b5a3d028627da Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 10 Aug 2026 17:46:53 +0300 Subject: [PATCH 05/12] fix(litestar): skip access logs for docs, static, health and metrics Swagger assets, k8s probes and Prometheus scrapes drowned out real traffic. Build the middleware exclude list from the paths the config already knows, regex-escaped, skipping empty values and a degenerate root path. --- .../bootstrappers/litestar_bootstrapper.py | 19 +++++++++++++++++++ tests/test_litestar_bootstrap.py | 13 +++++++++++++ 2 files changed, 32 insertions(+) diff --git a/lite_bootstrap/bootstrappers/litestar_bootstrapper.py b/lite_bootstrap/bootstrappers/litestar_bootstrapper.py index b2c3704..54cbc51 100644 --- a/lite_bootstrap/bootstrappers/litestar_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/litestar_bootstrapper.py @@ -1,6 +1,7 @@ import contextlib import dataclasses import pathlib +import re import typing import weakref @@ -178,10 +179,28 @@ 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) + return [re.escape(excluded_path) for excluded_path in excluded_paths] + def _build_logging_middleware_config(self) -> "LoggingMiddlewareConfig": + 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: diff --git a/tests/test_litestar_bootstrap.py b/tests/test_litestar_bootstrap.py index 3e1de99..b45a914 100644 --- a/tests/test_litestar_bootstrap.py +++ b/tests/test_litestar_bootstrap.py @@ -359,3 +359,16 @@ def test_litestar_access_logging_logs_metadata_only(litestar_config: LitestarCon assert request_records assert request_records[0]["path"] == "/login" assert request_records[0]["method"] == "POST" + + +def test_litestar_access_logging_excludes_infrastructure_paths(litestar_config: LitestarConfig) -> None: + config = dataclasses.replace(litestar_config, litestar_logging_middleware_enabled=True) + application = LitestarBootstrapper(bootstrap_config=config).bootstrap() + + with TestClient(app=application) as client, _recorded_litestar_logs() as log_lines: + assert client.get(config.swagger_path).status_code == status_codes.HTTP_200_OK + assert client.get(f"{config.swagger_static_path}/swagger-ui.css").status_code == status_codes.HTTP_200_OK + assert client.get(config.health_checks_path).status_code == status_codes.HTTP_200_OK + assert client.get(config.prometheus_metrics_path).status_code == status_codes.HTTP_200_OK + + assert _access_log_records(log_lines) == [] From e41f2bb415afb1d9a350eeade9db7553a23fd52c Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 10 Aug 2026 17:56:58 +0300 Subject: [PATCH 06/12] docs(planning): anchor exclude patterns in the design Litestar matches middleware exclude patterns with an unanchored search, so a bare escaped prefix would suppress access logs for lookalike routes. --- .../changes/2026-08-10.01-litestar-middleware-logging.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/planning/changes/2026-08-10.01-litestar-middleware-logging.md b/planning/changes/2026-08-10.01-litestar-middleware-logging.md index 85703b5..2faed1f 100644 --- a/planning/changes/2026-08-10.01-litestar-middleware-logging.md +++ b/planning/changes/2026-08-10.01-litestar-middleware-logging.md @@ -81,8 +81,11 @@ not be worth the diagnostic value. `path` is `scope["path"]` in Litestar's `_build_logging_middleware_excluded_paths()` collects `swagger_path`, `swagger_static_path` (only under `swagger_offline_docs`), `health_checks_path` -and `prometheus_metrics_path`, each `re.escape`d with the trailing slash -stripped, skipping empty values and a degenerate `/`. `LitestarLoggingInstrument` +and `prometheus_metrics_path`, each with the trailing slash stripped and +`re.escape`d into `^(?:/|$)`, 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 From c5489e3345f132bdbf50e69cfd9ba6858e3c3073 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 10 Aug 2026 17:58:20 +0300 Subject: [PATCH 07/12] fix(litestar): anchor access-log exclude patterns to path boundaries Litestar matches exclude patterns with an unanchored search, so a bare regex-escaped prefix like /custom-health also matched lookalikes such as /custom-healthy, silently suppressing their access logs. Anchor each pattern to the path itself or a sub-path. --- .../bootstrappers/litestar_bootstrapper.py | 4 +++- tests/test_litestar_bootstrap.py | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/lite_bootstrap/bootstrappers/litestar_bootstrapper.py b/lite_bootstrap/bootstrappers/litestar_bootstrapper.py index 54cbc51..3a6e218 100644 --- a/lite_bootstrap/bootstrappers/litestar_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/litestar_bootstrapper.py @@ -193,7 +193,9 @@ def _build_logging_middleware_excluded_paths(self) -> list[str]: normalized_path = candidate_path.rstrip("/") if normalized_path and normalized_path not in excluded_paths: excluded_paths.append(normalized_path) - return [re.escape(excluded_path) for excluded_path in excluded_paths] + # 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": excluded_paths: typing.Final = self._build_logging_middleware_excluded_paths() diff --git a/tests/test_litestar_bootstrap.py b/tests/test_litestar_bootstrap.py index b45a914..3a50de0 100644 --- a/tests/test_litestar_bootstrap.py +++ b/tests/test_litestar_bootstrap.py @@ -372,3 +372,22 @@ def test_litestar_access_logging_excludes_infrastructure_paths(litestar_config: assert client.get(config.prometheus_metrics_path).status_code == status_codes.HTTP_200_OK assert _access_log_records(log_lines) == [] + + +def test_litestar_access_logging_keeps_lookalike_paths(litestar_config: LitestarConfig) -> None: + @litestar.get("/custom-healthy") + async def lookalike_handler() -> dict[str, str]: + return {"status": "ok"} + + config = dataclasses.replace( + litestar_config, + litestar_logging_middleware_enabled=True, + application_config=AppConfig(route_handlers=[lookalike_handler]), + ) + application = LitestarBootstrapper(bootstrap_config=config).bootstrap() + + with TestClient(app=application) as client, _recorded_litestar_logs() as log_lines: + assert client.get("/custom-healthy").status_code == status_codes.HTTP_200_OK + + request_records = [record for record in _access_log_records(log_lines) if record["event"] == "HTTP Request"] + assert [record["path"] for record in request_records] == ["/custom-healthy"] From 01d8f0f22391af4c8fd9229d30c4f5bfc85f5b6a Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 10 Aug 2026 18:51:08 +0300 Subject: [PATCH 08/12] feat(litestar): allow a caller-supplied LoggingMiddlewareConfig litestar_logging_middleware_config replaces the hardened defaults wholesale for services that need their own access-log shape. Setting it while access logging is off is a silent no-op, so the config warns about it. --- .../bootstrappers/litestar_bootstrapper.py | 15 +++++++++++ tests/test_litestar_bootstrap.py | 25 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/lite_bootstrap/bootstrappers/litestar_bootstrapper.py b/lite_bootstrap/bootstrappers/litestar_bootstrapper.py index 3a6e218..13c0d7b 100644 --- a/lite_bootstrap/bootstrappers/litestar_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/litestar_bootstrapper.py @@ -3,6 +3,7 @@ import pathlib import re import typing +import warnings import weakref from lite_bootstrap import import_checker @@ -133,11 +134,22 @@ class LitestarConfig( ): application_config: "AppConfig" = dataclasses.field(default_factory=lambda: AppConfig()) # noqa: PLW0108 litestar_logging_middleware_enabled: bool = False + litestar_logging_middleware_config: "LoggingMiddlewareConfig | None" = None 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): @@ -198,6 +210,9 @@ def _build_logging_middleware_excluded_paths(self) -> list[str]: 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, diff --git a/tests/test_litestar_bootstrap.py b/tests/test_litestar_bootstrap.py index 3a50de0..a068ea4 100644 --- a/tests/test_litestar_bootstrap.py +++ b/tests/test_litestar_bootstrap.py @@ -13,6 +13,7 @@ import structlog from litestar import status_codes from litestar.config.app import AppConfig +from litestar.middleware.logging import LoggingMiddlewareConfig from litestar.params import FromPath from litestar.testing import TestClient from opentelemetry.sdk.trace import TracerProvider @@ -391,3 +392,27 @@ async def lookalike_handler() -> dict[str, str]: request_records = [record for record in _access_log_records(log_lines) if record["event"] == "HTTP Request"] assert [record["path"] for record in request_records] == ["/custom-healthy"] + + +def test_litestar_access_logging_custom_config_replaces_defaults(litestar_config: LitestarConfig) -> None: + custom_config = LoggingMiddlewareConfig( + request_log_fields=("path", "query"), + response_log_fields=("status_code",), + ) + log_lines = _post_password( + dataclasses.replace( + litestar_config, + litestar_logging_middleware_enabled=True, + litestar_logging_middleware_config=custom_config, + ) + ) + + request_records = [record for record in _access_log_records(log_lines) if record["event"] == "HTTP Request"] + assert request_records + assert "query" in request_records[0] + assert "method" not in request_records[0] + + +def test_litestar_logging_middleware_config_without_flag_warns(litestar_config: LitestarConfig) -> None: + with pytest.warns(UserWarning, match="litestar_logging_middleware_enabled"): + dataclasses.replace(litestar_config, litestar_logging_middleware_config=LoggingMiddlewareConfig()) From dd3818872f4e6352611aeabb060528f18009f81b Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 10 Aug 2026 18:55:52 +0300 Subject: [PATCH 09/12] docs(litestar): document access-logging opt-in and promote invariant --- architecture/instruments.md | 8 ++++- docs/integrations/litestar.md | 29 +++++++++++++++++++ ...26-08-10.01-litestar-middleware-logging.md | 2 +- 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/architecture/instruments.md b/architecture/instruments.md index 02b42fb..e2c4a28 100644 --- a/architecture/instruments.md +++ b/architecture/instruments.md @@ -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. diff --git a/docs/integrations/litestar.md b/docs/integrations/litestar.md index 25a8bf6..46064e4 100644 --- a/docs/integrations/litestar.md +++ b/docs/integrations/litestar.md @@ -61,6 +61,35 @@ 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 the +Swagger docs, the offline static assets, the health-check path and the metrics +path. + +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", "headers")), +) +``` + ## Prometheus `prometheus_group_path` defaults to `True`, so the `path` metric label uses the diff --git a/planning/changes/2026-08-10.01-litestar-middleware-logging.md b/planning/changes/2026-08-10.01-litestar-middleware-logging.md index 2faed1f..024b5d4 100644 --- a/planning/changes/2026-08-10.01-litestar-middleware-logging.md +++ b/planning/changes/2026-08-10.01-litestar-middleware-logging.md @@ -1,5 +1,5 @@ --- -summary: Stop leaking request/response bodies from the Litestar bootstrapper — set `enable_middleware_logging=False` by default and add `litestar_logging_middleware_enabled` / `litestar_logging_middleware_config` so opting back in yields metadata-only access logs that skip docs, static, health and metrics paths. +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 From 22b81d6b7a3c907b3bbd8139f8123f608456bd96 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 10 Aug 2026 18:57:06 +0300 Subject: [PATCH 10/12] docs(planning): align the change file with the shipped tests Record the recorded-log test strategy, the anchored exclude patterns and the lookalike-route regression test as realized. --- ...26-08-10.01-litestar-middleware-logging.md | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/planning/changes/2026-08-10.01-litestar-middleware-logging.md b/planning/changes/2026-08-10.01-litestar-middleware-logging.md index 024b5d4..299e029 100644 --- a/planning/changes/2026-08-10.01-litestar-middleware-logging.md +++ b/planning/changes/2026-08-10.01-litestar-middleware-logging.md @@ -85,16 +85,16 @@ and `prometheus_metrics_path`, each with the trailing slash stripped and `re.escape`d into `^(?:/|$)`, 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 +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. -Litestar 2.24 matches `exclude` with `re.search` against both the request path -and the route handler's paths, so a prefix such as `/doc/static` also matches the -static router's `/doc/static/{file_path:path}` and no Litestar-3 migration -`DeprecationWarning` fires. +Litestar 2.24 matches `exclude` against both the request path and the route +handler's paths, so `^/doc/static(?:/|$)` also matches the static router's +`/doc/static/{file_path:path}` and no Litestar-3 migration `DeprecationWarning` +fires. ## Non-goals @@ -110,15 +110,22 @@ static router's `/doc/static/{file_path:path}` and no Litestar-3 migration ## Testing -`just test -- -k litestar_logging_middleware`, added to -`tests/test_litestar_bootstrap.py`: +`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 in captured stdout, and the password string absent. + `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`. From 10392be7e6d21c19ffc0f8d39652db28a23b8238 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 10 Aug 2026 19:12:54 +0300 Subject: [PATCH 11/12] docs(litestar): add 1.4.0 release notes and fill documentation gaps Add planning/releases/1.4.0.md with a prominent behavior-change notice for the Litestar access-logging default flip (CI requires curated notes for a stable tag). Document the two new LitestarConfig options in the configuration reference, expand the litestar.md access-logging subsection with the excluded-path list, the path/path_params caveat, and a non-header escape-hatch example, correct the exclude-matching mechanism described in the design change file against the installed Litestar source, and note that LitestarConfig now also needs the explicit super() form under slots=True. --- architecture/config-model.md | 3 +- docs/integrations/litestar.md | 20 ++++- docs/introduction/configuration.md | 7 ++ ...26-08-10.01-litestar-middleware-logging.md | 14 ++- planning/releases/1.4.0.md | 86 +++++++++++++++++++ 5 files changed, 121 insertions(+), 9 deletions(-) create mode 100644 planning/releases/1.4.0.md diff --git a/architecture/config-model.md b/architecture/config-model.md index 6c55566..eac9854 100644 --- a/architecture/config-model.md +++ b/architecture/config-model.md @@ -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. diff --git a/docs/integrations/litestar.md b/docs/integrations/litestar.md index 46064e4..532cd62 100644 --- a/docs/integrations/litestar.md +++ b/docs/integrations/litestar.md @@ -73,9 +73,16 @@ LitestarConfig( ``` Enabled this way, it logs metadata only — `path`, `method`, `content_type`, -`path_params` for requests and `status_code` for responses — and skips the -Swagger docs, the offline static assets, the health-check path and the metrics -path. +`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): @@ -86,10 +93,15 @@ 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", "headers")), + 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 diff --git a/docs/introduction/configuration.md b/docs/introduction/configuration.md index 7e100c6..a456f47 100644 --- a/docs/introduction/configuration.md +++ b/docs/introduction/configuration.md @@ -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 diff --git a/planning/changes/2026-08-10.01-litestar-middleware-logging.md b/planning/changes/2026-08-10.01-litestar-middleware-logging.md index 299e029..2d0b375 100644 --- a/planning/changes/2026-08-10.01-litestar-middleware-logging.md +++ b/planning/changes/2026-08-10.01-litestar-middleware-logging.md @@ -91,10 +91,16 @@ these read directly — no `getattr` fallbacks like the shared read, so the instrument's position in `instruments_types` (before `LitestarSwaggerInstrument`) does not matter. -Litestar 2.24 matches `exclude` against both the request path and the route -handler's paths, so `^/doc/static(?:/|$)` also matches the static router's -`/doc/static/{file_path:path}` and no Litestar-3 migration `DeprecationWarning` -fires. +`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 diff --git a/planning/releases/1.4.0.md b/planning/releases/1.4.0.md new file mode 100644 index 0000000..d20c615 --- /dev/null +++ b/planning/releases/1.4.0.md @@ -0,0 +1,86 @@ +# lite-bootstrap 1.4.0 — Litestar access logging off by default + +**1.4.0 is a minor release with a behavior change for Litestar services.** + +## Behavior change + +**Litestar's `LoggingMiddleware` no longer logs requests and responses by +default.** If your service is on Litestar and you rely on the `HTTP Request` +/ `HTTP Response` access log lines it used to emit, they stop appearing after +this upgrade until you opt back in: + +```python +LitestarConfig( + service_name="microservice", + litestar_logging_middleware_enabled=True, +) +``` + +### Why + +`LitestarLoggingInstrument` registers Litestar's `StructlogPlugin`, and +Litestar's own default `LoggingMiddlewareConfig` logs full request and +response bodies. That meant: + +- Any credential posted to the service — a login form's password, an API + key in a JSON body — landed in stdout verbatim. Litestar only obfuscates + the `Authorization` / `X-API-KEY` headers and the `session` cookie; request + and response bodies are never obfuscated. +- Every offline Swagger asset served by `swagger_offline_docs=True` + (`swagger-ui-bundle.js`, `swagger-ui.css`, up to ~150 KB) was logged as an + ordinary response body on every request. +- Every k8s health probe and every Prometheus scrape produced its own log + line, unconditionally. + +None of this was opt-in — it was a side effect of adding the plugin. Every +other bootstrapper (FastAPI, FastStream, FastMCP, Free) adds no +request/response logging middleware at all, so this brings Litestar in line +with the rest. + +### What the opt-in logs + +With `litestar_logging_middleware_enabled=True`, access logs are metadata +only: + +- Requests: `path`, `method`, `content_type`, `path_params`. +- Responses: `status_code`. + +No `body`, `headers`, `cookies`, or `query` — bodies and headers are where +secrets live, and query strings carry tokens often enough to not be worth the +diagnostic value. Note that `path` and `path_params` are still logged, so a +secret embedded in the URL itself (e.g. `/reset-password/{token}`) is +recorded; keep secrets in the body, never in the path. + +The opt-in also excludes infrastructure routes from access logs: the Swagger +docs path, the offline Swagger static assets (when `swagger_offline_docs` is +on), the health-check path, and the Prometheus metrics path — each matched +whether or not the corresponding instrument is actually active, so a service +that disables health checks but serves its own route at the same path is +still excluded there. + +### Escape hatch + +To take full control — including restoring Litestar's original body-logging +defaults — pass your own `LoggingMiddlewareConfig` via +`litestar_logging_middleware_config`. It replaces the hardened defaults +above wholesale, with no merging: + +```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"), + ), +) +``` + +Supplying `litestar_logging_middleware_config` while +`litestar_logging_middleware_enabled` is `False` is a no-op that emits a +warning — set the flag to actually turn logging on. + +## References + +- `planning/changes/2026-08-10.01-litestar-middleware-logging.md` From d9c5e597c06a1b45a4423c13a9075ea57e92713a Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 10 Aug 2026 19:13:18 +0300 Subject: [PATCH 12/12] test(litestar): fix field ordering and test hygiene for access logging Reorder litestar_logging_middleware_config/enabled to keep LitestarConfig's fields alphabetical, move the shared _login_handler test route into _post_password so each app that uses it gets its own handler instance instead of sharing cached signature/state, and add a unit test pinning _build_logging_middleware_excluded_paths' degenerate-path guard (empty values, a bare "/", duplicates) and the exclude=None collapse when nothing survives. --- .../bootstrappers/litestar_bootstrapper.py | 2 +- tests/test_litestar_bootstrap.py | 51 +++++++++++++++++-- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/lite_bootstrap/bootstrappers/litestar_bootstrapper.py b/lite_bootstrap/bootstrappers/litestar_bootstrapper.py index 13c0d7b..ef6c10a 100644 --- a/lite_bootstrap/bootstrappers/litestar_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/litestar_bootstrapper.py @@ -133,8 +133,8 @@ class LitestarConfig( SwaggerConfig, ): application_config: "AppConfig" = dataclasses.field(default_factory=lambda: AppConfig()) # noqa: PLW0108 - litestar_logging_middleware_enabled: bool = False 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 diff --git a/tests/test_litestar_bootstrap.py b/tests/test_litestar_bootstrap.py index a068ea4..53a2a8b 100644 --- a/tests/test_litestar_bootstrap.py +++ b/tests/test_litestar_bootstrap.py @@ -24,6 +24,7 @@ from lite_bootstrap import LitestarBootstrapper, LitestarConfig, import_checker from lite_bootstrap.bootstrappers.litestar_bootstrapper import ( + LitestarLoggingInstrument, LitestarOpenTelemetryInstrumentationMiddleware, build_litestar_route_details_from_scope, build_span_name, @@ -318,13 +319,13 @@ def _access_log_records(log_lines: list[str]) -> list[dict[str, typing.Any]]: return [record for record in records if record.get("event") in {"HTTP Request", "HTTP Response"}] -@litestar.post("/login", request_max_body_size=1000) -async def _login_handler(data: dict[str, str]) -> dict[str, str]: - return data - - def _post_password(config: LitestarConfig) -> list[str]: """Bootstrap, POST credentials, and return the log lines Litestar emitted for that request.""" + + @litestar.post("/login", request_max_body_size=1000) + async def _login_handler(data: dict[str, str]) -> dict[str, str]: + return data + config = dataclasses.replace(config, application_config=AppConfig(route_handlers=[_login_handler])) application = LitestarBootstrapper(bootstrap_config=config).bootstrap() with TestClient(app=application) as client, _recorded_litestar_logs() as log_lines: @@ -416,3 +417,43 @@ def test_litestar_access_logging_custom_config_replaces_defaults(litestar_config def test_litestar_logging_middleware_config_without_flag_warns(litestar_config: LitestarConfig) -> None: with pytest.warns(UserWarning, match="litestar_logging_middleware_enabled"): dataclasses.replace(litestar_config, litestar_logging_middleware_config=LoggingMiddlewareConfig()) + + +def test_litestar_access_logging_excluded_paths_drops_degenerate_and_duplicates( + litestar_config: LitestarConfig, +) -> None: + # swagger_path is empty (dropped), swagger_static_path is a bare "/" (degenerate, dropped even + # though swagger_offline_docs is on), and prometheus_metrics_path duplicates health_checks_path + # once both are stripped of trailing slashes. + config = dataclasses.replace( + litestar_config, + swagger_path="", + swagger_offline_docs=True, + swagger_static_path="/", + health_checks_path="/api/", + prometheus_metrics_path="/api", + ) + instrument = LitestarLoggingInstrument(bootstrap_config=config) + + excluded_paths = instrument._build_logging_middleware_excluded_paths() # noqa: SLF001 + + assert excluded_paths == [r"^/api(?:/|$)"] + middleware_config = instrument._build_logging_middleware_config() # noqa: SLF001 + assert middleware_config.exclude == excluded_paths + + +def test_litestar_access_logging_excluded_paths_none_when_all_degenerate( + litestar_config: LitestarConfig, +) -> None: + config = dataclasses.replace( + litestar_config, + swagger_path="", + swagger_offline_docs=True, + swagger_static_path="/", + health_checks_path="/", + prometheus_metrics_path="", + ) + instrument = LitestarLoggingInstrument(bootstrap_config=config) + + assert instrument._build_logging_middleware_excluded_paths() == [] # noqa: SLF001 + assert instrument._build_logging_middleware_config().exclude is None # noqa: SLF001