Request ID middleware

Under concurrency, two users can hit your agent at the same millisecond. Their log lines interleave in stdout. Without a request ID, you cannot tell which SQL query belongs to which user, and you cannot join logs to traces. A request ID is the cheapest, highest-impact observability primitive you can add.

observability/context.py
python
import contextvars
import uuid

request_id_var: contextvars.ContextVar[str] = contextvars.ContextVar(
    "request_id", default=""
)

def new_request_id() -> str:
    value = uuid.uuid4().hex[:12]
    request_id_var.set(value)
    return value

def current_request_id() -> str:
    return request_id_var.get()

A Python ContextVar travels with the current task, including across asyncio await points. One set call at the top, every caller downstream reads the same value.

observability/logger.py
python
import structlog
from observability.context import current_request_id, new_request_id

def add_request_id(_, __, event_dict):
    rid = current_request_id()
    if rid:
        event_dict["request_id"] = rid
    return event_dict

structlog.configure(
    processors=[
        add_request_id,
        structlog.processors.add_log_level,
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.processors.JSONRenderer(),
    ],
)

log = structlog.get_logger("agent")

A structlog processor injects the current request ID into every log line. No call site has to remember to pass it.

session.py (wired)
python
from observability.context import new_request_id
from observability.logger import log

class EcommerceSession:
    def ask(self, question: str) -> str:
        rid = new_request_id()
        log.info("turn.start", question=question, turn=self.turn_number + 1)
        answer = self.graph.invoke({"user_message": question, "conversation_history": self.conversation_history})
        log.info("turn.done", request_id=rid)
        return answer["final_answer"]

One new_request_id call at the top of every turn. Every log line in the router, tools, and synthesiser picks up the same id automatically.

Ordering exercise: Order the request id flow

Loading practice…

Yes. asyncio.create_task copies the current Context by default, so the child task sees the same request_id. If you spawn a thread with run_in_executor, copy the context explicitly with contextvars.copy_context so the id follows the work across the thread boundary.