Coverage for src/lexigram/web/middleware/access_log.py: 28%
40 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
1"""Access log middleware for HTTP request/response logging.
3Logs every HTTP request with structured context: method, path, status code,
4duration, and request ID. Uses pure ASGI implementation for minimal overhead.
6Example output (dev mode):
7 2026-01-15 10:23:15 [info] request_completed method=GET path=/api/users
8 status=200 duration_ms=12.3 request_id=req_a1b2c3d4
10Example output (JSON mode):
11 {"event": "request_completed", "method": "GET", "path": "/api/users",
12 "status": 200, "duration_ms": 12.3, "request_id": "req_a1b2c3d4", ...}
13"""
15from __future__ import annotations
17from typing import TYPE_CHECKING, Any, cast
19from lexigram.logging import get_logger
20from lexigram.primitives import clock as ambient_clock
21from lexigram.primitives.context import REQUEST_ID, Context
22from lexigram.web import constants as const
24if TYPE_CHECKING:
25 from collections.abc import MutableMapping
27 from starlette.types import ASGIApp, Receive, Scope, Send
29logger = get_logger(__name__)
32class AccessLogMiddleware:
33 """Pure ASGI middleware for structured HTTP access logging.
35 Features:
36 - Logs method, path, status code, and duration for every request
37 - Automatically includes request_id from context
38 - Zero overhead for non-HTTP scopes (websocket, lifespan)
39 - Structured output compatible with JSON and console renderers
41 Example::
43 from lexigram.web.middleware.access_log import AccessLogMiddleware
45 app.add_middleware(AccessLogMiddleware)
46 """
48 def __init__(
49 self,
50 app: ASGIApp,
51 ctx: Context | None = None,
52 *,
53 exclude_paths: list[str] | None = None,
54 ) -> None:
55 """Initialize access log middleware.
57 Args:
58 app: ASGI application.
59 ctx: Application context for reading request metadata.
60 exclude_paths: Paths to exclude from logging (e.g., ["/health"]).
61 """
62 self.app = app
63 self._ctx = ctx
64 self.exclude_paths = set(
65 exclude_paths or [const.DEFAULT_HEALTH_PATH, "/healthz", "/ready"]
66 )
68 async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
69 """ASGI middleware implementation."""
70 if scope["type"] != "http":
71 await self.app(scope, receive, send)
72 return
74 path = scope.get("path", "")
75 if path in self.exclude_paths:
76 await self.app(scope, receive, send)
77 return
79 method = scope.get("method", "")
80 start_time = ambient_clock.monotonic()
81 status_code = 500 # Default if response never starts
83 async def send_with_logging(message: MutableMapping[str, Any]) -> None:
84 nonlocal status_code
85 if message["type"] == "http.response.start":
86 status_code = cast("int", message.get("status", 500))
87 await send(message)
89 try:
90 await self.app(scope, receive, send_with_logging)
91 finally:
92 duration_ms = round(
93 (ambient_clock.monotonic() - start_time) * 1000,
94 2,
95 )
96 request_id = self._ctx.get(REQUEST_ID) if self._ctx else None
98 log_kwargs: dict[str, Any] = {
99 "method": method,
100 "path": path,
101 "status": status_code,
102 "duration_ms": duration_ms,
103 }
104 if request_id:
105 log_kwargs["request_id"] = request_id
107 if status_code >= 500:
108 logger.error("request_completed", **log_kwargs)
109 elif status_code >= 400:
110 logger.warning("request_completed", **log_kwargs)
111 else:
112 logger.info("request_completed", **log_kwargs)
115__all__ = ["AccessLogMiddleware"]