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
This commit is contained in:
Yassin Kortam 2026-06-16 12:14:35 -07:00 • committed by GitHub
parent 902122a06b
commit b8b0d458af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 51 additions and 1 deletions

View file

@ -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: (

View file

@ -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