Coverage for src/lexigram/web/middleware/request_id.py: 35%
49 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"""Request ID middleware for request tracing.
3Adds unique request ID to each request and propagates through logs.
4"""
6from __future__ import annotations
8from typing import Any
9import uuid
11from starlette.requests import Request
12from starlette.types import ASGIApp, Receive, Scope, Send
14from lexigram.contracts.core.identity import IdGeneratorProtocol
15from lexigram.logging import get_logger
16from lexigram.primitives.context import REQUEST_ID, Context
18logger = get_logger(__name__)
21class RequestIDMiddleware:
22 """Pure ASGI middleware to add request ID to each request.
24 10x faster than BaseHTTPMiddleware - no task creation overhead.
26 Features:
27 - Generates unique request ID for each request
28 - Accepts existing request ID from X-Request-ID header
29 - Adds request ID to response headers
30 - Propagates request ID to context for logging
32 Example:
33 >>> from lexigram.web.middleware import RequestIDMiddleware
34 >>>
35 >>> # Request
36 >>> GET /api/users/123
37 >>> # (no X-Request-ID header)
38 >>>
39 >>> # Response
40 >>> X-Request-ID: req_a1b2c3d4
41 >>>
42 >>> # Logs
43 >>> 2026-01-12 10:23:15 INFO [req_a1b2c3d4] Fetching user 123
44 """
46 def __init__(
47 self,
48 app: ASGIApp,
49 ctx: Context | None = None,
50 header_name: str = "X-Request-ID",
51 ids: IdGeneratorProtocol | None = None,
52 ):
53 """Initialize request ID middleware.
55 Args:
56 app: ASGI application
57 ctx: Application context for propagating the request ID.
58 header_name: Header name for request ID (default: X-Request-ID).
59 ids: ID generator protocol for request ID generation.
60 """
61 self.app = app
62 self._ctx = ctx
63 self.header_name = header_name
64 self._ids = ids
66 async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
67 """Pure ASGI middleware implementation."""
69 if scope["type"] != "http":
70 # Pass through non-HTTP requests
71 await self.app(scope, receive, send)
72 return
74 # Get or generate request ID from headers
75 headers = dict(scope.get("headers", []))
76 header_bytes = self.header_name.lower().encode()
77 request_id_bytes = headers.get(header_bytes)
79 if request_id_bytes:
80 request_id = request_id_bytes.decode()
81 else:
82 # Generate new UUID-based request ID
83 request_id = (
84 f"req_{self._ids.generate() if self._ids else uuid.uuid4().hex[:12]}"
85 )
87 # Set in context (for logging and background tasks)
88 if self._ctx is not None:
89 self._ctx.set(REQUEST_ID, request_id)
91 # Store request ID in scope for access in handlers
92 scope["request_id"] = request_id
94 logger.debug(
95 "Request ID assigned",
96 request_id=request_id,
97 method=scope.get("method", ""),
98 path=scope.get("path", ""),
99 )
101 # Wrap send to add request ID header to response
102 async def send_with_request_id(message: Any) -> None:
103 if message["type"] == "http.response.start":
104 headers = list(message.get("headers", []))
105 # Add request ID header
106 headers.append([header_bytes, request_id.encode()])
107 message["headers"] = headers
108 await send(message)
110 # Process request
111 await self.app(scope, receive, send_with_request_id)
114def get_request_id_from_request(request: Request) -> str | None:
115 """Get request ID from request state.
117 Args:
118 request: Lexigram request.
120 Returns:
121 Request ID or None.
123 Example:
124 >>> @app.get("/api/status")
125 >>> async def get_status(request: Request):
126 ... request_id = get_request_id_from_request(request)
127 ... return {"request_id": request_id}
128 """
129 return getattr(request.state, "request_id", None)
132class RequestIDLogFilter:
133 """Log filter to add request ID to log records.
135 This filter extracts the current request ID from the injected Context
136 and adds it to each log record as `request_id`.
137 """
139 def __init__(self, ctx: Context | None = None) -> None:
140 """Initialize filter.
142 Args:
143 ctx: Application context instance. If provided, the request ID
144 is read via ``ctx.get(REQUEST_ID)``.
145 """
146 self._ctx = ctx
148 def filter(self, record: object) -> bool:
149 """Add request ID to log record."""
150 request_id = self._ctx.get(REQUEST_ID) if self._ctx is not None else None
151 record.request_id = request_id or "no_request" # type: ignore[attr-defined]
152 return True
155def configure_logging_with_request_id() -> None:
156 """Configure standard logging to include request ID.
158 This adds a filter to the root logger to ensure all logs include the
159 current request ID if available.
160 """
162 root_logger = get_logger()
163 if not any(isinstance(f, RequestIDLogFilter) for f in root_logger.filters):
164 root_logger.addFilter(RequestIDLogFilter())