From 70627804ac0e597a679b216af91999b149aa1633 Mon Sep 17 00:00:00 2001 From: Charan Date: Mon, 11 May 2026 23:11:01 +0530 Subject: [PATCH 1/2] fix(ollama): record failed spans when API raises exceptions When `Client._request` or `AsyncClient._request` raises, the span was left without an error status and was never ended. Wrap both `_wrap` and `_awrap` API calls in try/except so exceptions are captured via `set_status(ERROR)`, `record_exception`, and `span.end()` before re-raising. Fixes #412 Co-Authored-By: Claude Sonnet 4.6 --- .../instrumentation/ollama/__init__.py | 16 ++- .../tests/test_exceptions.py | 97 +++++++++++++++++++ 2 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 packages/opentelemetry-instrumentation-ollama/tests/test_exceptions.py diff --git a/packages/opentelemetry-instrumentation-ollama/opentelemetry/instrumentation/ollama/__init__.py b/packages/opentelemetry-instrumentation-ollama/opentelemetry/instrumentation/ollama/__init__.py index a688c92224..6bae5869b3 100644 --- a/packages/opentelemetry-instrumentation-ollama/opentelemetry/instrumentation/ollama/__init__.py +++ b/packages/opentelemetry-instrumentation-ollama/opentelemetry/instrumentation/ollama/__init__.py @@ -309,7 +309,13 @@ def _wrap( _handle_input(span, event_logger, llm_request_type, args, kwargs) start_time = time.perf_counter() - response = wrapped(*args, **kwargs) + try: + response = wrapped(*args, **kwargs) + except Exception as e: + span.set_status(Status(StatusCode.ERROR, str(e))) + span.record_exception(e) + span.end() + raise end_time = time.perf_counter() if response: @@ -384,7 +390,13 @@ async def _awrap( _handle_input(span, event_logger, llm_request_type, args, kwargs) start_time = time.perf_counter() - response = await wrapped(*args, **kwargs) + try: + response = await wrapped(*args, **kwargs) + except Exception as e: + span.set_status(Status(StatusCode.ERROR, str(e))) + span.record_exception(e) + span.end() + raise end_time = time.perf_counter() if response: if duration_histogram: diff --git a/packages/opentelemetry-instrumentation-ollama/tests/test_exceptions.py b/packages/opentelemetry-instrumentation-ollama/tests/test_exceptions.py new file mode 100644 index 0000000000..39d8a37d02 --- /dev/null +++ b/packages/opentelemetry-instrumentation-ollama/tests/test_exceptions.py @@ -0,0 +1,97 @@ +"""Tests that HTTP errors from the Ollama API are recorded as failed spans.""" + +import pytest +from unittest.mock import patch +from ollama._client import AsyncClient as OllamaAsyncClient +from ollama._client import Client as OllamaClient +from opentelemetry.instrumentation.ollama import OllamaInstrumentor +from opentelemetry.trace import StatusCode + + +def test_chat_exception_marks_span_error(span_exporter, tracer_provider, meter_provider): + # Patch _request on the class BEFORE instrumenting so wrapt wraps the mock. + # Call chain: ollama.chat → Client._request (wrapt wrapper) + # → _dispatch_wrap → _wrap → wrapped() = mock → raises + # → our except: set_status(ERROR), record_exception, span.end(), re-raise + with patch.object( + OllamaClient, "_request", side_effect=Exception("connection refused") + ): + instrumentor = OllamaInstrumentor() + instrumentor.instrument( + tracer_provider=tracer_provider, + meter_provider=meter_provider, + ) + try: + import ollama + + with pytest.raises(Exception, match="connection refused"): + ollama.chat( + model="llama3", + messages=[{"role": "user", "content": "Hello"}], + ) + finally: + instrumentor.uninstrument() + + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + span = spans[0] + assert span.status.status_code == StatusCode.ERROR + assert "connection refused" in span.status.description + assert any(e.name == "exception" for e in span.events) + + +def test_generate_exception_marks_span_error(span_exporter, tracer_provider, meter_provider): + with patch.object( + OllamaClient, "_request", side_effect=Exception("model not found") + ): + instrumentor = OllamaInstrumentor() + instrumentor.instrument( + tracer_provider=tracer_provider, + meter_provider=meter_provider, + ) + try: + import ollama + + with pytest.raises(Exception, match="model not found"): + ollama.generate(model="llama3", prompt="Hello") + finally: + instrumentor.uninstrument() + + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + span = spans[0] + assert span.status.status_code == StatusCode.ERROR + assert "model not found" in span.status.description + + +@pytest.mark.asyncio +async def test_async_chat_exception_marks_span_error( + span_exporter, tracer_provider, meter_provider +): + with patch.object( + OllamaAsyncClient, + "_request", + side_effect=Exception("async connection refused"), + ): + instrumentor = OllamaInstrumentor() + instrumentor.instrument( + tracer_provider=tracer_provider, + meter_provider=meter_provider, + ) + try: + import ollama + + with pytest.raises(Exception, match="async connection refused"): + await ollama.AsyncClient().chat( + model="llama3", + messages=[{"role": "user", "content": "Hello"}], + ) + finally: + instrumentor.uninstrument() + + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + span = spans[0] + assert span.status.status_code == StatusCode.ERROR + assert "async connection refused" in span.status.description + assert any(e.name == "exception" for e in span.events) From 3b51e3bc9654ef4cad0d0c94080e815271d3b165 Mon Sep 17 00:00:00 2001 From: Charan Date: Mon, 11 May 2026 23:57:42 +0530 Subject: [PATCH 2/2] fix(ollama): record error spans for exceptions during stream iteration Streaming errors raised while consuming _accumulate_streaming_response or _aaccumulate_streaming_response were not caught, leaving spans open without an error status. Wrap both generator loops in try/except/finally so that: - iteration errors set span status to ERROR and record the exception - span.end() is always called via finally (success and error paths) --- .../instrumentation/ollama/__init__.py | 176 ++++++++++-------- 1 file changed, 94 insertions(+), 82 deletions(-) diff --git a/packages/opentelemetry-instrumentation-ollama/opentelemetry/instrumentation/ollama/__init__.py b/packages/opentelemetry-instrumentation-ollama/opentelemetry/instrumentation/ollama/__init__.py index 6bae5869b3..5a7081ba85 100644 --- a/packages/opentelemetry-instrumentation-ollama/opentelemetry/instrumentation/ollama/__init__.py +++ b/packages/opentelemetry-instrumentation-ollama/opentelemetry/instrumentation/ollama/__init__.py @@ -105,49 +105,55 @@ def _accumulate_streaming_response( first_token_time = None last_response = None - for res in response: - last_response = res # Track the last response explicitly - - if first_token and streaming_time_to_first_token and start_time is not None: - first_token_time = time.perf_counter() - streaming_time_to_first_token.record( - first_token_time - start_time, - attributes={GenAIAttributes.GEN_AI_SYSTEM: "Ollama"}, + try: + for res in response: + last_response = res # Track the last response explicitly + + if first_token and streaming_time_to_first_token and start_time is not None: + first_token_time = time.perf_counter() + streaming_time_to_first_token.record( + first_token_time - start_time, + attributes={GenAIAttributes.GEN_AI_SYSTEM: "Ollama"}, + ) + first_token = False + yield res + + if llm_request_type == LLMRequestTypeValues.CHAT: + accumulated_response["message"]["content"] += res["message"]["content"] + accumulated_response["message"]["role"] = res["message"]["role"] + elif llm_request_type == LLMRequestTypeValues.COMPLETION: + text = res.get("response", "") + accumulated_response["response"] += text + + # Record streaming time to generate after the response is complete + if streaming_time_to_generate and first_token_time is not None: + model_name = last_response.get("model") if last_response else None + streaming_time_to_generate.record( + time.perf_counter() - first_token_time, + attributes={ + GenAIAttributes.GEN_AI_SYSTEM: "Ollama", + GenAIAttributes.GEN_AI_RESPONSE_MODEL: model_name, + }, ) - first_token = False - yield res - - if llm_request_type == LLMRequestTypeValues.CHAT: - accumulated_response["message"]["content"] += res["message"]["content"] - accumulated_response["message"]["role"] = res["message"]["role"] - elif llm_request_type == LLMRequestTypeValues.COMPLETION: - text = res.get("response", "") - accumulated_response["response"] += text - - # Record streaming time to generate after the response is complete - if streaming_time_to_generate and first_token_time is not None: - model_name = last_response.get("model") if last_response else None - streaming_time_to_generate.record( - time.perf_counter() - first_token_time, - attributes={ - GenAIAttributes.GEN_AI_SYSTEM: "Ollama", - GenAIAttributes.GEN_AI_RESPONSE_MODEL: model_name, - }, - ) - response_data = ( - last_response.model_dump() - if last_response and hasattr(last_response, 'model_dump') - else last_response - ) - _handle_response( - span=span, - event_logger=event_logger, - llm_request_type=llm_request_type, - token_histogram=token_histogram, - response=response_data | accumulated_response, - ) - span.end() + response_data = ( + last_response.model_dump() + if last_response and hasattr(last_response, 'model_dump') + else last_response + ) + _handle_response( + span=span, + event_logger=event_logger, + llm_request_type=llm_request_type, + token_histogram=token_histogram, + response=response_data | accumulated_response, + ) + except Exception as e: + span.set_status(Status(StatusCode.ERROR, str(e))) + span.record_exception(e) + raise + finally: + span.end() async def _aaccumulate_streaming_response( @@ -169,49 +175,55 @@ async def _aaccumulate_streaming_response( first_token_time = None last_response = None - async for res in response: - last_response = res - - if first_token and streaming_time_to_first_token and start_time is not None: - first_token_time = time.perf_counter() - streaming_time_to_first_token.record( - first_token_time - start_time, - attributes={GenAIAttributes.GEN_AI_SYSTEM: "Ollama"}, + try: + async for res in response: + last_response = res + + if first_token and streaming_time_to_first_token and start_time is not None: + first_token_time = time.perf_counter() + streaming_time_to_first_token.record( + first_token_time - start_time, + attributes={GenAIAttributes.GEN_AI_SYSTEM: "Ollama"}, + ) + first_token = False + yield res + + if llm_request_type == LLMRequestTypeValues.CHAT: + accumulated_response["message"]["content"] += res["message"]["content"] + accumulated_response["message"]["role"] = res["message"]["role"] + elif llm_request_type == LLMRequestTypeValues.COMPLETION: + text = res.get("response", "") + accumulated_response["response"] += text + + # Record streaming time to generate after the response is complete + if streaming_time_to_generate and first_token_time is not None: + model_name = last_response.get("model") if last_response else None + streaming_time_to_generate.record( + time.perf_counter() - first_token_time, + attributes={ + GenAIAttributes.GEN_AI_SYSTEM: "Ollama", + GenAIAttributes.GEN_AI_RESPONSE_MODEL: model_name, + }, ) - first_token = False - yield res - - if llm_request_type == LLMRequestTypeValues.CHAT: - accumulated_response["message"]["content"] += res["message"]["content"] - accumulated_response["message"]["role"] = res["message"]["role"] - elif llm_request_type == LLMRequestTypeValues.COMPLETION: - text = res.get("response", "") - accumulated_response["response"] += text - - # Record streaming time to generate after the response is complete - if streaming_time_to_generate and first_token_time is not None: - model_name = last_response.get("model") if last_response else None - streaming_time_to_generate.record( - time.perf_counter() - first_token_time, - attributes={ - GenAIAttributes.GEN_AI_SYSTEM: "Ollama", - GenAIAttributes.GEN_AI_RESPONSE_MODEL: model_name, - }, - ) - response_data = ( - last_response.model_dump() - if last_response and hasattr(last_response, 'model_dump') - else last_response - ) - _handle_response( - span, - event_logger, - llm_request_type, - token_histogram, - response_data | accumulated_response, - ) - span.end() + response_data = ( + last_response.model_dump() + if last_response and hasattr(last_response, 'model_dump') + else last_response + ) + _handle_response( + span, + event_logger, + llm_request_type, + token_histogram, + response_data | accumulated_response, + ) + except Exception as e: + span.set_status(Status(StatusCode.ERROR, str(e))) + span.record_exception(e) + raise + finally: + span.end() def _with_tracer_wrapper(func):