Repository navigation
fix: start each request from its own OTEL trace and log context - #450
Merged
Merged
Conversation
A request's task can inherit the `contextvars` of an earlier request on the same connection, so the trace context and the log deployment id both leak between requests. Under uvicorn on the asyncio loop it is deterministic once a body exceeds uvicorn's HIGH_WATER_LIMIT of 64 KiB: the body pauses reading, the request's own task resumes it, and asyncio pins that task's context to the connection's read callback for the rest of its life. Every later request on that connection is then created from that context. Two consequences: * `opentelemetry.instrumentation.asgi` extracts `traceparent` only when no span is current (`_start_internal_or_server_span`). With one inherited it drops the header silently and files the request under the leftover span as INTERNAL - putting it in an unrelated trace and leaving its real caller's trace with no server span at all. Measured: four requests with four distinct traceparents produced one trace and one SERVER span. * `deployment_id` is resolved from the headers before the body is read, so it is part of the pinned snapshot. Anything a request logs before routing - a client disconnect, a validation error - names the previous request's deployment. Reset both at `DIALApp.__call__`, which is the ASGI callable uvicorn invokes and therefore sits outside the whole middleware stack, including the one `FastAPIInstrumentor.instrument_app` installs outermost. `add_middleware` would land inside it and be too late. The log reset is unconditional, since the log prefix exists without telemetry. Neither reset is restored on the way out: this task's context is the one the next request may snapshot, so restoring would put the stale value back where it leaks from. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alex-chernyshov
approved these changes
Sep 18, 2026
This was referenced Oct 1, 2026
This was referenced Oct 23, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Applicable issues
Workaround for the bug in asyncio: python/cpython#141158
It isn't needed when either of the following holds:
--reset-contextvarsCLI option: Add--reset-contextvarsflag to isolate ASGI request context Kludex/uvicorn#2912Supersedes the PR: #448
Description of changes
The symptom
Requests handled by an SDK app can be recorded under another request's trace, while the real
caller's trace ends up with no server span at all. Seen in production as a Core trace with a
client span to an adapter and nothing beneath it, and an earlier Core trace accumulating adapter
requests it never made, for other deployments, seconds or minutes later.
The same inherited state also mislabels logs: a line emitted before routing can name the previous
request's deployment.
What is actually happening
A request's task can start with the
contextvarsof an earlier request on the same connection.Under uvicorn on the asyncio event loop this is deterministic once a request body exceeds
uvicorn's
HIGH_WATER_LIMITof 65536 bytes:handle_eventscallflow.pause_reading(), dropping the socket reader;RequestResponseCycle.receivecallsflow.resume_reading()→transport.resume_reading()→loop._add_reader(fd, _read_ready);_add_readerbuildsevents.Handle(callback, args, loop, context=None), andNonemeanscontextvars.copy_context()at that moment — so the connection's read callback is now boundto a snapshot of that request's context, for the rest of the connection's life;
_read_ready→handle_events→loop.create_task(cycle.run_asgi(app))thereforeruns in that context, and each new request task copies it.
Nothing is shared or overwritten between requests — only the value a task starts with is wrong.
uvloopis unaffected, since its transports never go throughloop._add_reader.Two consequences follow, one per consumer of the inherited context.
Trace context.
opentelemetry.instrumentation.asgiextractstraceparentonly when no span iscurrent (
_start_internal_or_server_span):With a span inherited from step 4, the header is discarded without a log line and the request is
adopted by the leftover span as
INTERNAL. Because the request's span is then itself in theforeign trace, the next request inherits that, and the whole connection collapses into one trace.
Log records follow along —
LoggingInstrumentorfillsotelTraceIDfromget_current_span()atemit time.
Log deployment id.
deployment_idfeeds the[…]log prefix and is resolved from theheaders/path by
_v1_dispatcher/_parse_requestbeforefrom_requestreads the body — i.e.before step 2 — so it is part of the captured snapshot. Anything a request logs before its handler
runs reads the previous request's deployment:
DisconnectMiddleware._log_client_disconnecton anearly disconnect, and validation errors raised before parsing.
The first request on each connection is always correct, which is why it looked intermittent: it
tracks connection reuse and body size, not load.
The fix
Reset both at
DIALApp.__call__. That is the ASGI callable uvicorn invokes, so it sits outside theentire middleware stack — including
OpenTelemetryMiddleware, whichFastAPIInstrumentorinstallsoutermost by replacing
build_middleware_stack.app.add_middleware(...)would land inside itand be too late.
aidial_sdk/telemetry/_context.py(new) —reset_trace_context(), a context manager thatattaches an empty
Contextfor the duration of the call.aidial_sdk/utils/logging.py—reset_log_context(), clearing the inherited deployment id.aidial_sdk/application.py—DIALApp.__call__calls both._reset_otel_contextisnullcontextby default andconfigure_telemetryswaps inreset_trace_context, so the tracereset costs nothing when telemetry is off. The log reset is unconditional, since the log prefix
exists either way.
Neither reset is restored on the way out, deliberately: this task's context is the one the next
request may snapshot, so restoring would put the stale value straight back where it leaks from.
Compared with #448 this drops the
build_middleware_stackmonkeypatch (and its# type: ignoreand ordering constraint), leaves
aidial_sdk/telemetry/init.pyuntouched, and additionally coversthe log deployment id.
Note on behaviour
An app run in-process inside an existing span — mounted under another instrumented app, or
TestClientcalled inside a span — now starts a fresh trace from its headers instead of becomingan in-process child. That is the correct server-boundary semantics, but it is a change.
Alternative considered
uvicorn --reset-contextvarsfixes the whole class at the source and would makereset_log_context()redundant. It is not used here because it needs uvicorn >= 0.45 whilepyproject.tomlallows>=0.19, it is opt-in and off by default, and adapters own their ownuvicorn command line — so it cannot be a library guarantee. It is worth adopting as a deployment
standard in addition to this change; note it also severs the
PyContextancestry thatOpenTelemetry's eBPF instrumentation follows, which the reset here does not.
Tests
tests/test_request_context.py:test_each_request_is_traced_under_its_own_traceparent— drives a realDIALAppthrough a realuvicorn over one keep-alive connection with two >64 KiB requests carrying distinct
traceparents, and asserts oneSERVERspan per request in its own caller's trace. Without thereset it fails with request feat: something #2 having no
SERVERspan at all. This covers the wiring as well asthe logic — a structural assertion about middleware order would not.
test_telemetry_installs_the_trace_context_reset— locks theconfigure_telemetrywiring.test_deployment_id_is_not_inherited_from_another_request— a request must not see an earlierrequest's deployment id on entry.
Full suite: 1580 passed, 8 skipped.
ruffandpyrightclean.Checklist
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
🤖 Generated with Claude Code