"""Per-request correlation and actor context. Every request gets an id before it reaches a route. That id is returned in the ``X-Request-ID`` response header, included in every error body, and written onto every error record — so a user reporting "it broke" can hand over one string that points at exactly one row in the error console. The same object carries the resolved tenant and user. Authentication happens in dependencies, long after the middleware has run, so the middleware seeds the context with what it knows (method, path, ip, user agent) and the auth dependencies fill in the rest via :func:`note_actor`. Error handlers then read one object instead of re-deriving anything. Deliberately stored on ``request.state`` rather than in a ``contextvar``: it is the same object the handler already has in hand, it survives every task boundary Starlette may introduce, and it cannot leak between requests. """ from __future__ import annotations import uuid from dataclasses import dataclass from fastapi import Request from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint from starlette.responses import Response #: Header used to both accept a caller-supplied id and echo it back. Accepting #: an upstream id matters when the API sits behind a reverse proxy or a #: frontend BFF that already correlates its own logs. REQUEST_ID_HEADER = "X-Request-ID" #: Long enough to be unique, short enough to paste into a bug report. REQUEST_ID_LENGTH = 16 _STATE_ATTR = "error_context" @dataclass(slots=True) class RequestContext: """What we know about the request an error happened on.""" request_id: str method: str path: str ip: str = "" user_agent: str = "" #: Filled in by the auth dependencies once the caller is authenticated. organization_id: str | None = None user_id: str | None = None #: Set when a failure happens inside a specific agent run. run_id: str | None = None #: Query string, kept separate from ``path`` so fingerprints group across #: different filters on the same endpoint. query: str = "" def new_request_id() -> str: return uuid.uuid4().hex[:REQUEST_ID_LENGTH] def client_ip(request: Request) -> str: """Real client address, honouring one proxy hop. Same rule as ``deps.client_meta``: a trusted reverse proxy sets ``X-Forwarded-For``, and the left-most entry is the original caller. """ forwarded = request.headers.get("x-forwarded-for", "") if forwarded: return forwarded.split(",")[0].strip() return request.client.host if request.client else "" def context_of(request: Request) -> RequestContext | None: """The context for this request, or ``None`` outside the middleware.""" return getattr(request.state, _STATE_ATTR, None) def note_actor( request: Request, *, user_id: str | None = None, organization_id: str | None = None, run_id: str | None = None, ) -> None: """Record who/which tenant this request belongs to. Called from the auth dependencies. Silent when there is no context (a direct unit-test call to a dependency, for instance) — correlation is telemetry, and telemetry must never be the reason a request fails. """ context = context_of(request) if context is None: return if user_id: context.user_id = user_id if organization_id: context.organization_id = organization_id if run_id: context.run_id = run_id class RequestContextMiddleware(BaseHTTPMiddleware): """Attach a :class:`RequestContext` to every request and echo its id.""" async def dispatch( self, request: Request, call_next: RequestResponseEndpoint ) -> Response: incoming = request.headers.get(REQUEST_ID_HEADER, "").strip() # Cap the accepted value: an unbounded header would let a caller write # arbitrarily long ids into the error table. request_id = incoming[:64] if incoming else new_request_id() setattr( # noqa: B010 - the attribute name is a module constant request.state, _STATE_ATTR, RequestContext( request_id=request_id, method=request.method, path=request.url.path, query=request.url.query or "", ip=client_ip(request), user_agent=request.headers.get("user-agent", "")[:255], ), ) response = await call_next(request) response.headers[REQUEST_ID_HEADER] = request_id return response __all__ = [ "REQUEST_ID_HEADER", "RequestContext", "RequestContextMiddleware", "client_ip", "context_of", "new_request_id", "note_actor", ]