agnes-the-ai-analyst/app/middleware/request_id.py
Vojtech 38f6b639d2
feat(observability): request_id end-to-end + dev debug toolbar + centralized logging (#136)
Cuts release 0.20.0.

## Highlights
- X-Request-ID header on every response + sanitized to [A-Za-z0-9_-] (CRLF log-forging mitigation)
- Error pages (HTML + JSON 500) surface request_id for support tickets
- Dev debug toolbar gated by DEBUG=1 — fastapi-debug-toolbar with custom DuckDBPanel
- Centralized app.logging_config.setup_logging() replaces 23 scattered basicConfig calls
- Telegram bot drops bot.log file — stdout only (BREAKING)

## Devin findings addressed
- BUG_0001: .env.template no longer claims FastAPI debug=True
- BUG_0002: subprocess extractor logs INFO to stderr again
- ANALYSIS_0003: _wants_html no longer matches Accept: */* (curl gets JSON as before)
- BUG on b1c6ee9: HTML 500 page no longer leaks str(exc) in production
- BUG on b13d2fe: 2 CLAUDE.md compliance flags (transform.py + ws_gateway) accepted as scope-limited logging refactor — follow-up to update CLAUDE.md if needed

See CHANGELOG [0.20.0] for full notes.
2026-04-29 22:54:21 +02:00

50 lines
1.7 KiB
Python

"""Request-ID middleware. Assigns or propagates X-Request-ID per request.
Pure ASGI middleware (not BaseHTTPMiddleware) so the request_id ContextVar
propagates into route handlers and BackgroundTasks without being clobbered
by an early `finally`-block reset. Each request runs in its own asyncio
task with an isolated context copy, so no manual reset is needed.
"""
from __future__ import annotations
import uuid
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from app.logging_config import request_id_var
_MAX_RID_LEN = 64
_ALLOWED = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_")
def _sanitize(rid: str) -> str:
cleaned = "".join(c for c in rid if c in _ALLOWED)[:_MAX_RID_LEN]
return cleaned or uuid.uuid4().hex[:12]
class RequestIdMiddleware:
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
rid_header: str | None = None
for k, v in scope.get("headers", []):
if k == b"x-request-id":
rid_header = v.decode("latin-1", errors="replace")
break
rid = _sanitize(rid_header) if rid_header else uuid.uuid4().hex[:12]
request_id_var.set(rid)
async def send_wrapper(message: Message) -> None:
if message["type"] == "http.response.start":
headers = list(message.get("headers", []))
headers.append((b"x-request-id", rid.encode("latin-1")))
message["headers"] = headers
await send(message)
await self.app(scope, receive, send_wrapper)