You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Anthropic AsyncMessageStream.get_final_message()/get_final_text() bypass TracedAsyncStream, so the span never finishes
Description
When consuming an Anthropic streamed response via the SDK's own convenience
methods (stream.get_final_message() / stream.get_final_text()) instead
of manual async for iteration, the APM span created for the .stream()
call is never finished, and therefore never exported — to plain APM or
LLM Observability. No exception is raised and no error is logged anywhere;
the span is simply left open indefinitely.
Reproduction
importanthropicimportddtrace# or run under ddtrace-run / with DD_LLMOBS_ENABLED=trueclient=anthropic.AsyncAnthropic()
asyncdefgenerate():
asyncwithclient.messages.stream(
model="claude-opus-4-8",
max_tokens=1024,
messages=[{"role": "user", "content": "hello"}],
) asstream:
message=awaitstream.get_final_message() # <-- span never finishesreturnmessage# No span shows up in APM traces or LLM Observability for this call,# despite the request completing successfully with a 200 from the API.
Changing the body to manually iterate first works around it:
asyncwithclient.messages.stream(...) asstream:
asyncfor_instream: # <-- drives the span-finishing hookpassmessage=awaitstream.get_final_message() # now just reads accumulated statereturnmessage
Root cause
ddtrace/contrib/internal/anthropic/patch.py wraps Messages.stream() / AsyncMessages.stream() and, for streaming responses, hands the result to handle_streamed_response() in ddtrace/contrib/internal/anthropic/_streaming.py,
which builds a TracedAsyncStream (from ddtrace/llmobs/_integrations/base_stream_handler.py) wrapping the real
stream object via wrapt.ObjectProxy.
finalize_stream() — which calls ctx.dispatch_ended_event(), the only
thing that actually finishes the span — is called from exactly one place:
get_final_message/until_done are methods defined on the original AsyncMessageStream class, not on TracedAsyncStream. Because TracedAsyncStream is a wrapt.ObjectProxy, stream.get_final_message()
resolves via __getattr__ to the unwrapped object's bound method — self
inside that method is the original stream, not the proxy — so until_done()'s internal consumption never goes through TracedAsyncStream.__anext__, and finalize_stream() is never called.
Why this matters
get_final_message()/get_final_text() are first-class, documented
Anthropic SDK methods for exactly this use case ("I don't need to process
individual events, just give me the final result") — this isn't an obscure
or unsupported usage pattern. Anyone using them with the streaming API loses
tracing entirely and has no indication anything is wrong.
Related issues (same class of bug, fixed individually per-integration)
dd-trace-js#7633 — Anthropic (Node): a related but distinct symptom
(spans export, but with empty input/output) from the same underlying
cause — BetaMessageStream consumes events through an internal queue
before the Symbol.asyncIterator wrapper can intercept them.
All three are instances of the same architectural gap: the streaming
instrumentation only observes consumption through its own proxy's iterator
protocol, and any SDK-internal or convenience-method consumption path
bypasses it silently. Given how often this recurs, a generic fix in base_stream_handler.py (e.g. having __aexit__/__exit__ also call finalize_stream() if it hasn't fired yet) seems more robust than
continuing to patch each integration individually as it's discovered.
Environment
ddtrace 4.11.1 (latest on PyPI as of this report)
anthropic >=0.40.0
Confirmed still present on the current dd-trace-pymain branch
(TracedAsyncStream.__aexit__/TracedStream.__exit__ unchanged)
Title
Anthropic AsyncMessageStream.get_final_message()/get_final_text() bypass TracedAsyncStream, so the span never finishes
Description
When consuming an Anthropic streamed response via the SDK's own convenience
methods (
stream.get_final_message()/stream.get_final_text()) insteadof manual
async foriteration, the APM span created for the.stream()call is never finished, and therefore never exported — to plain APM or
LLM Observability. No exception is raised and no error is logged anywhere;
the span is simply left open indefinitely.
Reproduction
Changing the body to manually iterate first works around it:
Root cause
ddtrace/contrib/internal/anthropic/patch.pywrapsMessages.stream()/AsyncMessages.stream()and, for streaming responses, hands the result tohandle_streamed_response()inddtrace/contrib/internal/anthropic/_streaming.py,which builds a
TracedAsyncStream(fromddtrace/llmobs/_integrations/base_stream_handler.py) wrapping the realstream object via
wrapt.ObjectProxy.finalize_stream()— which callsctx.dispatch_ended_event(), the onlything that actually finishes the span — is called from exactly one place:
Neither
__aenter__nor__aexit__call it:AsyncMessageStream.get_final_message()(Anthropic SDK,anthropic/lib/streaming/_messages.py) is:get_final_message/until_doneare methods defined on the originalAsyncMessageStreamclass, not onTracedAsyncStream. BecauseTracedAsyncStreamis awrapt.ObjectProxy,stream.get_final_message()resolves via
__getattr__to the unwrapped object's bound method —selfinside that method is the original stream, not the proxy — so
until_done()'s internal consumption never goes throughTracedAsyncStream.__anext__, andfinalize_stream()is never called.Why this matters
get_final_message()/get_final_text()are first-class, documentedAnthropic SDK methods for exactly this use case ("I don't need to process
individual events, just give me the final result") — this isn't an obscure
or unsupported usage pattern. Anyone using them with the streaming API loses
tracing entirely and has no indication anything is wrong.
Related issues (same class of bug, fixed individually per-integration)
langchain-awscallsclose()on thegenerator, raising
GeneratorExit, which isn't caught byexcept Exception. Fixed by moving cleanup into afinallyblock(Bedrock/botocore-specific file).
span open across sequential queries. Fixed by eagerly finalizing on a
specific chunk type (Claude Agent SDK–specific file).
(spans export, but with empty input/output) from the same underlying
cause —
BetaMessageStreamconsumes events through an internal queuebefore the
Symbol.asyncIteratorwrapper can intercept them.All three are instances of the same architectural gap: the streaming
instrumentation only observes consumption through its own proxy's iterator
protocol, and any SDK-internal or convenience-method consumption path
bypasses it silently. Given how often this recurs, a generic fix in
base_stream_handler.py(e.g. having__aexit__/__exit__also callfinalize_stream()if it hasn't fired yet) seems more robust thancontinuing to patch each integration individually as it's discovered.
Environment
ddtrace4.11.1 (latest on PyPI as of this report)anthropic>=0.40.0dd-trace-pymainbranch(
TracedAsyncStream.__aexit__/TracedStream.__exit__unchanged)