request_log_context.py 3.93 KB
"""
Request-scoped reqid/uid for logging and downstream HTTP headers.

Kept as a **top-level module** (not under ``utils/``) because ``utils/__init__.py``
pulls optional deps (e.g. sqlalchemy) that are not installed in ``.venv-embedding``.
Uvicorn ``--log-config`` and the embedding service must be able to import this module
without importing the full ``utils`` package.
"""

from __future__ import annotations

import logging
from contextvars import ContextVar, Token
from typing import Dict, Optional, Tuple

_DEFAULT_REQUEST_ID = "-1"
_DEFAULT_USER_ID = "-1"

_request_id_var: ContextVar[str] = ContextVar("request_log_reqid", default=_DEFAULT_REQUEST_ID)
_user_id_var: ContextVar[str] = ContextVar("request_log_uid", default=_DEFAULT_USER_ID)

LOG_LINE_FORMAT = (
    "%(asctime)s | reqid:%(reqid)s | uid:%(uid)s | %(levelname)-8s | %(name)s | %(message)s"
)


def _normalize_value(value: Optional[str], *, fallback: str) -> str:
    text = str(value or "").strip()
    return text[:64] if text else fallback


def bind_request_log_context(
    request_id: Optional[str] = None,
    user_id: Optional[str] = None,
) -> Tuple[str, str, Tuple[Token[str], Token[str]]]:
    """Bind reqid/uid to contextvars for the current execution context."""
    normalized_reqid = _normalize_value(request_id, fallback=_DEFAULT_REQUEST_ID)
    normalized_uid = _normalize_value(user_id, fallback=_DEFAULT_USER_ID)
    req_token = _request_id_var.set(normalized_reqid)
    uid_token = _user_id_var.set(normalized_uid)
    return normalized_reqid, normalized_uid, (req_token, uid_token)


def reset_request_log_context(tokens: Tuple[Token[str], Token[str]]) -> None:
    """Reset reqid/uid contextvars back to their previous values."""
    req_token, uid_token = tokens
    _request_id_var.reset(req_token)
    _user_id_var.reset(uid_token)


def current_request_log_context() -> Tuple[str, str]:
    """Return the currently bound reqid/uid pair."""
    return _request_id_var.get(), _user_id_var.get()


def build_request_log_extra(
    request_id: Optional[str] = None,
    user_id: Optional[str] = None,
) -> Dict[str, str]:
    """Build logging extras, defaulting to the current bound context."""
    current_reqid, current_uid = current_request_log_context()
    return {
        "reqid": _normalize_value(request_id, fallback=current_reqid),
        "uid": _normalize_value(user_id, fallback=current_uid),
    }


def build_downstream_request_headers(
    request_id: Optional[str] = None,
    user_id: Optional[str] = None,
) -> Dict[str, str]:
    """Build headers for downstream service calls when request context exists."""
    extra = build_request_log_extra(request_id=request_id, user_id=user_id)
    if extra["reqid"] == _DEFAULT_REQUEST_ID and extra["uid"] == _DEFAULT_USER_ID:
        return {}
    headers = {"X-Request-ID": extra["reqid"]}
    if extra["uid"]:
        headers["X-User-ID"] = extra["uid"]
    return headers


class RequestLogContextFilter(logging.Filter):
    """Inject reqid/uid defaults into all log records."""

    def filter(self, record: logging.LogRecord) -> bool:
        reqid = getattr(record, "reqid", None)
        uid = getattr(record, "uid", None)

        if reqid is None or uid is None:
            bound_reqid, bound_uid = current_request_log_context()
            reqid = reqid if reqid is not None else bound_reqid
            uid = uid if uid is not None else bound_uid

        if reqid == _DEFAULT_REQUEST_ID and uid == _DEFAULT_USER_ID:
            try:
                from context.request_context import get_current_request_context

                context = get_current_request_context()
            except Exception:
                context = None
            if context is not None:
                reqid = getattr(context, "reqid", None) or reqid
                uid = getattr(context, "uid", None) or uid

        record.reqid = _normalize_value(reqid, fallback=_DEFAULT_REQUEST_ID)
        record.uid = _normalize_value(uid, fallback=_DEFAULT_USER_ID)
        return True