From b8b0d458af6e7ee796f8d31eef55cc7f5045e038 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Tue, 16 Jun 2026 12:14:35 -0700 Subject: [PATCH] fix(otel): stamp gen_ai.input/output.messages on v2 spans (#30548) The canonical GenAI mapper's _LLM_CALL_ATTRS table had no extractors for gen_ai.input.messages or gen_ai.output.messages, so V2 LLM spans never carried prompt or completion content even when capture was enabled via OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=span_and_event. The request and response bodies were already captured onto LLMCallSpanData.messages_in and choices_out, but the mapper never read them. Add the two extractors, serializing messages_in and output_messages(d) through serialize_messages so the keys are omitted when content capture is off and the spans stay sparse. Resolves LIT-3788 --- litellm/integrations/otel/mappers/genai.py | 9 +++- .../otel/test_otel_v2_components.py | 43 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/litellm/integrations/otel/mappers/genai.py b/litellm/integrations/otel/mappers/genai.py index d4f14e97a7a..d9be68a06c2 100644 --- a/litellm/integrations/otel/mappers/genai.py +++ b/litellm/integrations/otel/mappers/genai.py @@ -10,7 +10,12 @@ table: one lambda per mapping operation, applied against the typed span data. from typing import Callable from litellm.integrations.otel.mappers.base import AttributeMap, AttrValue, SpanData -from litellm.integrations.otel.mappers.utils import collect, drop_none +from litellm.integrations.otel.mappers.utils import ( + collect, + drop_none, + output_messages, + serialize_messages, +) from litellm.integrations.otel.model.payloads import ( GuardrailSpanData, LLMCallSpanData, @@ -47,6 +52,8 @@ class GenAIMapper: else None ), GenAI.REQUEST_SEED: lambda d: d.request_params.seed, + GenAI.INPUT_MESSAGES: lambda d: serialize_messages(d.messages_in), + GenAI.OUTPUT_MESSAGES: lambda d: serialize_messages(output_messages(d)), GenAI.RESPONSE_MODEL: lambda d: d.response_model, GenAI.RESPONSE_ID: lambda d: d.response_id, GenAI.RESPONSE_FINISH_REASONS: lambda d: ( diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_components.py b/tests/test_litellm/integrations/otel/test_otel_v2_components.py index 19f4b0ff457..19eef284b91 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_components.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_components.py @@ -2,6 +2,8 @@ baggage helpers, metrics, the typed coercion helpers, mapper branches, span-name builders, and the registry validator's failure paths. Needs the OTel SDK.""" +import json + import pytest pytest.importorskip("opentelemetry") @@ -225,6 +227,47 @@ def test_genai_mapper_all_request_params(): assert attrs["server.port"] == 443 +def test_genai_mapper_stamps_input_output_messages(): + data = LLMCallSpanData( + operation=GenAIOperation.CHAT, + provider="openai", + request_model="gpt-4o", + response_model="gpt-4o-2024", + response_id="resp_1", + request_params=LLMRequestParams(), + usage=LLMUsage(), + finish_reasons=("stop",), + error=None, + response_cost=None, + server=None, + identity=RequestIdentity(call_id="c1"), + messages_in=( + {"role": "system", "content": "Be concise."}, + {"role": "user", "content": "What's the weather?"}, + ), + choices_out=( + { + "finish_reason": "stop", + "message": {"role": "assistant", "content": "Sunny."}, + }, + ), + ) + attrs = GenAIMapper().map(data) + assert json.loads(attrs[GenAI.INPUT_MESSAGES]) == [ + {"role": "system", "content": "Be concise."}, + {"role": "user", "content": "What's the weather?"}, + ] + assert json.loads(attrs[GenAI.OUTPUT_MESSAGES]) == [ + {"role": "assistant", "content": "Sunny."} + ] + + +def test_genai_mapper_omits_messages_when_content_not_captured(): + attrs = GenAIMapper().map(_full_llm_call()) + assert GenAI.INPUT_MESSAGES not in attrs + assert GenAI.OUTPUT_MESSAGES not in attrs + + def test_genai_mapper_cost_breakdown(): from litellm.integrations.otel.model.semconv import LiteLLM