Skip to content

Commit 6ed2cbe

Browse files
committed
feat: implement structured logging
1 parent 436565d commit 6ed2cbe

3 files changed

Lines changed: 75 additions & 6 deletions

File tree

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,15 @@ ACCESS_TOKEN_EXPIRE_MINUTES=30
1616
REFRESH_TOKEN_EXPIRE_MINUTES=10080
1717

1818
RATE_LIMIT="100/minute"
19+
1920
CORS_ALLOW_ORIGINS=http://localhost:3000
2021
CORS_ALLOW_METHODS=*
2122
CORS_ALLOW_HEADERS=*
23+
2224
SECURITY_CONTENT_SECURITY_POLICY=default-src 'self'; frame-ancestors 'none'
25+
2326
IDEMPOTENCY_TTL_SECONDS=86400
27+
2428
ACCOUNT_LOCKOUT_MAX_ATTEMPTS=5
2529
ACCOUNT_LOCKOUT_WINDOW_MINUTES=15
2630
ACCOUNT_LOCKOUT_DURATION_MINUTES=15

src/core/middleware/structured_logging.py

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,48 @@
1010
class StructuredLoggingMiddleware(BaseHTTPMiddleware):
1111
async def dispatch(self, request: Request, call_next):
1212
started_at = time.perf_counter()
13-
response = await call_next(request)
14-
latency_ms = round((time.perf_counter() - started_at) * 1000, 2)
13+
try:
14+
response = await call_next(request)
15+
except Exception as exc:
16+
self._log_request(
17+
request=request,
18+
started_at=started_at,
19+
status_code=500,
20+
level=logging.ERROR,
21+
message="request failed",
22+
error_type=type(exc).__name__,
23+
)
24+
raise
25+
26+
self._log_request(
27+
request=request,
28+
started_at=started_at,
29+
status_code=response.status_code,
30+
level=logging.INFO,
31+
message="request completed",
32+
)
33+
return response
1534

16-
logger.info(
17-
"request completed",
35+
@staticmethod
36+
def _log_request(
37+
request: Request,
38+
started_at: float,
39+
status_code: int,
40+
level: int,
41+
message: str,
42+
error_type: str | None = None,
43+
) -> None:
44+
latency_ms = round((time.perf_counter() - started_at) * 1000, 2)
45+
logger.log(
46+
level,
47+
message,
1848
extra={
1949
"method": request.method,
2050
"path": request.url.path,
21-
"status_code": response.status_code,
51+
"status_code": status_code,
2252
"latency_ms": latency_ms,
2353
"request_id": getattr(request.state, "request_id", None),
2454
"user_id": getattr(request.state, "user_id", None),
55+
"error_type": error_type,
2556
},
2657
)
27-
return response

tests/core/test_security_not_implemented.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,41 @@ async def call_next(_request):
107107
assert record.user_id == "user-123"
108108

109109

110+
def test_structured_logging_middleware_logs_exception_context(caplog):
111+
async def run():
112+
caplog.set_level(logging.ERROR, logger="src.core.middleware.structured_logging")
113+
request = Request(
114+
{
115+
"type": "http",
116+
"method": "POST",
117+
"path": "/api/v1/todos/",
118+
"headers": [],
119+
"query_string": b"",
120+
"server": ("testserver", 80),
121+
"scheme": "http",
122+
"client": ("testclient", 50000),
123+
}
124+
)
125+
request.state.request_id = "request-456"
126+
request.state.user_id = "user-456"
127+
128+
async def call_next(_request):
129+
raise RuntimeError("write failed")
130+
131+
with pytest.raises(RuntimeError, match="write failed"):
132+
await StructuredLoggingMiddleware(None).dispatch(request, call_next)
133+
134+
asyncio.run(run())
135+
136+
record = caplog.records[0]
137+
assert record.method == "POST"
138+
assert record.path == "/api/v1/todos/"
139+
assert record.status_code == 500
140+
assert record.request_id == "request-456"
141+
assert record.user_id == "user-456"
142+
assert record.error_type == "RuntimeError"
143+
144+
110145
def test_admin_router_exposes_liveness_and_readiness():
111146
app = FastAPI()
112147
register_admin_router(app)

0 commit comments

Comments
 (0)