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
39 changes: 39 additions & 0 deletions docs/integrations/fastapi.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,42 @@ application = bootstrapper.bootstrap()
```

Read more about available configuration options [here](../introduction/configuration.md).

## Logging

Structlog is configured process-wide, so `structlog.get_logger()` works in any route handler.

FastAPI has no access log of its own, so lite-bootstrap provides one. It is **off by default**,
because uvicorn already writes an access line per request and an HTTP service is the highest-volume
place to add a log record. Turn it on explicitly:

```python
FastAPIConfig(
service_name="microservice",
fastapi_logging_middleware_enabled=True,
)
```

Enabled, it writes one `http_request` line per request to the `http.access` logger, carrying `method`,
`path`, `content_type`, `path_params` and `status_code` under `http`, plus `duration` in nanoseconds.
A request that raises is logged at exception level and the exception is re-raised unchanged.

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

These paths are skipped, whether or not the corresponding instrument is configured: `swagger_path`,
`swagger_static_path` (when `swagger_offline_docs` is on), `health_checks_path` and
`prometheus_metrics_path`. So if you disable health checks but still serve your own route at
`health_checks_path`, that route is not access-logged either.

If you keep uvicorn's own access log as well, you will get two lines per request. To leave only the
structured one, clear uvicorn's handlers:

```python
FastAPIConfig(
service_name="microservice",
fastapi_logging_middleware_enabled=True,
logging_unset_handlers=["uvicorn.access"],
)
```
9 changes: 9 additions & 0 deletions docs/introduction/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,15 @@ Additional parameters for Litestar's access-log middleware:

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

### Structlog FastAPI

FastAPI ships no access log of its own, so lite-bootstrap provides one. It is **off by default**:

- `fastapi_logging_middleware_enabled` - turn on the structured access log (default: `False`).

See [the FastAPI integration guide](../integrations/fastapi.md#logging) for what gets logged and why
it defaults to off.

### Structlog FastStream

When using FastStream, the structlog logger is automatically injected into the broker so that all broker
Expand Down
5 changes: 5 additions & 0 deletions docs/introduction/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ so there is nothing else material hiding in it. That configuration is:

--8<-- "benchmarks/README.md:tuned"

One thing to leave off rather than turn on: the FastAPI access log
([`fastapi_logging_middleware_enabled`](../integrations/fastapi.md#logging)) is off by default, and
turning it on makes every request emit a log record. On a service that otherwise logs nothing per
request, that is the difference between the first row of the logging table above and the rest of it.

lite-bootstrap already applies one saving for you: it passes `sentry_logs_level=None` by default,
because it never enables Sentry Logs and the handler formats every record before checking whether
they are enabled. That one costs nothing, which is why it is a default rather than a knob.
Expand Down
82 changes: 81 additions & 1 deletion lite_bootstrap/bootstrappers/fastapi_bootstrapper.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import contextlib
import dataclasses
import time
import typing

from lite_bootstrap import import_checker
Expand All @@ -22,11 +23,19 @@
from lite_bootstrap.types import UNSET, UnsetType


if typing.TYPE_CHECKING:
from starlette.types import ASGIApp, Message, Receive, Scope, Send

if import_checker.is_fastapi_installed:
import fastapi
from fastapi.middleware.cors import CORSMiddleware
from fastapi.routing import _merge_lifespan_context

if import_checker.is_structlog_installed:
import structlog

fastapi_access_logger: typing.Final = structlog.get_logger("http.access")

if import_checker.is_opentelemetry_installed:
from opentelemetry.trace import get_tracer_provider

Expand Down Expand Up @@ -55,6 +64,7 @@ class FastAPIConfig(
prometheus_instrumentator_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict)
prometheus_instrument_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict)
prometheus_expose_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict)
fastapi_logging_middleware_enabled: bool = False

def __post_init__(self) -> None:
# @dataclass(slots=True) replaces the class object, breaking bare super().
Expand Down Expand Up @@ -83,6 +93,76 @@ def app(self) -> "fastapi.FastAPI":
return self.application


class _AccessLogMiddleware:
"""One structured line per request, pure ASGI."""

def __init__(self, app: "ASGIApp", *, excluded_paths: tuple[str, ...]) -> None:
self.app = app
self.excluded_paths = excluded_paths

def _is_excluded(self, path: str) -> bool:
normalized_path = path.rstrip("/")
return any(
normalized_path == excluded_path or normalized_path.startswith(f"{excluded_path}/")
for excluded_path in self.excluded_paths
)

@staticmethod
def _http_fields(scope: "Scope", status_code: int | None) -> dict[str, typing.Any]:
content_type = ""
for header_name, header_value in scope.get("headers", ()):
if header_name == b"content-type":
content_type = header_value.decode("latin-1")
break
return {
"method": scope.get("method", ""),
"path": scope.get("path", ""),
"content_type": content_type,
"path_params": scope.get("path_params", {}),
"status_code": status_code,
}

async def __call__(self, scope: "Scope", receive: "Receive", send: "Send") -> None:
if scope["type"] != "http" or self._is_excluded(scope["path"]):
await self.app(scope, receive, send)
return

status_code: int | None = None

async def send_wrapper(message: "Message") -> None:
nonlocal status_code
if message["type"] == "http.response.start":
status_code = message["status"]
await send(message)

started_at = time.perf_counter_ns()
try:
await self.app(scope, receive, send_wrapper)
except Exception:
fastapi_access_logger.exception(
"http_request",
http=self._http_fields(scope, status_code),
duration=time.perf_counter_ns() - started_at,
)
raise
fastapi_access_logger.info(
"http_request",
http=self._http_fields(scope, status_code),
duration=time.perf_counter_ns() - started_at,
)


@dataclasses.dataclass(kw_only=True)
class FastAPILoggingInstrument(LoggingInstrument):
bootstrap_config: FastAPIConfig

def bootstrap(self) -> None:
super().bootstrap()
if not self.bootstrap_config.fastapi_logging_middleware_enabled:
return
self.bootstrap_config.app.add_middleware(_AccessLogMiddleware, excluded_paths=self._build_excluded_paths())


@dataclasses.dataclass(kw_only=True, slots=True)
class FastAPICorsInstrument(CorsInstrument):
bootstrap_config: FastAPIConfig
Expand Down Expand Up @@ -174,7 +254,7 @@ class FastAPIBootstrapper(BaseBootstrapper["fastapi.FastAPI"]):
PyroscopeInstrument,
SentryInstrument,
FastAPIHealthChecksInstrument,
LoggingInstrument,
FastAPILoggingInstrument,
FastAPIPrometheusInstrument,
FastAPISwaggerInstrument,
]
Expand Down
15 changes: 1 addition & 14 deletions lite_bootstrap/bootstrappers/litestar_bootstrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,22 +190,9 @@ class LitestarLoggingInstrument(LoggingInstrument):

def _build_logging_middleware_excluded_paths(self) -> list[str]:
"""Regex-escaped path prefixes for infrastructure routes not worth an access log line."""
config = self.bootstrap_config
candidate_paths: typing.Final = (
config.swagger_path,
config.swagger_static_path if config.swagger_offline_docs else "",
config.health_checks_path,
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]
return [rf"^{re.escape(excluded_path)}(?:/|$)" for excluded_path in self._build_excluded_paths()]

def _build_logging_middleware_config(self) -> "LoggingMiddlewareConfig":
# A caller-supplied config replaces the hardened defaults wholesale, no merging.
Expand Down
22 changes: 22 additions & 0 deletions lite_bootstrap/instruments/logging_instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,28 @@ def _unset_handlers(self) -> None:
for unset_handlers_logger in self.bootstrap_config.logging_unset_handlers:
logging.getLogger(unset_handlers_logger).handlers = []

def _build_excluded_paths(self) -> tuple[str, ...]:
"""Infrastructure routes not worth an access log line, normalized and deduplicated.

Sibling paths are read with ``getattr`` because they live on the Swagger, HealthChecks and
Prometheus configs, which a given framework's config need not mix in (see ADR-0002).
"""
config = self.bootstrap_config
offline_docs: typing.Final = getattr(config, "swagger_offline_docs", False)
candidate_paths: typing.Final = (
getattr(config, "swagger_path", ""),
getattr(config, "swagger_static_path", "") if offline_docs else "",
getattr(config, "health_checks_path", ""),
getattr(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 tuple(excluded_paths)

@property
def structlog_processors(self) -> list[typing.Any]:
return [
Expand Down
Loading
Loading