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/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..532cd62 100644 --- a/docs/integrations/litestar.md +++ b/docs/integrations/litestar.md @@ -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 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/lite_bootstrap/bootstrappers/litestar_bootstrapper.py b/lite_bootstrap/bootstrappers/litestar_bootstrapper.py index 6509e77..ef6c10a 100644 --- a/lite_bootstrap/bootstrappers/litestar_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/litestar_bootstrapper.py @@ -1,7 +1,9 @@ import contextlib import dataclasses import pathlib +import re import typing +import warnings import weakref from lite_bootstrap import import_checker @@ -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 @@ -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]]: @@ -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): @@ -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( @@ -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(), ), ) ) 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..2d0b375 --- /dev/null +++ b/planning/changes/2026-08-10.01-litestar-middleware-logging.md @@ -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-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 `^(?:/|$)`, 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. 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` diff --git a/tests/test_litestar_bootstrap.py b/tests/test_litestar_bootstrap.py index 3d00f72..53a2a8b 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 @@ -9,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 @@ -19,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, @@ -279,3 +285,175 @@ 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"}] + + +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: + 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 + + +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" + + +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) == [] + + +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"] + + +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()) + + +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