Phoenix 摄取管线:一条 span 的旅程
本章讲什么: 跟着一条 span,从它以 OTLP protobuf 字节到达,到变成数据库里的一行,中间每一步发生了什么、为什么这么设计。这是 Phoenix 的写路径,也是整个平台的心脏。
3.1 第一步:OTLP 解码——把扁平 key 还原成嵌 套结构
要解决的小问题: OpenTelemetry 的 span 属性是扁平的键值对。一个 LLM 调用会发出这样的 key:
llm.token_count.prompt = 12
llm.token_count.completion = 8
llm.input_messages.0.message.role = "user"
llm.input_messages.0.message.content = "hi"
但 Phoenix 想要的是嵌套的 JSON(llm.input_messages 是个数组,每项是个对象)。所以解码的核心就是"扁平 → 嵌套"的还原。
思路: 接收层对每个 OTLP span 调 decode_otlp_span,它把字节解成扁平 dict,再交给 unflatten 还原。
真实实现在 src/phoenix/trace/otel.py:70 decode_otlp_span,最关键的一行:
# src/phoenix/trace/otel.py:84
attributes = unflatten(load_json_strings(coerce_otlp_span_attributes(raw_attributes.items())))
它做三件事:load_json_strings 把"看起来是 JSON 字符串"的值解析回对象;unflatten 把 a.b.0.c 这种点号路径重建成 {"a": {"b": [{"c": ...}]}};最终包成一个 Span 数据类返回(otel.py:96)。
巧妙之处——"数组只用于对象"规则: 还原数组时有个陷阱。tags.0、tags.1 这种数字子键,到底是数组下标,还是恰好叫 "0"/"1" 的普通 key?Phoenix 的判断是:只有当这组项里包含 Mapping(对象)时,才当数组还原。原始基本类型数组(如一串字符串 tag)保持原样。
# src/phoenix/trace/attributes.py:149 has_mapping
def has_mapping(sequence: Iterable[Any]) -> bool:
for item in sequence:
if isinstance(item, Mapping):
return True
return False
注释说得很直白(attributes.py:156-161):OTel 语义约定里,数组通常装的是结构化对象(retrieval.documents[0]、llm.messages[1]),不是 ["tag1","tag2"] 这种基本类型数组——这个判断保证了"扁平→嵌套→再扁平"的来回转换是无损的。unflatten 本体在 attributes.py:101,用一棵 trie 来重建路径。
另一个细节——OTel gen_ai 语义的桥接: 同一段 decode_otlp_span 里,会把任何 OTel 标准的 gen_ai.* 属性合成出对应的 OpenInference 属性,但用 setdefault,所以已存在的 OpenInference key 优先(otel.py:82-83)。这让 Phoenix 既吃自家 OpenInference 探针,也吃通用 OTel gen_ai 探针。