Repository navigation
Description
Which component is this bug for?
Ollama Instrumentation
📜 Description
For stream=True, the Ollama instrumentation returns its own generator, _accumulate_streaming_response / _aaccumulate_streaming_response (opentelemetry/instrumentation/ollama/__init__.py, lines 90 and 161 at 0.62.4), which iterates over the Ollama stream and ends the span in its finally. Two problems follow from that:
-
Sync, closed before reading. The span is created before the generator, but a generator that is closed before its first
next()never runs its body, so thetry/finallythat ends the span is never entered.stream.close()before reading (the app decides it does not need the answer) leaves the span open forever: it is never exported and stays referenced. -
Async,
aclose().await stream.aclose()closes the instrumentation's async generator (itsfinallyends the span), but not the Ollama async stream it iterates over: async generators are not closed when an enclosingasync foris interrupted. The Ollama stream holds the httpx response, so the response, and its connection, stays open until the stream is garbage-collected. Without the instrumentation,aclose()closes it immediately. In a server that cancels streamed answers this holds connections from the pool.
A fix would be to end the span (and close the wrapped stream) in the wrapper's close path: e.g. a small wrapper class whose close() / aclose() end the span if the iteration never started, and to call response.close() / await response.aclose() in the finally blocks.
👟 Reproduction steps
pip install ollama opentelemetry-instrumentation-ollama opentelemetry-sdk httpx
import asyncio, gc, json, httpx, ollama
from importlib.metadata import version
from opentelemetry.sdk.trace import SpanProcessor, TracerProvider
from opentelemetry.instrumentation.ollama import OllamaInstrumentor
class Spans(SpanProcessor):
def __init__(self): self.started, self.ended = {}, {}
def on_start(self, span, parent_context=None): self.started[span.context.span_id] = span.name
def on_end(self, span): self.ended[span.context.span_id] = span.name
def open(self): return [n for k, n in self.started.items() if k not in self.ended]
spans = Spans()
provider = TracerProvider()
provider.add_span_processor(spans)
OllamaInstrumentor().instrument(tracer_provider=provider)
CLOSED = []
class Body(httpx.SyncByteStream, httpx.AsyncByteStream): # NDJSON body; records whether the client closed it
def __init__(self, lines): self.lines = lines
def __iter__(self): yield from self.lines
async def __aiter__(self):
for x in self.lines: yield x
def close(self): CLOSED.append(True)
async def aclose(self): CLOSED.append(True)
def respond(request): # local stand-in for the Ollama server: a 3-part streamed answer
chat = request.url.path.endswith("/api/chat")
parts = [{"model": "m", "created_at": "2026-01-01T00:00:00Z", "done": False,
**({"message": {"role": "assistant", "content": w}} if chat else {"response": w})} for w in ("Hel", "lo", "!")]
parts.append({"model": "m", "created_at": "2026-01-01T00:00:00Z", "done": True, "done_reason": "stop",
**({"message": {"role": "assistant", "content": ""}} if chat else {"response": ""})})
return httpx.Response(200, headers={"content-type": "application/x-ndjson"},
stream=Body([(json.dumps(p) + "\n").encode() for p in parts]))
print(f"ollama {version('ollama')} | opentelemetry-instrumentation-ollama {version('opentelemetry-instrumentation-ollama')}")
client = ollama.Client(host="http://mock", transport=httpx.MockTransport(respond))
stream = client.generate(model="m", prompt="hi", stream=True)
stream.close() # the app decides it does not need the answer
gc.collect()
print("sync: close() before reading -> spans never ended:", spans.open())
async def main():
aclient = ollama.AsyncClient(host="http://mock", transport=httpx.MockTransport(respond))
stream = await aclient.chat(model="m", messages=[{"role": "user", "content": "hi"}], stream=True)
await stream.__anext__()
await stream.aclose() # the app stops reading after the first part
print("async: aclose() after 1 chunk -> HTTP response closed:", bool(CLOSED))
asyncio.run(main())👍 Expected behavior
close() before reading ends the span (the call was made, it was just not read), and aclose() closes the underlying Ollama stream, so its HTTP response is closed right away, as it is without the instrumentation.
👎 Actual Behavior with Screenshots
ollama 0.6.3 | opentelemetry-instrumentation-ollama 0.62.4
sync: close() before reading -> spans never ended: ['ollama.completion']
async: aclose() after 1 chunk -> HTTP response closed: False
The same script without OllamaInstrumentor().instrument(...) prints HTTP response closed: True.
🤖 Python Version
3.12
📃 Provide any additional context for the Bug.
opentelemetry-instrumentation-ollama 0.62.4 (the latest release), ollama 0.6.3. Not covered by the open PRs #4127 (exceptions) or #4223 (semantic conventions).
👀 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?
None
Activity
Suddha-Abhitej commentedon Oct 12, 2026
I'd like to take this on.
Approach, following the suggestion in the issue:
__iter__/__next__/close()) instead of the bare generator.close()ends the span if iteration never started, and__del__covers a stream that is dropped without being read. The generator'sfinallyalso closes the wrapped Ollama stream.__aiter__/__anext__/aclose(). Thefinallyawaitsresponse.aclose(), so the HTTP response is released immediately instead of at GC.httpx.MockTransport(no cassettes): close/aclose before reading, dropped before reading, close/aclose after a partial read, idempotent close, and full iteration unchanged.Two notes:
close()before the first read, no HTTP request is actually sent, since Ollama's stream is lazy. The span is still ended, with status left unset. Happy to set a different status if you prefer.send()/throw()are no longer available. Ollama's type hints only promiseIterator/AsyncIterator.armanunix commentedon Oct 13, 2026
A related case in the same wrapper: an app that stops reading at Ollama's final
donerecord (for chunk in stream: ... if chunk.done: break, a common pattern) has the complete answer, but the span records no output._accumulate_streaming_responsesets the accumulated response after itsforloop and ends the span infinally. When the app breaks out, the generator receivesGeneratorExitat itsyield, the code after the loop never runs, andfinallyends the span withoutgen_ai.completion.*. Recording the response when thedonerecord arrives (or in theGeneratorExitpath) would fix it.Suddha-Abhitej commentedon Oct 13, 2026
Fixed, along with two more cases the same investigation turned up. PR: #4617
Your case had the same root as case 1, from the other side:
GeneratorExitsubclasses
BaseException, so breaking out at thedonerecord misses theexcept Exceptionarm, theelseblock never runs, andfinallyends the spanbare. Fixed by accumulating before the
yieldand adding anexcept GeneratorExit:arm that records what was received and re-raises. The span isnow identical to a full iteration, token usage included.
Two things that fell out of it:
disconnect — also ended the span empty. It now records what the app received,
with
error.type=CancelledError. TheCancelledErroritself is re-raiseduntouched, and there's a test asserting it reaches the awaiting caller.
llm.streaming.time_to_generate. Not emitted when the app breaks atdone, even though generation had completed. Now emitted for that case; amid-stream break still emits nothing.
22 regression tests, 18 of which fail on current
main. Verified against a realOllama server and checked in Jaeger: on
maintheclose()-before-reading caseexports only the parent span, since
ollama.chatnever ends — on the branchboth are there.
Suddha-Abhitej commentedon Oct 13, 2026