"""Ngữ cảnh theo dõi đi kèm mỗi request (user / session / task / feature). VÌ SAO LÀ HEADER, KHÔNG PHẢI BODY: Job được tạo ở ~15 endpoint với ~15 model Pydantic khác nhau, lại có cả endpoint nhận multipart (upload). Thêm 6 trường vào từng model là sửa rải rác và rất dễ sót. Frontend gắn header `X-TNT-*` một chỗ duy nhất (bản vá `fetch` trong `lib/track.js`), backend đọc một chỗ duy nhất ở đây, và `jobs.create()` tự nhặt — endpoint không phải biết gì về tracking. Dùng ContextVar nên an toàn với cả endpoint `async def` lẫn `def` (Starlette chạy endpoint đồng bộ trong threadpool có sao chép context). """ from __future__ import annotations from contextvars import ContextVar _ctx: ContextVar[dict] = ContextVar("tnt_track_ctx", default={}) HEADERS = { "user_id": "x-tnt-user", "session_id": "x-tnt-session", "task_id": "x-tnt-task", "feature": "x-tnt-feature", "phase": "x-tnt-phase", "attempt": "x-tnt-attempt", } def get_ctx() -> dict: return _ctx.get() or {} class TrackContextMiddleware: """ASGI middleware thuần — CỐ Ý không dùng BaseHTTPMiddleware. BaseHTTPMiddleware bọc response vào một luồng trung gian; app này phục vụ file video lớn bằng FileResponse nên không đáng đánh đổi. Ở đây chỉ đọc vài header rồi đặt ContextVar, không đụng gì tới request/response. """ def __init__(self, app): self.app = app async def __call__(self, scope, receive, send): if scope.get("type") != "http": return await self.app(scope, receive, send) raw = {} for k, v in scope.get("headers") or (): key = k.decode("latin-1").lower() if key.startswith("x-tnt-"): raw[key] = v.decode("latin-1", "ignore")[:120] ctx = {k: raw.get(v, "") for k, v in HEADERS.items()} try: ctx["attempt"] = int(ctx["attempt"]) if ctx["attempt"] else None except (TypeError, ValueError): ctx["attempt"] = None ctx["user_id"] = ctx["user_id"].strip().lower() token = _ctx.set(ctx if ctx["user_id"] else {}) try: await self.app(scope, receive, send) finally: _ctx.reset(token)