diff --git a/litellm/integrations/otel/emitter.py b/litellm/integrations/otel/emitter.py index 6feaf2734e9..a1fb0b1bf23 100644 --- a/litellm/integrations/otel/emitter.py +++ b/litellm/integrations/otel/emitter.py @@ -10,6 +10,7 @@ from opentelemetry.trace.status import Status, StatusCode from litellm.integrations.otel.model.config import OpenTelemetryV2Config from litellm.integrations.otel.mappers import resolve_mappers from litellm.integrations.otel.mappers.base import AttributeMapper, SpanData +from litellm.integrations.otel.model.events import llm_message_events from litellm.integrations.otel.model.payloads import ( GuardrailSpanData, LLMCallSpanData, @@ -165,6 +166,14 @@ class SpanEmitter: for mapper in self._mappers: for key, value in mapper.map(data).items(): span.set_attribute(key, value) + # The event half of ``capture_message_content`` (``event_only`` / + # ``span_and_event``): record prompt/response bodies as span events so + # they reach the configured trace exporter even when content is kept off + # the span attributes (``event_only``). Span-attribute content is the + # mappers' job above and is gated separately by ``content_on_span``. + if self._config.capture_event_content and isinstance(data, LLMCallSpanData): + for name, attributes in llm_message_events(data): + span.add_event(name, attributes) error = ( data.error if isinstance( diff --git a/litellm/integrations/otel/logger.py b/litellm/integrations/otel/logger.py index 79931c0796c..b44722b2c89 100644 --- a/litellm/integrations/otel/logger.py +++ b/litellm/integrations/otel/logger.py @@ -321,7 +321,9 @@ class OpenTelemetryV2(CustomLogger): carrier.span.end(end_time=to_ns(end_time)) return None data = LLMCallSpanData.from_standard_logging_payload( - payload, capture_content=self.config.capture_span_content + payload, + capture_content=self.config.capture_content, + content_on_span=self.config.capture_span_content, ) end_time_ns = to_ns(end_time) if carrier.span is not None: diff --git a/litellm/integrations/otel/mappers/genai.py b/litellm/integrations/otel/mappers/genai.py index d9be68a06c2..2c4c9e134b4 100644 --- a/litellm/integrations/otel/mappers/genai.py +++ b/litellm/integrations/otel/mappers/genai.py @@ -52,8 +52,10 @@ 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.INPUT_MESSAGES: lambda d: serialize_messages(d.span_messages_in), + GenAI.OUTPUT_MESSAGES: lambda d: serialize_messages( + output_messages(d.span_choices_out) + ), 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/litellm/integrations/otel/mappers/langfuse.py b/litellm/integrations/otel/mappers/langfuse.py index 14c9fd01d05..95da1a28077 100644 --- a/litellm/integrations/otel/mappers/langfuse.py +++ b/litellm/integrations/otel/mappers/langfuse.py @@ -57,8 +57,10 @@ class LangfuseMapper: "langfuse.observation.model.parameters": lambda d: json_if( collect(LangfuseMapper._MODEL_PARAMS, d.request_params) ), - "langfuse.observation.input": lambda d: serialize_messages(d.messages_in), - "langfuse.observation.output": lambda d: serialize_messages(output_messages(d)), + "langfuse.observation.input": lambda d: serialize_messages(d.span_messages_in), + "langfuse.observation.output": lambda d: serialize_messages( + output_messages(d.span_choices_out) + ), "langfuse.observation.usage_details": lambda d: json_if( collect(LangfuseMapper._USAGE_FIELDS, d.usage) ), diff --git a/litellm/integrations/otel/mappers/langtrace.py b/litellm/integrations/otel/mappers/langtrace.py index 7c0f30e57dd..8e1c08d0ff5 100644 --- a/litellm/integrations/otel/mappers/langtrace.py +++ b/litellm/integrations/otel/mappers/langtrace.py @@ -42,10 +42,12 @@ class LangtraceMapper: _BLOB_ATTRS: dict[str, Callable[[LLMCallSpanData], AttrValue | None]] = { "llm.prompts": lambda d: ( - json_or_none(list(d.messages_in)) if d.messages_in else None + json_or_none(list(d.span_messages_in)) if d.span_messages_in else None ), "llm.completions": lambda d: ( - json_or_none(output_messages(d)) if d.choices_out else None + json_or_none(output_messages(d.span_choices_out)) + if d.span_choices_out + else None ), } diff --git a/litellm/integrations/otel/mappers/openinference.py b/litellm/integrations/otel/mappers/openinference.py index d8195cbe03d..9274f9557db 100644 --- a/litellm/integrations/otel/mappers/openinference.py +++ b/litellm/integrations/otel/mappers/openinference.py @@ -82,9 +82,11 @@ class OpenInferenceMapper: return { **collect(cls._LLM_CALL_ATTRS, data), **collect(cls._BLOB_ATTRS, data), - **cls._messages("llm.input_messages", "input.value", data.messages_in), + **cls._messages("llm.input_messages", "input.value", data.span_messages_in), **cls._messages( - "llm.output_messages", "output.value", output_messages(data) + "llm.output_messages", + "output.value", + output_messages(data.span_choices_out), ), **cls._tools(data), } diff --git a/litellm/integrations/otel/mappers/utils.py b/litellm/integrations/otel/mappers/utils.py index 6228fc8bbe7..b9712289d9e 100644 --- a/litellm/integrations/otel/mappers/utils.py +++ b/litellm/integrations/otel/mappers/utils.py @@ -9,7 +9,6 @@ import json from typing import Callable, Mapping, Sequence from litellm.integrations.otel.mappers.base import AttributeMap, AttrValue -from litellm.integrations.otel.model.payloads import LLMCallSpanData def drop_none(values: Mapping[str, AttrValue | None]) -> AttributeMap: @@ -71,6 +70,6 @@ def message_content(message: object) -> str | None: return None -def output_messages(data: LLMCallSpanData) -> list: +def output_messages(choices: Sequence[Mapping[str, object]]) -> list[object]: """The ``message`` payload of each response choice.""" - return [c.get("message") for c in data.choices_out if isinstance(c, dict)] + return [c.get("message") for c in choices] diff --git a/litellm/integrations/otel/mappers/weave.py b/litellm/integrations/otel/mappers/weave.py index 54b07299271..8df8c5a2c0a 100644 --- a/litellm/integrations/otel/mappers/weave.py +++ b/litellm/integrations/otel/mappers/weave.py @@ -29,7 +29,7 @@ class WeaveMapper: _BLOB_ATTRS: dict[str, Callable[[LLMCallSpanData], AttrValue | None]] = { # Weave treats the response choices as the "output" payload. "weave.output": lambda d: ( - json_or_none(list(d.choices_out)) if d.choices_out else None + json_or_none(list(d.span_choices_out)) if d.span_choices_out else None ), } diff --git a/litellm/integrations/otel/model/config.py b/litellm/integrations/otel/model/config.py index a109ba898ff..b362f4ed043 100644 --- a/litellm/integrations/otel/model/config.py +++ b/litellm/integrations/otel/model/config.py @@ -288,6 +288,30 @@ class OpenTelemetryV2Config(BaseSettings): CaptureMessageContent.SPAN_AND_EVENT, ) + @property + def capture_event_content(self) -> bool: + """Whether prompt/response content may be emitted as span events. + + The event half of the OTel ``ContentCapturingMode`` contract: under + ``event_only`` and ``span_and_event`` the message bodies are recorded as + ``gen_ai.*`` events on the LLM-call span, complementing (or, for + ``event_only``, standing in for) the ``gen_ai.input.messages`` / + ``gen_ai.output.messages`` span attributes that ``capture_span_content`` + governs. Defaults off, like its span counterpart. + """ + return self.capture_message_content in ( + CaptureMessageContent.EVENT_ONLY, + CaptureMessageContent.SPAN_AND_EVENT, + ) + + @property + def capture_content(self) -> bool: + """Whether message bodies are retained at all — as span attributes, span + events, or both. The payload parser keeps the raw prompt/response only + when this is true, so a request can never force content past either sink + while every capture mode is off.""" + return self.capture_span_content or self.capture_event_content + @classmethod def from_env(cls) -> "OpenTelemetryV2Config": return cls() diff --git a/litellm/integrations/otel/model/events.py b/litellm/integrations/otel/model/events.py new file mode 100644 index 00000000000..91e89409246 --- /dev/null +++ b/litellm/integrations/otel/model/events.py @@ -0,0 +1,73 @@ +"""GenAI message events: the event half of ``capture_message_content``. + +The OTel GenAI ``ContentCapturingMode`` records prompt/response content either as +span attributes (``gen_ai.input.messages`` / ``gen_ai.output.messages``, owned by +the mappers) or as events, or both. This module owns the event representation: +one ``gen_ai.{role}.message`` event per request message and one ``gen_ai.choice`` +event per response choice, stamped on the LLM-call span so they ride whatever +trace exporter the operator already configured — the console exporter included — +without a separate logs pipeline. +""" + +from typing import Mapping, cast + +from litellm.integrations.otel.mappers.base import AttributeMap +from litellm.integrations.otel.mappers.utils import drop_none, message_content +from litellm.integrations.otel.model.payloads import LLMCallSpanData +from litellm.integrations.otel.model.semconv import GenAI, GenAIEvent +from litellm.integrations.otel.model.utils import as_str + + +def _role(message: object) -> str | None: + """The chat-message ``role``, or ``None`` when absent or not a dict.""" + if not isinstance(message, dict): + return None + role = cast(Mapping[str, object], message).get("role") + return role if isinstance(role, str) and role else None + + +def llm_message_events( + data: LLMCallSpanData, +) -> tuple[tuple[str, AttributeMap], ...]: + """``(event_name, attributes)`` pairs for an LLM call's prompt and response. + + One ``gen_ai.{role}.message`` event per request message and one + ``gen_ai.choice`` per response choice, each carrying the provider, role, and + (when present) the textual content. The caller stamps them as span events. + """ + provider = data.provider or None + inputs = tuple(_input_event(provider, m) for m in data.messages_in) + outputs = tuple( + _choice_event(provider, idx, c) for idx, c in enumerate(data.choices_out) + ) + return inputs + outputs + + +def _input_event( + provider: str | None, message: Mapping[str, object] +) -> tuple[str, AttributeMap]: + role = _role(message) or "user" + attrs = drop_none( + { + GenAI.PROVIDER_NAME: provider, + "role": role, + "content": message_content(message), + } + ) + return GenAIEvent.message(role), attrs + + +def _choice_event( + provider: str | None, index: int, choice: Mapping[str, object] +) -> tuple[str, AttributeMap]: + message = choice.get("message") + attrs = drop_none( + { + GenAI.PROVIDER_NAME: provider, + "index": index, + "finish_reason": as_str(choice.get("finish_reason")), + "role": _role(message), + "content": message_content(message), + } + ) + return GenAIEvent.CHOICE, attrs diff --git a/litellm/integrations/otel/model/payloads.py b/litellm/integrations/otel/model/payloads.py index 82b7df5922c..0ea321cce5e 100644 --- a/litellm/integrations/otel/model/payloads.py +++ b/litellm/integrations/otel/model/payloads.py @@ -308,11 +308,31 @@ class LLMCallSpanData: # dataclass stays hashable and frozen. messages_in: tuple[Mapping[str, object], ...] = () choices_out: tuple[Mapping[str, object], ...] = () + # Whether the retained bodies may be stamped as span *attributes*. False + # under ``event_only`` capture, where the bodies are kept for span events + # only and must not leak onto ``gen_ai.input.messages`` / ``...output...`` + # (or any vendor vocabulary's) span attributes. + content_on_span: bool = True system_fingerprint: str | None = None + @property + def span_messages_in(self) -> tuple[Mapping[str, object], ...]: + """Request messages exposed to span-attribute mappers — empty when the + captured content is bound to events only.""" + return self.messages_in if self.content_on_span else () + + @property + def span_choices_out(self) -> tuple[Mapping[str, object], ...]: + """Response choices exposed to span-attribute mappers — empty when the + captured content is bound to events only.""" + return self.choices_out if self.content_on_span else () + @classmethod def from_standard_logging_payload( - cls, payload: "StandardLoggingPayload", capture_content: bool = False + cls, + payload: "StandardLoggingPayload", + capture_content: bool = False, + content_on_span: bool | None = None, ) -> "LLMCallSpanData": params = cast(Mapping[str, object], payload.get("model_parameters") or {}) # The single parse of the request's metadata — the request-vs-provider @@ -328,9 +348,12 @@ class LLMCallSpanData: choices_out = _dicts(response.get("choices")) # ``finish_reasons`` is metadata, not content, so derive it from # ``choices_out`` before gating. The raw message/choice bodies are only - # retained when content capture is enabled (see ``capture_span_content``); - # otherwise the content-bearing mappers receive empty sequences and emit - # no prompt/response text. + # retained when content capture is enabled — for span attributes, span + # events, or both (see ``capture_content``). ``content_on_span`` then + # decides whether the bodies reach span attributes; under ``event_only`` + # they are retained but kept off the span, for events alone. With capture + # off the content-bearing mappers receive empty sequences and emit no + # prompt/response text. finish_reasons = _finish_reasons(choices_out) return cls( operation=resolve_operation(as_str(payload.get("call_type"))), @@ -356,6 +379,9 @@ class LLMCallSpanData: tools=_extract_tools(params), messages_in=_dicts(payload.get("messages")) if capture_content else (), choices_out=choices_out if capture_content else (), + content_on_span=( + capture_content if content_on_span is None else content_on_span + ), system_fingerprint=as_str(response.get("system_fingerprint")), ) diff --git a/litellm/integrations/otel/model/semconv.py b/litellm/integrations/otel/model/semconv.py index 6315a5a4a89..363d4687220 100644 --- a/litellm/integrations/otel/model/semconv.py +++ b/litellm/integrations/otel/model/semconv.py @@ -92,6 +92,20 @@ class GenAI: PROMPT_NAME: Final = "gen_ai.prompt.name" +class GenAIEvent: + """OTel GenAI event names. Prompt/response content is recorded as these + events on the LLM-call span under the ``event_only`` / ``span_and_event`` + capture modes — the event half of ``ContentCapturingMode``.""" + + CHOICE: Final = "gen_ai.choice" + + @staticmethod + def message(role: str) -> str: + """Per-message event name, e.g. ``gen_ai.user.message``. The standard + roles (system/user/assistant/tool) yield the canonical semconv names.""" + return f"gen_ai.{role}.message" + + class MCP: """OTel GenAI MCP (Model Context Protocol) span-attribute keys. 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 19eef284b91..0840fcfb74d 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_components.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_components.py @@ -268,6 +268,67 @@ def test_genai_mapper_omits_messages_when_content_not_captured(): assert GenAI.OUTPUT_MESSAGES not in attrs +def _captured_llm_call(content_on_span=True): + return 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."}, + }, + ), + content_on_span=content_on_span, + ) + + +def test_genai_mapper_withholds_span_content_when_event_only(): + """``content_on_span=False`` (the ``event_only`` mode) keeps retained bodies + off the span attributes even though they're present for the event path.""" + attrs = GenAIMapper().map(_captured_llm_call(content_on_span=False)) + assert GenAI.INPUT_MESSAGES not in attrs + assert GenAI.OUTPUT_MESSAGES not in attrs + + +def test_llm_message_events_carry_role_and_content(): + from litellm.integrations.otel.model.events import llm_message_events + + events = llm_message_events(_captured_llm_call()) + names = [name for name, _ in events] + assert names == ["gen_ai.system.message", "gen_ai.user.message", "gen_ai.choice"] + + by_name = {name: attrs for name, attrs in events} + assert by_name["gen_ai.system.message"]["role"] == "system" + assert by_name["gen_ai.system.message"]["content"] == "Be concise." + assert by_name["gen_ai.user.message"]["content"] == "What's the weather?" + choice = by_name["gen_ai.choice"] + assert choice["index"] == 0 + assert choice["finish_reason"] == "stop" + assert choice["role"] == "assistant" + assert choice["content"] == "Sunny." + assert choice[GenAI.PROVIDER_NAME] == "openai" + + +def test_llm_message_events_empty_without_bodies(): + from litellm.integrations.otel.model.events import llm_message_events + + assert llm_message_events(_full_llm_call()) == () + + def test_genai_mapper_cost_breakdown(): from litellm.integrations.otel.model.semconv import LiteLLM diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_logger.py b/tests/test_litellm/integrations/otel/test_otel_v2_logger.py index 0ceb7efbe0b..cb43f7c1fae 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_logger.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_logger.py @@ -238,6 +238,98 @@ def test_idempotent_on_repeat_callback(): assert len(exporter.get_finished_spans()) == 1 +# --------------------------------------------------------------------------- # +# Message-content capture modes (issue #30956) +# --------------------------------------------------------------------------- # + + +def _logger_for_mode(mode): + cfg = OpenTelemetryV2Config( + exporter="in_memory", + legacy_compat=False, + capture_message_content=mode, + ) + exporter = InMemorySpanExporter() + tracer_provider = providers.build_tracer_provider(cfg, exporter=exporter) + return OpenTelemetryV2(config=cfg, tracer_provider=tracer_provider), exporter + + +def _content_payload(): + return _payload( + messages=[ + {"role": "system", "content": "Be concise."}, + {"role": "user", "content": "What's the capital of France?"}, + ], + response={ + "id": "resp_1", + "model": "gpt-4o-2024", + "choices": [ + { + "finish_reason": "stop", + "message": {"role": "assistant", "content": "Paris."}, + } + ], + }, + ) + + +def _emit_content_span(mode): + logger, exporter = _logger_for_mode(mode) + _emit_llm(logger, _kwargs(_content_payload())) + (span,) = exporter.get_finished_spans() + return span + + +def test_span_and_event_emits_both_attributes_and_events(): + """Regression for #30956: ``span_and_event`` must put message content on the + span attributes AND emit semantic events. Before the fix, only the span + attributes were produced and no events were emitted.""" + span = _emit_content_span("span_and_event") + attrs = dict(span.attributes or {}) + + assert "What's the capital of France?" in attrs[GenAI.INPUT_MESSAGES] + assert "Paris." in attrs[GenAI.OUTPUT_MESSAGES] + + events = {e.name: dict(e.attributes or {}) for e in span.events} + assert set(events) == { + "gen_ai.system.message", + "gen_ai.user.message", + "gen_ai.choice", + } + assert events["gen_ai.user.message"]["content"] == "What's the capital of France?" + assert events["gen_ai.choice"]["content"] == "Paris." + assert events["gen_ai.choice"]["finish_reason"] == "stop" + + +def test_event_only_emits_events_without_span_content(): + """Regression for #30956: ``event_only`` must still capture content — as + events — instead of dropping it. Before the fix this mode produced neither + span attributes nor events, so the content was lost entirely.""" + span = _emit_content_span("event_only") + attrs = dict(span.attributes or {}) + + assert GenAI.INPUT_MESSAGES not in attrs + assert GenAI.OUTPUT_MESSAGES not in attrs + + events = {e.name for e in span.events} + assert events == {"gen_ai.system.message", "gen_ai.user.message", "gen_ai.choice"} + + +def test_span_only_emits_no_message_events(): + span = _emit_content_span("span_only") + attrs = dict(span.attributes or {}) + assert GenAI.INPUT_MESSAGES in attrs + assert [e for e in span.events if e.name.startswith("gen_ai.")] == [] + + +def test_no_content_emits_neither_attributes_nor_events(): + span = _emit_content_span("no_content") + attrs = dict(span.attributes or {}) + assert GenAI.INPUT_MESSAGES not in attrs + assert GenAI.OUTPUT_MESSAGES not in attrs + assert [e for e in span.events if e.name.startswith("gen_ai.")] == [] + + # --------------------------------------------------------------------------- # # MCP tool-call spans # --------------------------------------------------------------------------- #