Skip to content

🐛 Bug Report: failed calls are missing from gen_ai.client.operation.duration or recorded without error.type (Google GenAI, Bedrock, Anthropic, OpenAI streams, LiteLLM) #4590

Open

Description

@armanunix

Which component is this bug for?

Google Generative AI Instrumentation

📜 Description

The OpenTelemetry GenAI semantic conventions make gen_ai.client.operation.duration a required metric, with error.type conditionally required "if the operation ended in an error", so every failed call is expected to produce a duration point with error.type. Several OpenLLMetry instrumentations miss failures:

Package Failure What is recorded
google-generativeai generate_content fails (HTTP 500) no duration point
google-generativeai streaming call fails duration point without error.type
bedrock invoke_model / converse (and streaming variants) fail no duration point
anthropic stream fails mid-way (sync and async) no duration point
openai chat stream fails mid-way (sync and async) duration point without error.type
litellm completion / embedding fails (HTTP 500), or a stream fails no duration point

For comparison, a request that fails before streaming is recorded correctly by the OpenAI and Anthropic instrumentations (one point with error.type), and the OpenTelemetry GenAI instrumentations record a point with error.type in all of the cases above.

Code paths (0.62.4):

  • google-generativeai _wrap / _awrap (__init__.py, lines 340 and 261): the except block records the exception on the span, ends it and re-raises; the duration is only recorded after a successful call.
  • bedrock _instrumented_model_invoke, _instrumented_converse and the streaming/async variants (e.g. __init__.py line 239): the except block sets error.type on the span and re-raises; the duration is recorded later in _handle_call, which a failed call never reaches. The only duration-with-error.type code (around line 205) wraps client creation, not model calls.
  • anthropic stream __next__ / __anext__ (streaming.py, lines 250 and 382): on an exception they add to the exception counter and re-raise; the duration is only recorded in _complete_instrumentation, after a successful end.
  • openai ChatStream.__next__ / __anext__ (shared/chat_wrappers.py, line 734): on an exception _ensure_cleanup() -> _record_partial_metrics() records the duration with _shared_attributes() only, without error.type.
  • litellm _handle_exception (__init__.py, line 630) only records the exception counter; _LiteLLMStream._finish_error likewise.

As a result, error-rate and latency views built on gen_ai.client.operation.duration miss or misclassify these failures.

👟 Reproduction steps

Google GenAI and Bedrock (fail before any response):

import json
import httpx, boto3
from botocore.awsrequest import AWSResponse
from botocore.config import Config
from google import genai
from google.genai import types
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import InMemoryMetricReader
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.instrumentation.google_generativeai import GoogleGenerativeAiInstrumentor
from opentelemetry.instrumentation.bedrock import BedrockInstrumentor

reader = InMemoryMetricReader()
tp, mp = TracerProvider(), MeterProvider(metric_readers=[reader])
GoogleGenerativeAiInstrumentor().instrument(tracer_provider=tp, meter_provider=mp)
BedrockInstrumentor().instrument(tracer_provider=tp, meter_provider=mp)

def duration_points():
    data = reader.get_metrics_data()
    return sum(p.count for rm in (data.resource_metrics if data else []) for sm in rm.scope_metrics
               for m in sm.metrics if m.name == "gen_ai.client.operation.duration" for p in m.data.data_points)

def check(label, call):
    before = duration_points()
    try:
        call(); outcome = "ok"
    except Exception as e:
        outcome = type(e).__name__
    print(f"{label:28} {outcome:24} new duration points: {duration_points() - before}")

gclient = genai.Client(api_key="x", http_options=types.HttpOptions(httpx_client=httpx.Client(
    transport=httpx.MockTransport(lambda r: httpx.Response(
        500, json={"error": {"code": 500, "message": "boom", "status": "INTERNAL"}})))))
check("google generate_content", lambda: gclient.models.generate_content(model="gemini-2.0-flash", contents="hello"))

class Raw:
    def __init__(self, d): self.d = d
    def stream(self, *a, **k): yield self.d
    def read(self, amt=None): return self.d
    def close(self): pass
    release_conn = close

bclient = boto3.client("bedrock-runtime", region_name="us-east-1", aws_access_key_id="x",
                       aws_secret_access_key="x", config=Config(retries={"total_max_attempts": 1}))
bclient.meta.events.register("before-send.bedrock-runtime.*", lambda request, **kw: AWSResponse(
    request.url, 500, {"content-type": "application/json", "x-amzn-errortype": "InternalServerException"},
    Raw(json.dumps({"message": "boom"}).encode())))
model = "anthropic.claude-3-haiku-20240307-v1:0"
check("bedrock invoke_model", lambda: bclient.invoke_model(modelId=model, body=json.dumps(
    {"anthropic_version": "bedrock-2023-05-31", "max_tokens": 50, "messages": [{"role": "user", "content": "hi"}]})))
check("bedrock converse", lambda: bclient.converse(modelId=model,
                                                   messages=[{"role": "user", "content": [{"text": "hi"}]}]))

OpenAI and Anthropic (the stream breaks after its first event):

import json
import httpx, openai, anthropic
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import InMemoryMetricReader
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.instrumentation.openai import OpenAIInstrumentor
from opentelemetry.instrumentation.anthropic import AnthropicInstrumentor

reader = InMemoryMetricReader()
tp, mp = TracerProvider(), MeterProvider(metric_readers=[reader])
OpenAIInstrumentor().instrument(tracer_provider=tp, meter_provider=mp)
AnthropicInstrumentor().instrument(tracer_provider=tp, meter_provider=mp)

def duration_points():
    out = {}
    data = reader.get_metrics_data()
    for rm in (data.resource_metrics if data else []):
        for sm in rm.scope_metrics:
            for m in sm.metrics:
                if m.name == "gen_ai.client.operation.duration":
                    for p in m.data.data_points:
                        k = p.attributes.get("error.type") or "no error.type"
                        out[k] = out.get(k, 0) + p.count
    return out

def broken(hx, first):        # sends the first SSE event, then the connection breaks
    class S(hx.SyncByteStream):
        def __iter__(self):
            yield first
            raise hx.ReadError("connection broken")
    return hx.MockTransport(lambda r: hx.Response(200, headers={"content-type": "text/event-stream"}, stream=S()))

def check(label, call):
    before = duration_points()
    try:
        call(); outcome = "ok"
    except Exception as e:
        outcome = type(e).__name__
    after = duration_points()
    print(label, outcome, {k: c - before.get(k, 0) for k, c in after.items() if c != before.get(k, 0)} or "NONE")

chunk = {"id": "c1", "object": "chat.completion.chunk", "created": 0, "model": "gpt-4o-mini",
         "choices": [{"index": 0, "delta": {"role": "assistant", "content": "hi"}, "finish_reason": None}]}
oc = openai.OpenAI(api_key="x", max_retries=0, http_client=httpx.Client(
    transport=broken(httpx, f"data: {json.dumps(chunk)}\n\n".encode())))
check("openai chat stream fails mid-way:", lambda: list(oc.chat.completions.create(
    model="gpt-4o-mini", messages=[{"role": "user", "content": "hello"}], stream=True)))

import anthropic._base_client as bc
hx = getattr(bc, "httpx2", None) or httpx          # newer anthropic SDKs use httpx2
start = {"type": "message_start", "message": {"id": "m1", "type": "message", "role": "assistant", "model": "claude-x",
         "content": [], "stop_reason": None, "stop_sequence": None, "usage": {"input_tokens": 7, "output_tokens": 0}}}
ac = anthropic.Anthropic(api_key="x", max_retries=0, http_client=hx.Client(
    transport=broken(hx, f"event: message_start\ndata: {json.dumps(start)}\n\n".encode())))
check("anthropic stream fails mid-way:", lambda: list(ac.messages.create(
    model="claude-x", max_tokens=50, messages=[{"role": "user", "content": "hello"}], stream=True)))

LiteLLM (HTTP 500):

import os
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
import httpx, litellm, openai
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import InMemoryMetricReader
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.instrumentation.litellm import LiteLLMInstrumentor

reader = InMemoryMetricReader()
LiteLLMInstrumentor().instrument(tracer_provider=TracerProvider(), meter_provider=MeterProvider(metric_readers=[reader]))

def duration_points():
    data = reader.get_metrics_data()
    return sum(p.count for rm in (data.resource_metrics if data else []) for sm in rm.scope_metrics
               for m in sm.metrics if m.name == "gen_ai.client.operation.duration" for p in m.data.data_points)

client = openai.OpenAI(api_key="x", max_retries=0, http_client=httpx.Client(transport=httpx.MockTransport(
    lambda r: httpx.Response(500, json={"error": {"message": "boom", "type": "server_error"}}))))
for label, call in (("litellm completion", lambda: litellm.completion(model="openai/gpt-4o-mini", client=client, api_key="x",
                                                                       messages=[{"role": "user", "content": "hello"}])),
                    ("litellm embedding", lambda: litellm.embedding(model="openai/text-embedding-3-small", client=client,
                                                                     api_key="x", input=["hello"]))):
    before = duration_points()
    try:
        call(); outcome = "ok"
    except Exception as e:
        outcome = type(e).__name__
    print(f"{label:28} {outcome:24} new duration points: {duration_points() - before}")

👍 Expected behavior

Every call, successful or not, records one gen_ai.client.operation.duration point, and a failed call (including a stream that fails mid-way) carries error.type (e.g. ServerError, InternalServerException, ReadError), as these instrumentations already do for requests that fail before streaming.

👎 Actual Behavior with Screenshots

Google GenAI (google-genai 2.29.0, opentelemetry-instrumentation-google-generativeai 0.62.4):
  models.generate_content          ok           duration points: +1 (no error.type)
  models.generate_content          ServerError  duration points: NONE
  models.generate_content_stream   ok           duration points: +1 (no error.type)
  models.generate_content_stream   ServerError  duration points: +1 (no error.type)

Bedrock (boto3 1.43.111, opentelemetry-instrumentation-bedrock 0.62.4):
  invoke_model   ok                        duration points: +1 (no error.type)
  invoke_model   InternalServerException   duration points: NONE
  converse       ok                        duration points: +1 (no error.type)
  converse       InternalServerException   duration points: NONE

OpenAI chat.completions.create(stream=True) (openai 2.54.0, opentelemetry-instrumentation-openai 0.62.4):
  sync  fails before streaming (HTTP 500)  InternalServerError  duration points: +1 (InternalServerError)
  async fails before streaming (HTTP 500)  InternalServerError  duration points: +1 (InternalServerError)
  sync  stream fails mid-way               ReadError            duration points: +1 (no error.type)
  async stream fails mid-way               ReadError            duration points: +1 (no error.type)

Anthropic messages.create(stream=True) (anthropic 1.13.0, opentelemetry-instrumentation-anthropic 0.62.4):
  sync  fails before streaming (HTTP 500)  InternalServerError  duration points: +1 (InternalServerError)
  async fails before streaming (HTTP 500)  InternalServerError  duration points: +1 (InternalServerError)
  sync  stream fails mid-way               ReadError            duration points: NONE
  async stream fails mid-way               ReadError            duration points: NONE

LiteLLM (litellm 1.104.2, opentelemetry-instrumentation-litellm 0.62.4):
  completion, stream whose connection drops    MidStreamFallbackError  duration points: NONE
  completion rejected by the server (HTTP 500) InternalServerError     duration points: NONE

🤖 Python Version

3.12

📃 Provide any additional context for the Bug.

All five packages at 0.62.4 (the latest release), with the SDK versions listed above.

👀 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

  1. shantam77 commented on Oct 11, 2026

    @shantam77

    I would like to work on this.

    I just worked in that package for #4607

  2. armanunix commented on Oct 11, 2026

    @armanunix
    Author

    The same in opentelemetry-instrumentation-ollama 0.62.4 (ollama 0.6.3): a failed chat / generate records no gen_ai.client.operation.duration point, while a successful one records one. In _wrap / _awrap the duration is only recorded after a successful wrapped(...) call; the open PR #4127 sets the error status on the span but still records no duration for the failure.

    import httpx, ollama
    from opentelemetry.sdk.metrics import MeterProvider
    from opentelemetry.sdk.metrics.export import InMemoryMetricReader
    from opentelemetry.sdk.trace import TracerProvider
    from opentelemetry.instrumentation.ollama import OllamaInstrumentor
    
    reader = InMemoryMetricReader()
    OllamaInstrumentor().instrument(tracer_provider=TracerProvider(), meter_provider=MeterProvider(metric_readers=[reader]))
    
    def points():
        data = reader.get_metrics_data()
        return sum(p.count for rm in (data.resource_metrics if data else []) for sm in rm.scope_metrics
                   for m in sm.metrics if m.name == "gen_ai.client.operation.duration" for p in m.data.data_points)
    
    STATUS = [200]
    def respond(request):
        if STATUS[0] != 200:
            return httpx.Response(STATUS[0], json={"error": "mock failure"})
        return httpx.Response(200, json={"model": "m", "created_at": "2026-01-01T00:00:00Z", "done": True, "done_reason": "stop",
                                         "message": {"role": "assistant", "content": "Hello"}, "prompt_eval_count": 3, "eval_count": 1})
    
    client = ollama.Client(host="http://mock", transport=httpx.MockTransport(respond))
    for status in (200, 500):
        STATUS[0] = status; before = points()
        try:
            client.chat(model="m", messages=[{"role": "user", "content": "hi"}]); outcome = "ok"
        except ollama.ResponseError:
            outcome = "ResponseError"
        print(f"HTTP {status}: {outcome:14s} new duration points: {points() - before}")
    HTTP 200: ok             new duration points: 1
    HTTP 500: ResponseError  new duration points: 0
    
  3. jay0237 commented on Oct 13, 2026

    @jay0237

    Hello maintainers! It would be great to work on the Ollama-specific part of the issue.

    I have created a local patch for adding a metric gen_ai.client.operation.duration to failed synchronous and asynchronous chat and generate calls. Also, I have added regression tests for the successful case, failing case, and stream.

    Ollama package tests and Ruff linter are passed locally. I have seen that there is another developer who wants to fix this problem too, so I wanted to coordinate before creating the PR.

    Is it possible for me to create a PR for the Ollama-specific part of the issue or maybe somebody else is working on this?

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