OTLP Attribute Serialization#
Phoenix ingests spans over OTLP, where attributes arrive as a flat list of (string_key, value) pairs using dot-separated keys (e.g. "llm.token_count.completion"). Internally, Phoenix stores and works with attributes as nested dicts (e.g. {"llm": {"token_count": {"completion": 123}}}). The conversion layer lives in two files:
src/phoenix/trace/attributes.py— core conversion primitivessrc/phoenix/trace/otel.py— OTLP decode/encode pipeline that uses those primitives
Decode path: flat → nested#
decode_otlp_span in otel.py applies the following pipeline to incoming OTLP attributes:
_decode_key_values → coerce_otlp_span_attributes → load_json_strings → unflatten
-
_decode_key_values— convertsKeyValueprotobufs to(str, Any)tuples using_decode_value, which handles all OTLP scalar and container types (string, bool, int, double, array, kvlist, bytes) . -
coerce_otlp_span_attributes— converts LLM token count values toint(they may arrive as floats or strings). -
load_json_strings— for attributes whose keys end with any ofDOCUMENT_METADATA,LLM_PROMPT_TEMPLATE_VARIABLES,METADATA, orTOOL_PARAMETERS, the string value is JSON-parsed into a dict. Failures leave the raw string intact . -
unflatten— the primary conversion: builds a trie from the dot-split key pairs and walks it into a nested dict.
After unflattening, one more coercion is applied: if input.value is not a string, it is JSON-serialized and input.mime_type is automatically set to application/json .
unflatten internals: trie + indexed arrays#
unflatten is built on a two-step process :
-
_build_trie— splits each key by the separator (default.), inserting each part as a node in a_Trie. Numeric segments (all-digit key parts) are treated as array indices rather than dict keys, collected in the trie node'sindicesset. Non-numeric segments becomebranches. -
_walk— traverses the trie and materializes it: nodes withindicesbecome lists of dicts; nodes withbranchesbecome dicts.
Key rule for index promotion: A digit segment becomes an index only if the trie node at that point doesn't already have a scalar value — if a value already exists, the digit is demoted to a regular branch . This prevents ambiguous overlap between a leaf value and an indexed namespace.
Example: The pairs ("retrieval.documents.0.document.content", "A") and ("retrieval.documents.1.document.content", "B") unflatten to :
{"retrieval": {"documents": [{"document": {"content": "A"}}, {"document": {"content": "B"}}]}}
Prefix exclusions: unflatten accepts a prefix_exclusions parameter (typically populated with all OpenInference semantic convention strings, sorted longest-first). _partition_with_prefix_exclusion checks these before splitting, keeping known multi-segment attribute names (e.g. "llm.token_count.total") intact as a single trie branch rather than splitting them letter by letter.
has_mapping — a guard used by both unflatten and flatten: digit sub-keys are only promoted to array indices if the resulting array would contain dicts (not plain scalars like integers or strings).
Encode path: nested → flat#
encode_span_to_otlp reverses the process for outbound spans. For each attribute value that is a Mapping, it calls flatten with recurse_on_sequence=True. For sequences that contain mappings, flatten is also called. JSON_STRING_ATTRIBUTES values that are dicts are re-serialized to JSON strings during flattening .
flatten dispatches to _flatten_mapping or _flatten_sequence depending on input type. _flatten_sequence emits indexed keys (prefix.0.key, prefix.1.key, …) for sequences of dicts, regenerating the numeric-segment format consumed by unflatten.
Key functions reference#
| Function | File | Purpose |
|---|---|---|
unflatten | attributes.py | Flat key-value pairs → nested dict |
flatten | attributes.py | Nested dict → flat key-value pairs |
load_json_strings | attributes.py | Parse JSON string attributes into dicts |
get_attribute_value | attributes.py | Safe traversal of nested attribute dict |
has_mapping | attributes.py | Guard: sequence contains dicts? |
_build_trie | attributes.py | Trie construction with index detection |
_walk | attributes.py | Trie → nested dict materialization |
decode_otlp_span | otel.py | Full OTLP protobuf → Span decode pipeline |
encode_span_to_otlp | otel.py | Span → OTLP protobuf encode pipeline |
coerce_otlp_span_attributes | otel.py | Token count type coercion |