Auto-tagging every log line with the active CallSid — without passing it through every function signature.
The pain you don’t notice until you have it
A single voice call generates 50–200 log lines: WebSocket frames, tool invocations, n8n calls, transcript fragments, errors. Now imagine three concurrent calls. Your log file is a braided mess:
1 | INFO app.services.tools_service Tool invocation: schedule_meeting |
Which call timed out? Which one called verify? You can’t tell without grepping by timestamp and praying.
The naive fix is extra={"call_sid": sid} on every logger.info(...) call. That works for the five lines you remember to update, and silently fails for the rest. You need automatic propagation.
contextvars is the right tool
Python’s contextvars.ContextVar was designed for exactly this: a value that follows your asyncio task chain automatically, isolated from sibling tasks, with no global-state hazards.
The whole thing is twenty lines:
1 | # app/core/log_context.py |
Two pieces:
- The ContextVar holds the active
call_sidfor the current asyncio task. - A
logging.Filtercopies that value onto everyLogRecordasrecord.call_sid.
Attach the filter to your handler once:
1 | handler.addFilter(CallSidFilter()) |
…and JSON formatters pick it up automatically because they iterate record.__dict__.
Where to bind
Three sites, all in the request-entry layer:
1 | # api/endpoints/calls.py |
And one site for cleanup:
1 | finally: |
That’s it. Every log line emitted by any module — n8n_service, tools_service, third-party libraries that use logging — gets tagged automatically.
Before / after
Before:
1 | INFO app.services.tools_service Tool invocation: schedule_meeting |
After (text):
1 | INFO app.services.tools_service [CA9f7e...] Tool invocation: schedule_meeting |
After (JSON, for Loki/Datadog/Splunk):
1 | {"ts":"...","level":"WARN","logger":"app.services.n8n_service", |
grep CA9f7e... (or {call_sid="CA9f7e..."} in your log aggregator’s query language) replays the entire call in chronological order.
Why not just extra={}?
| Approach | Updates every call site? | Works in third-party libs? | Survives across await? |
|---|---|---|---|
logger.info(..., extra={"call_sid": sid}) |
Yes (and you’ll miss some) | No | Yes |
threading.local() |
No | Yes | No — different task, no value |
ContextVar + filter |
No | Yes | Yes |
ContextVar is the only option that propagates correctly through asyncio and covers every logger in the process (yours and your dependencies’) with zero code changes outside the logging config.
A subtle correctness note
ContextVar.set() is asyncio-task-scoped — concurrent calls don’t pollute each other. But if you spawn a TaskGroup child and want the child to inherit the parent’s call_sid, that already works because tasks inherit context by default. If you asyncio.run_in_executor to a thread pool, the value is copied with contextvars.copy_context() automatically as of Python 3.7+. You almost never need to think about it.
The unexpected bonus
The same pattern scales to anything else you want to correlate by:
tenant_idfor multi-tenant deploymentsrequest_idfor HTTP request tracingtrace_idfor OpenTelemetry interop (it’s literally how OTel propagates context)
Add a second ContextVar. Add a second filter (or extend the first). Every log line now carries both. Your future self, three months in, will thank you.
Takeaway
You don’t need a logging framework, a tracing SDK, or a dedicated correlation library. You need 20 lines: a ContextVar, a logging.Filter, and three bind_call_sid() calls at request entry. Every log line, in every module, gets the active call’s ID. Your post-mortems get 10× faster.