Source code for qfa.services.call_context

"""Request-scoped ContextVar carrying the active call's correlation context.

A single ``ContextVar[CallContext | None]`` propagates ``tenant_id``,
``operation``, and ``call_id`` from the driving adapter (the FastAPI
dependency :func:`~qfa.api.dependencies.call_scope_for` in production)
down to the driven adapter (``TrackingLLMAdapter``), which reads it
when stamping each persisted ``LLMCallRecord``. The orchestrator in
between never touches it.

Entry is via :func:`call_scope`, which takes the correlation UUID as a
required argument — in HTTP requests, that's the same UUID
``RequestIdMiddleware`` placed in ``X-Request-ID``, so header, logs,
and ``llm_calls.call_id`` rows always join cleanly. Non-HTTP callers
(CLI, jobs, tests) pass ``request_id=uuid4()`` themselves.

``asyncio`` propagates ContextVars across ``create_task`` / ``gather``
via snapshot-on-spawn, so fan-out from a public orchestrator method
preserves the context without explicit forwarding.
"""

from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from contextvars import ContextVar
from uuid import UUID

from qfa.domain.usage_models import CallContext, Operation

current_call_context: ContextVar[CallContext | None] = ContextVar(
    "current_call_context",
    default=None,
)


[docs] @asynccontextmanager async def call_scope( tenant_id: str, operation: Operation, request_id: UUID, ) -> AsyncIterator[CallContext]: """Set ``current_call_context`` for the duration of the block. Parameters ---------- tenant_id : str Tenant making the call. operation : Operation Public orchestrator operation issuing the call. request_id : UUID Correlation UUID for the API invocation. Becomes the ``call_id`` field of the resulting ``CallContext`` and is stamped onto every ``LLMCallRecord`` persisted inside the scope. In HTTP requests this is the same UUID set in the ``X-Request-ID`` header by ``RequestIdMiddleware``; non-HTTP callers pass a freshly-generated ``uuid4()``. Yields ------ CallContext The context that was set. """ ctx = CallContext( tenant_id=tenant_id, operation=operation, call_id=request_id, ) token = current_call_context.set(ctx) try: yield ctx finally: current_call_context.reset(token)