GenAI to OpenInference Attribute Mapping#
Phoenix automatically converts OpenTelemetry gen_ai.* semantic convention attributes to the OpenInference llm.*, retrieval.*, and tool.* attribute namespaces during OTLP span ingestion. This lets spans emitted by OTel-native GenAI instrumentors (Anthropic, Google, OpenAI, Vercel AI SDK, etc.) appear in Phoenix with full message I/O, tool calls, and retrieval documents — without requiring the client to emit OpenInference attributes directly.
The feature was introduced in PR #13267 (merged 2026-05-15, released in arize-phoenix 15.10.0).
Ingestion Pipeline#
The conversion runs inside decode_otlp_span() in src/phoenix/trace/otel.py. The pipeline processes raw OTLP key-value pairs before they are stored:
OTLP attributes
→ _decode_key_values() # protobuf KeyValue → (str, Any) tuples
→ coerce_otlp_span_attributes()# token count type coercion
→ load_json_strings() # parse METADATA, TOOL_PARAMETERS, etc. from JSON strings
→ [gen_ai conversion] # synthesize OI attrs from gen_ai.* via setdefault merge
→ unflatten() # dot-notation flat dict → nested dict
→ Span
The synthesis step calls get_openinference_attributes(raw_attributes) from src/phoenix/trace/gen_ai/conversion.py, then merges the result back with OI-wins precedence :
for key, value in get_openinference_attributes(raw_attributes).items():
raw_attributes.setdefault(key, value)
setdefault means any llm.* key already written by the client is preserved; the synthesized value fills only absent keys. A hot-path bail short-circuits the entire conversion for spans with no gen_ai.* attributes (HTTP, DB spans) via a single any() check.
Attribute Mappings#
All conversion logic lives in src/phoenix/trace/gen_ai/conversion.py. The top-level get_openinference_attributes() delegates to focused sub-functions:
OTel gen_ai.* attribute | OpenInference attribute | Notes |
|---|---|---|
gen_ai.operation.name | openinference.span.kind | chat/text_completion/generate_content → LLM; invoke_agent → AGENT; execute_tool → TOOL; embeddings → EMBEDDING; retrieval → RETRIEVER |
gen_ai.provider.name / gen_ai.system | llm.provider + llm.system | Legacy gen_ai.system still handled for backward compat |
gen_ai.request.model | llm.model_name (or embedding.model_name for EMBEDDING spans) | |
gen_ai.request.temperature, top_p, max_tokens, etc. | llm.invocation_parameters (JSON) | Packed into a single JSON blob |
gen_ai.usage.input_tokens | llm.token_count.prompt | |
gen_ai.usage.output_tokens | llm.token_count.completion | |
gen_ai.usage.cache_read_input_tokens | llm.token_count.prompt_details.cache_read | |
gen_ai.usage.cache_creation_input_tokens | llm.token_count.prompt_details.cache_write | |
gen_ai.system_instructions | llm.input_messages.0 (role=system) | Shifts all input message indexes up by 1 |
gen_ai.input.messages | llm.input_messages.{n} | Index starts at 1 when system_instructions present |
gen_ai.output.messages | llm.output_messages.{n} | |
gen_ai.tool.definitions | llm.tools.{n}.tool.json_schema | Rewrapped into OpenAI-style JSON schema |
gen_ai.retrieval.query_text | input.value | Only on RETRIEVER spans |
gen_ai.retrieval.documents | retrieval.documents.{n}.* | |
gen_ai.conversation.id | session.id | |
gen_ai.response.id, gen_ai.response.model | output.value (JSON) | Packed together as JSON response payload |
gen_ai.tool.name, gen_ai.tool.call.id, gen_ai.tool.call.arguments, gen_ai.tool.call.result | tool.name, tool.id, tool.parameters, output.value | Only on TOOL spans |
Message Flattening#
Each message is validated against the generated Pydantic v2 models (ChatMessage, OutputMessage) and then flattened to dot-notation by _flatten_message(). Part types handled: TextPart, UriPart, BlobPart (rendered as data URLs), ToolCallRequestPart, ToolCallResponsePart. Multi-part messages use the message.contents.{i}.* path; single-text messages use the simpler message.content scalar.
The Pydantic models are generated from OTel GenAI semconv JSON schemas via make gen-otel-models and live in src/phoenix/trace/gen_ai/__generated__/models.py. Validation is all-or-nothing per payload: a single malformed item drops the entire list .
Known Bugs and Limitations#
Merge Corruption on Dual-Emitted Spans#
The most impactful known bug. When the client SDK emits a partial OpenInference mapping (e.g. via @arizeai/openinference-genai) AND Phoenix ingest synthesizes its own mapping from gen_ai.*, the per-key setdefault merge interleaves two incompatible index spaces and corrupts the stored messages.
Root cause: gen_ai.system_instructions is projected to llm.input_messages.0 (system role) by the server, shifting user/assistant turns to index 1+. The JS package @arizeai/openinference-genai ignores gen_ai.system_instructions entirely and places the user message at index 0. The setdefault merge then:
llm.input_messages.0.message.role=user(client wins, correct index from client)llm.input_messages.0.message.content=<system prompt>(server fills the "gap", wrong)llm.input_messages.1.message.role=user(server, duplicate)
Result in the UI: the system prompt appears as a user message; the user message appears twice.
Suggested fix (not yet merged): treat indexed message namespaces atomically — if the span already has any key under llm.input_messages., skip all synthesized keys for that namespace rather than gap-filling per key.
_flatten_message Silently Drops ReasoningPart#
The _flatten_message function handles TextPart, UriPart, BlobPart, ToolCallRequestPart, and ToolCallResponsePart — but has no branch for ReasoningPart. A reasoning part (e.g. from @ai-sdk/otel with a reasoning-capable model) falls through all branches and is silently dropped from llm.output_messages.*. The generated models do define ReasoningPart in the parts union; only the flattener is missing the case.
Google Instrumentor Schema Drift#
opentelemetry-instrumentation-google-genai uses the v1.30-era schema (data field instead of content, no modality field). The v1.41 BlobPart model rejects these parts → they fall through to GenericPart → dropped in _flatten_message. Images vanish from llm.input_messages for Gemini traces. A TODO block at conversion.py:445 documents three remediation options.
All-or-Nothing Message Validation#
_validate_root_list() uses pydantic-core's model_validate_json fast path: a single malformed message item causes the entire list (all input or output messages) to be dropped silently.
Key Files and References#
| File | Purpose |
|---|---|
src/phoenix/trace/gen_ai/conversion.py | Main conversion logic: get_openinference_attributes(), _flatten_message(), _flatten_document(), all sub-converters |
src/phoenix/trace/gen_ai/__generated__/models.py | Auto-generated Pydantic v2 models for OTel GenAI semconv v1.41 (ChatMessage, OutputMessage, TextPart, BlobPart, ReasoningPart, etc.) |
src/phoenix/trace/otel.py | decode_otlp_span() — the ingestion entry point where the conversion is wired in |
scripts/generate_otel_gen_ai_models.py | Codegen script run via make gen-otel-models; merges five OTel GenAI semconv JSON schemas into the single models file |
tests/unit/trace/gen_ai/test_conversion.py | 119 unit tests covering the conversion logic |
Open issues:
- phoenix#14961 — per-key setdefault merge corrupts dual-emitted spans
- phoenix#14962 —
_flatten_messagesilently dropsReasoningPart - openinference#3471 — JS
@arizeai/openinference-genaidoesn't mapgen_ai.system_instructions