Skip to content

🐛 Bug Report: Anthropic instrumentation: streaming spans are never ended on early exit (with-block, messages.stream() helper, close) #4536

Open

Description

@armanunix

Which component is this bug for?

Anthropic instrumentation

📜 Description

When a streamed Anthropic response is not read to the end, the LLM span is never ended and never exported, even after the stream object is garbage-collected. This affects the most common ways of stopping a stream early:

  • leaving a with block (sync and async)
  • the SDK's recommended helper: with client.messages.stream(...) as s: and then break out of s.text_stream
  • stream.close() / await stream.close()
  • break, dropping the stream, or never iterating it

The model call, its tokens and its cost are silently missing from the trace.

Root cause: in streaming.py, AnthropicStream and AnthropicAsyncStream end the span only in _complete_instrumentation(), which runs on stream exhaustion or from get_final_message() / until_done(). The wrappers define no close, no cleanup in __exit__ / __aexit__, and no __del__ fallback, so every early exit leaves the span open permanently.

(The OpenAI instrumentation's ChatStream has __exit__ / __del__ cleanup since #3155, so the Anthropic wrappers are inconsistent with it.)

👟 Reproduction steps

Self-contained (mock HTTP transport, no API key). Run with python repro_anthropic.py:

import asyncio, gc, json, anthropic
try:
    import httpx2 as httpx         
except ImportError:
    import httpx
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from opentelemetry.instrumentation.anthropic import AnthropicInstrumentor

def ev(name, data): return f"event: {name}\ndata: {json.dumps(data)}\n\n"
SSE = (ev("message_start", {"type": "message_start", "message": {"id": "msg_1", "type": "message",
        "role": "assistant", "model": "m", "content": [], "stop_reason": None, "stop_sequence": None,
        "usage": {"input_tokens": 3, "output_tokens": 0}}})
     + ev("content_block_start", {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}})
     + "".join(ev("content_block_delta", {"type": "content_block_delta", "index": 0,
                  "delta": {"type": "text_delta", "text": p}}) for p in ["Hel", "lo", "!"])
     + ev("content_block_stop", {"type": "content_block_stop", "index": 0})
     + ev("message_delta", {"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": None},
                            "usage": {"output_tokens": 3}})
     + ev("message_stop", {"type": "message_stop"})).encode()
handler = lambda r: httpx.Response(200, headers={"content-type": "text/event-stream"}, content=SSE)

exporter = InMemorySpanExporter()
provider = TracerProvider(); provider.add_span_processor(SimpleSpanProcessor(exporter))
AnthropicInstrumentor().instrument(tracer_provider=provider)
KW = dict(model="m", max_tokens=10, messages=[{"role": "user", "content": "hi"}])
client = anthropic.Anthropic(api_key="x", base_url="http://mock",
                             http_client=httpx.Client(transport=httpx.MockTransport(handler)))

def spans(): gc.collect(); return len(exporter.get_finished_spans())

# 1. Recommended helper, stop after the first text chunk
exporter.clear()
with client.messages.stream(**KW) as s:
    for text in s.text_stream:
        break
del s
print("messages.stream() early exit:", spans())   # expected 1, actual 0

# 2. Plain stream in a with-block, stop after the first event
exporter.clear()
with client.messages.create(stream=True, **KW) as s:
    next(iter(s))
del s
print("with-block early exit:      ", spans())   # expected 1, actual 0

# 3. Async with-block
async def main():
    aclient = anthropic.AsyncAnthropic(api_key="x", base_url="http://mock",
                                       http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)))
    exporter.clear()
    async with await aclient.messages.create(stream=True, **KW) as s:
        await s.__aiter__().__anext__()
    del s
    print("async with-block early exit:", spans())  # expected 1, actual 0
asyncio.run(main())

👍 Expected behavior

Exactly one finished span per call when the stream is stopped early by leaving a with block, by the messages.stream() helper, or by close(). Paths with no explicit end signal (break, drop, never iterated) should at least end the span when the object is released.

👎 Actual Behavior with Screenshots

messages.stream() early exit: 0
with-block early exit:       0
async with-block early exit: 0

Spans ended (still referenced / after release / after gc.collect()), anthropic 1.11.0:

Path sync async
read to end 1 / 1 / 1 1 / 1 / 1
messages.stream() read to end 1 / 1 / 1 —
messages.stream() early exit 0 / 0 / 0 0 / 0 / 0
with block 0 / 0 / 0 0 / 0 / 0
close() 0 / 0 / 0 0 / 0 / 0
break / drop / never iterated 0 / 0 / 0 0 / 0 / 0

For comparison, opentelemetry-instrumentation-genai-anthropic 1.2b0 ends the span on the with block, the helper's early exit and close().

🤖 Python Version

3.12

📃 Provide any additional context for the Bug.

Suggested fix: route every exit through _complete_instrumentation() (made idempotent): __exit__ / __aexit__, close, and a __del__ fallback, plus the same in WrappedMessageStreamManager.__exit__ / WrappedAsyncMessageStreamManager.__aexit__, so the messages.stream() helper ends the span when its block exits.

👀 Have you spent some time to check if this bug has been raised before?

  • I checked and didn't find similar issue

Are you willing to submit PR?

Yes I am willing to submit a PR!

Activity

  1. rupak-eng commented on Oct 2, 2026

    @rupak-eng

    Working on this — the Anthropic wrappers are indeed inconsistent with the OpenAI side (fixed for ChatStream in #4534). Putting up a PR shortly that adds close()/__exit__ (+ __del__ fallback) to AnthropicStream, close()/__aexit__ to AnthropicAsyncStream, and cleanup in the WrappedMessageStreamManager exits.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions