Repository navigation
Description
Which component is this bug for?
All Packages
📜 Description
When a call is cancelled while it waits for the server (for example asyncio.wait_for(...) times out, the task is cancelled, or the user presses Ctrl-C), the LLM span is never ended. An unended span is never exported, so the call disappears from the trace, even though the request was sent and may be billed.
The wrappers start the span with tracer.start_span(...) and end it only in except Exception or after a successful response. asyncio.CancelledError and KeyboardInterrupt are BaseException, so they pass through without ending the span:
- openai
chat_wrapper/achat_wrapper: L104, L203 (same pattern incompletion_wrappers.pyandembeddings_wrappers.py) - anthropic
_wrap/_awrap: L569, L701 - google-generativeai
_awrap/_wrap: L261, L340
Both streaming and non-streaming calls are affected, because the cancellation happens before the stream object exists. The same fix was made for the MCP session span in #4517.
👟 Reproduction steps
pip install openai anthropic google-genai opentelemetry-sdk opentelemetry-instrumentation-openai opentelemetry-instrumentation-anthropic opentelemetry-instrumentation-google-generativeai
python repro.py openai
python repro.py anthropic
python repro.py google
import asyncio, gc, logging, sys
from importlib.metadata import version
import httpx
from opentelemetry.sdk.trace import SpanProcessor, TracerProvider
logging.disable(logging.WARNING)
SDK = sys.argv[1] if len(sys.argv) > 1 else "openai"
class Spans(SpanProcessor):
def __init__(self): self.open, self.ended = set(), []
def on_start(self, span, parent_context=None): self.open.add(span.context.span_id)
def on_end(self, span):
self.open.discard(span.context.span_id)
self.ended.append(f"{span.name} [{span.status.status_code.name}]")
spans = Spans()
provider = TracerProvider()
provider.add_span_processor(spans)
def slow_http(hx=httpx): # a server that answers after 5 s
async def slow_server(request):
await asyncio.sleep(5)
return hx.Response(200, json={})
return hx.AsyncClient(transport=hx.MockTransport(slow_server))
M = [{"role": "user", "content": "hi"}]
if SDK == "openai":
import openai
from opentelemetry.instrumentation.openai import OpenAIInstrumentor
OpenAIInstrumentor().instrument(tracer_provider=provider)
client = openai.AsyncOpenAI(api_key="x", base_url="http://mock/v1", max_retries=0, http_client=slow_http())
calls = {"create": lambda: client.chat.completions.create(model="m", messages=M),
"create(stream=True)": lambda: client.chat.completions.create(model="m", messages=M, stream=True)}
pkg = ("openai", "opentelemetry-instrumentation-openai")
elif SDK == "anthropic":
import anthropic, anthropic._base_client as base
from opentelemetry.instrumentation.anthropic import AnthropicInstrumentor
AnthropicInstrumentor().instrument(tracer_provider=provider)
hx = getattr(base, "httpx2", None) or httpx # newer anthropic releases use httpx2
client = anthropic.AsyncAnthropic(api_key="x", base_url="http://mock", max_retries=0, http_client=slow_http(hx))
calls = {"create": lambda: client.messages.create(model="m", max_tokens=50, messages=M),
"create(stream=True)": lambda: client.messages.create(model="m", max_tokens=50, messages=M, stream=True)}
pkg = ("anthropic", "opentelemetry-instrumentation-anthropic")
else:
from google import genai
from google.genai import types
from opentelemetry.instrumentation.google_generativeai import GoogleGenerativeAiInstrumentor
GoogleGenerativeAiInstrumentor().instrument(tracer_provider=provider)
client = genai.Client(api_key="x", http_options=types.HttpOptions(base_url="http://mock", httpx_async_client=slow_http()))
calls = {"generate_content": lambda: client.aio.models.generate_content(model="m", contents="hi"),
"generate_content_stream": lambda: client.aio.models.generate_content_stream(model="m", contents="hi")}
pkg = ("google-genai", "opentelemetry-instrumentation-google-generativeai")
async def main():
print(" | ".join(f"{p} {version(p)}" for p in pkg))
for name, call in calls.items():
spans.ended.clear()
try:
await asyncio.wait_for(call(), timeout=1.0) # the app gives up after 1 s
except TimeoutError:
pass
gc.collect()
await asyncio.sleep(0.1)
print(f"{name:25} timed out -> ended spans: {spans.ended or 'none'}; spans never ended: {len(spans.open)}")
spans.open.clear()
asyncio.run(main())The sync clients behave the same way when the call is interrupted with Ctrl-C (KeyboardInterrupt) while waiting for the response.
👍 Expected behavior
The span ends with status ERROR and the exception (CancelledError / KeyboardInterrupt) recorded. The exception still propagates to the caller unchanged.
Suggested fix: catch BaseException instead of Exception around the wrapped call (or end the span in a finally block).
👎 Actual Behavior with Screenshots
The span is never ended, so it is never exported:
openai 3.26.0 | opentelemetry-instrumentation-openai 0.62.4
create timed out -> ended spans: none; spans never ended: 1
create(stream=True) timed out -> ended spans: none; spans never ended: 1
anthropic 1.11.0 | opentelemetry-instrumentation-anthropic 0.62.4
create timed out -> ended spans: none; spans never ended: 1
create(stream=True) timed out -> ended spans: none; spans never ended: 1
google-genai 2.28.0 | opentelemetry-instrumentation-google-generativeai 0.62.4
generate_content timed out -> ended spans: none; spans never ended: 1
generate_content_stream timed out -> ended spans: none; spans never ended: 1
🤖 Python Version
3.12
📃 Provide any additional context for the Bug.
The span-ending gap is in the request wrappers. A stream that is cancelled in the middle of reading is also not recorded as an error: the stream wrappers catch only Exception as well.
👀 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
Rudra-G-23 commentedon Oct 7, 2026
Hi, I'd be interested in helping with this. I noticed #4517 handled a similar cancellation case by ending the span in
finally, while leaving cancellation status UNSET rather than ERROR.For these OpenAI/Anthropic/Google request spans, should cancellation be recorded as ERROR as proposed here, or should it follow the #4517 behavior?
Also, @armanunix are you already planning to submit the PR for this issue?
armanunix commentedon Oct 11, 2026
The same in opentelemetry-instrumentation-ollama 0.62.4 (ollama 0.6.3):
_wrap/_awraphandle onlyexcept Exception, so achat/generatecall interrupted byKeyboardInterrupt(orasyncio.CancelledErrorforAsyncClient) while waiting for the server never ends its span. The open PR #4127 also catches onlyException, so it does not cover this.