|
1 | 1 | import contextlib |
2 | 2 | import dataclasses |
| 3 | +import time |
3 | 4 | import typing |
4 | 5 |
|
5 | 6 | from lite_bootstrap import import_checker |
|
22 | 23 | from lite_bootstrap.types import UNSET, UnsetType |
23 | 24 |
|
24 | 25 |
|
| 26 | +if typing.TYPE_CHECKING: |
| 27 | + from starlette.types import ASGIApp, Message, Receive, Scope, Send |
| 28 | + |
25 | 29 | if import_checker.is_fastapi_installed: |
26 | 30 | import fastapi |
27 | 31 | from fastapi.middleware.cors import CORSMiddleware |
28 | 32 | from fastapi.routing import _merge_lifespan_context |
29 | 33 |
|
| 34 | +if import_checker.is_structlog_installed: |
| 35 | + import structlog |
| 36 | + |
| 37 | + fastapi_access_logger: typing.Final = structlog.get_logger("http.access") |
| 38 | + |
30 | 39 | if import_checker.is_opentelemetry_installed: |
31 | 40 | from opentelemetry.trace import get_tracer_provider |
32 | 41 |
|
@@ -55,6 +64,7 @@ class FastAPIConfig( |
55 | 64 | prometheus_instrumentator_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) |
56 | 65 | prometheus_instrument_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) |
57 | 66 | prometheus_expose_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) |
| 67 | + fastapi_logging_middleware_enabled: bool = False |
58 | 68 |
|
59 | 69 | def __post_init__(self) -> None: |
60 | 70 | # @dataclass(slots=True) replaces the class object, breaking bare super(). |
@@ -83,6 +93,76 @@ def app(self) -> "fastapi.FastAPI": |
83 | 93 | return self.application |
84 | 94 |
|
85 | 95 |
|
| 96 | +class _AccessLogMiddleware: |
| 97 | + """One structured line per request, pure ASGI.""" |
| 98 | + |
| 99 | + def __init__(self, app: "ASGIApp", *, excluded_paths: tuple[str, ...]) -> None: |
| 100 | + self.app = app |
| 101 | + self.excluded_paths = excluded_paths |
| 102 | + |
| 103 | + def _is_excluded(self, path: str) -> bool: |
| 104 | + normalized_path = path.rstrip("/") |
| 105 | + return any( |
| 106 | + normalized_path == excluded_path or normalized_path.startswith(f"{excluded_path}/") |
| 107 | + for excluded_path in self.excluded_paths |
| 108 | + ) |
| 109 | + |
| 110 | + @staticmethod |
| 111 | + def _http_fields(scope: "Scope", status_code: int | None) -> dict[str, typing.Any]: |
| 112 | + content_type = "" |
| 113 | + for header_name, header_value in scope.get("headers", ()): |
| 114 | + if header_name == b"content-type": |
| 115 | + content_type = header_value.decode("latin-1") |
| 116 | + break |
| 117 | + return { |
| 118 | + "method": scope.get("method", ""), |
| 119 | + "path": scope.get("path", ""), |
| 120 | + "content_type": content_type, |
| 121 | + "path_params": scope.get("path_params", {}), |
| 122 | + "status_code": status_code, |
| 123 | + } |
| 124 | + |
| 125 | + async def __call__(self, scope: "Scope", receive: "Receive", send: "Send") -> None: |
| 126 | + if scope["type"] != "http" or self._is_excluded(scope["path"]): |
| 127 | + await self.app(scope, receive, send) |
| 128 | + return |
| 129 | + |
| 130 | + status_code: int | None = None |
| 131 | + |
| 132 | + async def send_wrapper(message: "Message") -> None: |
| 133 | + nonlocal status_code |
| 134 | + if message["type"] == "http.response.start": |
| 135 | + status_code = message["status"] |
| 136 | + await send(message) |
| 137 | + |
| 138 | + started_at = time.perf_counter_ns() |
| 139 | + try: |
| 140 | + await self.app(scope, receive, send_wrapper) |
| 141 | + except Exception: |
| 142 | + fastapi_access_logger.exception( |
| 143 | + "http_request", |
| 144 | + http=self._http_fields(scope, status_code), |
| 145 | + duration=time.perf_counter_ns() - started_at, |
| 146 | + ) |
| 147 | + raise |
| 148 | + fastapi_access_logger.info( |
| 149 | + "http_request", |
| 150 | + http=self._http_fields(scope, status_code), |
| 151 | + duration=time.perf_counter_ns() - started_at, |
| 152 | + ) |
| 153 | + |
| 154 | + |
| 155 | +@dataclasses.dataclass(kw_only=True) |
| 156 | +class FastAPILoggingInstrument(LoggingInstrument): |
| 157 | + bootstrap_config: FastAPIConfig |
| 158 | + |
| 159 | + def bootstrap(self) -> None: |
| 160 | + super().bootstrap() |
| 161 | + if not self.bootstrap_config.fastapi_logging_middleware_enabled: |
| 162 | + return |
| 163 | + self.bootstrap_config.app.add_middleware(_AccessLogMiddleware, excluded_paths=self._build_excluded_paths()) |
| 164 | + |
| 165 | + |
86 | 166 | @dataclasses.dataclass(kw_only=True, slots=True) |
87 | 167 | class FastAPICorsInstrument(CorsInstrument): |
88 | 168 | bootstrap_config: FastAPIConfig |
@@ -174,7 +254,7 @@ class FastAPIBootstrapper(BaseBootstrapper["fastapi.FastAPI"]): |
174 | 254 | PyroscopeInstrument, |
175 | 255 | SentryInstrument, |
176 | 256 | FastAPIHealthChecksInstrument, |
177 | | - LoggingInstrument, |
| 257 | + FastAPILoggingInstrument, |
178 | 258 | FastAPIPrometheusInstrument, |
179 | 259 | FastAPISwaggerInstrument, |
180 | 260 | ] |
|
0 commit comments