Skip to content

fix(otel): emit gen_ai.input.messages and gen_ai.output.messages from the genai mapper - #30431

Closed
mateo-berri wants to merge 3 commits into
litellm_internal_stagingfrom
litellm_otel_v2_genai_messages
Closed

fix(otel): emit gen_ai.input.messages and gen_ai.output.messages from the genai mapper#30431
mateo-berri wants to merge 3 commits into
litellm_internal_stagingfrom
litellm_otel_v2_genai_messages

Conversation

@mateo-berri

@mateo-berri mateo-berri commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Relevant issues

Reported by a user testing OpenTelemetry V2 on 1.88.1 with console export: with OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=span_and_event, the canonical genai mapper never produced gen_ai.input.messages or gen_ai.output.messages, while other mappers such as openinference did

Linear ticket

Pre-Submission checklist

  • I have added meaningful tests
  • My PR passes all unit tests on make test-unit
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have requested a Greptile review by commenting @greptileai and received a Confidence Score of at least 4/5 before requesting a maintainer review

What was wrong

GenAIMapper is the always-active, canonical OTel GenAI vocabulary. It stamped request params, usage, cost, finish reasons, and tool definitions, but it never read messages_in or choices_out. So even with content capture explicitly enabled, the two semconv attributes that carry the actual prompt and completion bodies, gen_ai.input.messages and gen_ai.output.messages, were simply absent.

This was the only mapper missing message content. openinference, langfuse, weave, and langtrace all serialized messages_in / choices_out, which is why the user saw the content show up in those vocabularies but not in the canonical one. The keys themselves already existed in semconv.py (GenAI.INPUT_MESSAGES / GenAI.OUTPUT_MESSAGES); nothing ever populated them.

The fix

GenAIMapper._llm_call now serializes both message lists into the OpenTelemetry GenAI message schema: each message is {"role", "parts": [...]}, where parts are typed fragments (text, tool_call, tool_call_response), and output messages carry the choice's finish_reason. Spans cannot hold nested structures, so the list is serialized to a JSON string, which is the convention's documented fallback for span attributes. Tool-call arguments blobs are parsed back from their JSON-string form into structured objects, falling back to the raw value if they are not valid JSON.

Serialization goes through the shared json_or_none helper (the same one the other mapper utilities use), so a stray non-serializable value in a message field is stringified rather than raising a TypeError up through collect and dropping the whole span.

Chat choices carry the completion under message, but legacy /completions choices expose it as a top-level text string with no message; output serialization falls back to that text so text-completion responses still produce a populated parts list instead of an empty one.

Content stays gated exactly as before: messages_in and choices_out are populated only when capture_span_content is on (span_only / span_and_event); otherwise they arrive as empty tuples and the new attributes resolve to nothing. An operator must still opt in before any prompt or completion body leaves the process.

Example gen_ai.output.messages value for a tool-calling turn:

[{"role":"assistant","parts":[{"type":"tool_call","id":"call_42","name":"get_weather","arguments":{"city":"Paris"}}],"finish_reason":"tool_calls"}]

Screenshots / Proof of Fix

Run a proxy with content capture on and console export, then send one chat request and read the printed span:

export LITELLM_OTEL_V2=true
export OTEL_EXPORTER=console
export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=span_and_event
python litellm/proxy/proxy_cli.py --config litellm/proxy/dev_config.yaml --detailed_debug --reload --use_v2_migration_resolver 2>&1 | tee litellm.log
curl -s http://localhost:4000/v1/chat/completions \
  -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" \
  -d '{"model":"gpt-4o","messages":[{"role":"user","content":"Reply with the single word: sunny"}]}'

In the console span output, confirm the two attributes are now present (they were absent before this change):

grep -E "gen_ai.input.messages|gen_ai.output.messages" litellm.log

You should see the request and response serialized under both keys, for example gen_ai.input.messages -> [{"role":"user","parts":[{"type":"text","content":"Reply with the single word: sunny"}]}] and gen_ai.output.messages -> [{"role":"assistant","parts":[{"type":"text","content":"sunny"}],"finish_reason":"stop"}]

Type

🐛 Bug Fix

Changes

litellm/integrations/otel/mappers/genai.py: add a _BLOB_ATTRS table that maps gen_ai.input.messages / gen_ai.output.messages, plus pure helper functions that convert OpenAI-format chat messages and response choices into the OTel GenAI role/parts schema, serialized through json_or_none. _llm_call is reshaped into a no-mutation dict merge (matching the sibling vendor mappers), with tool-definition stamping pulled into a small _tools helper. Output serialization falls back to a choice's top-level text field so legacy /completions responses, which have no nested message, still emit a populated parts list.

tests/test_litellm/integrations/otel/test_otel_v2_components.py: regression tests covering input/output message serialization, the parts schema, finish_reason on output, multimodal text-only extraction, assistant tool calls with parsed arguments, tool-result responses, the capture-off path where both attributes are omitted, non-string / unparseable tool-call arguments passthrough, the choice-without-message default role, text-completion output drawn from the text field, and a non-serializable tool result surviving serialization.


Note

Medium Risk
When message content capture is enabled, prompts and completions are written to span attributes (PII exposure to observability backends); behavior is gated upstream and serialization is hardened with json_or_none.

Overview
Fixes the canonical GenAIMapper so LLM spans include gen_ai.input.messages and gen_ai.output.messages when messages_in / choices_out are populated (content capture on). Previously those semconv keys existed but were never filled, unlike other vendor mappers.

The mapper now serializes chat input and completion choices into the OTel GenAI role / parts shape (text, tool calls, tool results), JSON-stringifies the lists via json_or_none, and adds finish_reason on outputs. It handles multimodal input (text parts only), parses tool arguments JSON when possible, falls back to legacy text completions, and omits both attributes when capture leaves empty tuples.

_llm_call is refactored to merge _BLOB_ATTRS with existing attrs and a new _tools helper. Regression tests cover the main serialization paths and failure modes.

Reviewed by Cursor Bugbot for commit 636a3f2. Bugbot is set up for automated code reviews on this repo. Configure here.

… the genai mapper

The canonical GenAI mapper stamped request params, usage, cost, and tools
but never mapped messages_in or choices_out, so gen_ai.input.messages and
gen_ai.output.messages were absent even with
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT set to span_and_event.
Every vendor mapper (openinference, langfuse, weave, langtrace) already
serialized this content, leaving the canonical vocabulary the only one
without prompt/response bodies.

Serialize both into the OTel GenAI message schema (role + typed parts;
finish_reason on output) as a JSON string, the convention's documented
span fallback. Text, tool_call, and tool_call_response parts are covered,
with tool-call arguments parsed back into structured form. Content stays
gated upstream: messages_in/choices_out arrive empty unless capture is on,
so the new attributes only appear when an operator opts in.
@greptile-apps

greptile-apps Bot commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Fixes GenAIMapper — the always-active canonical OTel GenAI vocabulary — to emit gen_ai.input.messages and gen_ai.output.messages span attributes when content capture is enabled, matching what sibling mappers (openinference, langfuse, etc.) already produced.

  • Adds _BLOB_ATTRS to GenAIMapper and a set of module-level helpers that convert OpenAI-format messages/choices into the {"role", "parts": [...]} GenAI schema, serialized to a JSON string via json_or_none (which falls back to str() for non-serializable values and returns None on total failure, preventing any span crash).
  • Refactors _llm_call to a clean dict-spread merge and extracts tool-definition stamping into a small _tools helper, aligning with the style used by vendor mappers.
  • Ten new regression tests cover the parts schema, finish_reason on outputs, capture-off omission, multimodal text-only extraction, tool calls with parsed JSON arguments, tool-result messages, legacy text-completion choices, default role, and non-serializable content resilience.

Confidence Score: 5/5

Safe to merge — the change is strictly additive, gated behind the existing content-capture flag, and uses the shared json_or_none helper to prevent any serialization failure from dropping a whole span.

The new code paths are purely additive: attributes are only stamped when messages_in/choices_out are non-empty (i.e., content capture is already opted in), and the serialization is fully guarded by json_or_none with a str fallback. The refactored _llm_call preserves all previously-emitted attributes via the same collect/drop_none pipeline. Ten new tests exercise the key scenarios including edge cases and the capture-off path, and no existing tests were weakened.

No files require special attention.

Important Files Changed

Filename Overview
litellm/integrations/otel/mappers/genai.py Adds gen_ai.input.messages and gen_ai.output.messages attribute population via _BLOB_ATTRS and helper functions; refactors _llm_call to dict-spread; serialization uses json_or_none for safe fallback
tests/test_litellm/integrations/otel/test_otel_v2_components.py Adds 10 regression tests covering parts schema, finish_reason, capture-off omission, multimodal text-only extraction, tool calls with parsed arguments, tool results, legacy text field, role default, and non-serializable content resilience

Reviews (3): Last reviewed commit: "fix(otel): emit text-completion output u..." | Re-trigger Greptile

Comment thread litellm/integrations/otel/mappers/genai.py Outdated
@codecov

codecov Bot commented Jun 14, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Route the new gen_ai.input.messages / gen_ai.output.messages serialization
through the shared json_or_none helper instead of a bare json.dumps, matching
the defensive pattern the other mapper utilities already use. A stray
non-serializable value in a message field is now stringified rather than
raising a TypeError up through collect and dropping the whole span.

Add regression tests for the unparseable / non-string tool-call arguments
passthrough, the choice-without-message default role, and a non-serializable
tool result surviving serialization.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai


Generated by Claude Code

@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run


Generated by Claude Code

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Text completion output missing
    • Already fixed in commit 636a3f2 on this branch: _output_message now falls back to _text_parts(choice.get("text")) when no message body is present, covering legacy /completions choices, and a regression test was added.

You can send follow-ups to the cloud agent here.

Comment thread litellm/integrations/otel/mappers/genai.py
Legacy /completions choices expose the completion on a top-level text
field rather than a nested message, so _output_message produced empty
parts for them. Fall back to the choice text when there is no message.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai


Generated by Claude Code

@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run


Generated by Claude Code

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 636a3f2. Configure here.

@mateo-berri

Copy link
Copy Markdown
Contributor Author

superceded by #30549, #30548

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant