OpenTelemetry 与 Jaeger 数据模型转换全解:Grafana Tempo 中 jaeger translator 的字段映射规则与实现原理
【免费下载链接】tempoGrafana Tempo is a high volume, minimal dependency distributed tracing backend.项目地址: https://gitcode.com/GitHub_Trending/tempo1/tempo
本文系统讲解 OpenTelemetry 与 Jaeger 两种追踪数据模型之间的双向转换规则,核心依据为 Grafana Tempo 仓库中 vendored 的 OpenTelemetry Collector Contrib 翻译器文档 translator/jaeger/README.md,并以其源码实现为佐证。你将掌握 TraceId/SpanId、ParentId、SpanKind、Status、Attributes、Events、Links 等核心字段在 OTLP、Jaeger Thrift、Jaeger Protobuf 三种格式间的精确映射方法,并了解该翻译器在 Tempo 的 tempo-query 插件中如何被真实调用。
背景:三种可被 Jaeger 接受的 Span 格式
Jaeger 后端本身不消费 OTLP 数据,它原生接受三种格式的 Span,而这正是翻译器需要解决的核心问题:
| 格式 | 定义 | 传输方式 |
|---|---|---|
| OpenTelemetry Protocol(OTLP) | OpenTelemetry 官方协议 | 标准 OTLP 通道 |
ThriftBatch | Jaeger IDL 中定义的 Thrift 模型 | UDP 或 HTTP |
ProtobufBatch | Jaeger 模型 v2 的 Protobuf 定义 | gRPC |
本文档定义的是OTLP 与 Jaeger Span 之间的转换规则。OpenTelemetry 规范中"非 OTLP 映射"章节给出的是通用映射规则;当通用规则与本文规则冲突时,必须优先采用本文(文档)中的规则。这意味着 Jaeger 翻译器是一份"特殊优先于一般"的映射实现,任何对该实现的修改都必须遵循这一优先级原则。
在 Tempo 仓库中,这份文档与实现位于 translator/jaeger/ 目录下,包含四个核心 Go 文件:traces_to_jaegerproto.go(OTLP → Jaeger Proto)、jaegerproto_to_traces.go(Jaeger Proto → OTLP)、jaegerthrift_to_traces.go(Jaeger Thrift → OTLP)以及constants.go(共享常量)。
字段映射总览:一张表看懂核心对应关系
下表概括了 OpenTelemetry Span 与 Jaeger Thrift、Jaeger Proto 之间的主要转换关系,是理解整个翻译器行为的入口:
| OpenTelemetry | Jaeger Thrift | Jaeger Proto | 说明 |
|---|---|---|---|
| Span.TraceId | Span.traceIdLow/High | Span.trace_id | 详见 IDs 转换 |
| Span.ParentId | Span.parentSpanId | 作为 SpanReference | 详见 Parent ID 映射 |
| Span.SpanId | Span.spanId | Span.span_id | 直接映射 |
| Span.TraceState | TBD | TBD | 规范中尚未定稿 |
| Span.Name | Span.operationName | Span.operation_name | 直接映射 |
| Span.Kind | Span.tags["span.kind"] | 同上 | 值映射见 SpanKind 映射 |
| Span.StartTime | Span.startTime | Span.start_time | 时间单位见 时间单位规则 |
| Span.EndTime | Span.duration | 同上 | 计算为 EndTime − StartTime,单位规则同上 |
| Span.Attributes | Span.tags | 同上 | 数据类型映射见 Attributes 映射 |
| Span.DroppedAttributesCount | 追加到 Span.tags | 同上 | 标签名遵循 Dropped Attributes Count 约定 |
| Span.Events | Span.logs | 同上 | 映射格式见 Events 映射为 Logs |
| Span.DroppedEventsCount | 追加到 Span.tags | 同上 | 标签名遵循 Dropped Events Count 约定 |
| Span.Links | Span.references | 同上 | 见 Links 映射为引用 |
| Span.DroppedLinksCount | 追加到 Span.tags | 同上 | 标签名遵循 Dropped Links Count 约定 |
| Span.Status | 追加到 Span.tags | 同上 | 标签名见 Status 与 error 标记 |
值得注意的是:Jaeger Thrift 与 Jaeger Proto 在多数字段上是等价的,主要差异集中在 ID 表示、ParentId 编码方式与时间精度上——这正是后续小节深入展开的难点。
Resource 映射:服务身份的来源
OpenTelemetry 的Resource 必须映射为 Jaeger 的Span.Process标签。一个进程可以对应多个 Resource,导出器需要自行处理这种多对一情况(例如将多个 Resource 的标签合并到同一个 Process,或拆分到多个 Batch)。
关键在于:Jaeger 后端依赖Span.Process.ServiceName来识别产生 Span 的服务。因此该字段必须从 service Resource 的service.name属性填充;如果 Span 的 Resource 中不含service.name,则必须从 SDK 提供的默认 Resource 中获取。
在 Tempo 的实际场景中,这一映射通过resourceToJaegerProtoProcess实现:从pcommon.Resource提取属性并生成model.Process,随后由 plugin.go 中的调用者把 Batch 的 Process 挂回每个 Span,并基于ServiceName构建ProcessMap,保证 Jaeger UI 能正确按服务维度聚合。
IDs 转换:ID 的字节序与符号位处理
Trace ID 和 Span ID 在 Jaeger 中是随机字节序列。但 Thrift 模型使用i64(有符号 64 位整数)表示 ID;128 位的 Trace ID 则拆成两个i64字段traceIdLow和traceIdHigh。
转换规则有两个硬性要求:
- 字节必须按 Big Endian 字节序与无符号整数互转,例如
[0x10, 0x00, 0x00, 0x00] == 268435456; - 无符号整数必须通过"重新解释"转换为有符号
i64——即保持底层 64 位二进制不变,仅改变解读方式。
文档给出了 Go 语言的参考示例:
var ( id []byte = []byte{0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} unsigned uint64 = binary.BigEndian.Uint64(id) signed int64 = int64(unsigned) ) fmt.Println("unsigned:", unsigned) fmt.Println(" signed:", signed) // Output: // unsigned: 18374686479671623680 // signed: -72057594037927936可以看到,同一个 8 字节序列在高位为0xFF时,按uint64解读是一个巨大的正数,按int64解读则是负数——这就是"重新解释"的含义。Jaeger 翻译器在进行 Thrift 与 OTLP 互转时必须严格遵循此字节序约定,否则跨系统交换的 Trace ID 将无法对应到同一链路。
Parent ID 映射:Thrift 顶层字段与 Proto 引用
两种 Jaeger 模型对父 Span 的表示方式不同,这是最容易踩坑的差异点:
- Jaeger Thrift:允许在 Span 的顶层字段中直接保存 parent ID(
parentSpanId); - Jaeger Proto:不支持顶层 parent ID 字段,父 Span 必须记录为一条
SpanReference,其ref_type为CHILD_OF,并且这条引用必须是引用列表中的第一条。
Python 示意(对应 OpenTracing 语义约定):
SpanReference( ref_type=opentracing.CHILD_OF, trace_id=span.context.trace_id, span_id=parent_id, )源码侧的印证:在jaegerthrift_to_traces.go中,jThriftSpanParentID从 Thrift Span 的顶层字段取父 ID;而在jaegerproto_to_traces.go的jReferencesToSpanLinks中,会显式排除 parent ID(excludeParentID)后再把其余引用转换为 OTLP 的 SpanLink。这正体现了"Proto 的父关系编码在引用里,Thrift 的父关系编码在顶层字段里"这一不对称设计。
SpanKind 映射:编码为 span.kind 标签
OpenTelemetry 的SpanKind必须编码为 Jaeger Span 上的span.kind标签,唯一的例外是SpanKind.INTERNAL——它不应被翻译为任何标签(内部 Span 在 Jaeger 语义中没有对应的 kind 概念)。
| OpenTelemetry | Jaeger 值 |
|---|---|
SpanKind.CLIENT | "client" |
SpanKind.SERVER | "server" |
SpanKind.CONSUMER | "consumer" |
SpanKind.PRODUCER | "producer" |
SpanKind.INTERNAL | 不添加span.kind标签 |
反向转换时,jSpanKindToInternal会把 Jaeger 的字符串 kind 还原为 OTLP 的SpanKind;而对于缺少span.kind标签的 Jaeger Span,则默认按内部 Span 处理。这在 Tempo 场景中很常见:由 OpenTelemetry SDK 内部探针产生的 Span 通常不带 kind,转换后不会被误判为客户端或服务端调用。
时间单位规则:微秒与纳秒
两种 Jaeger 模型对时间精度的要求截然不同:
- Jaeger Thrift:时间戳和时长必须使用微秒(时间戳为自 epoch 起的微秒数)。若 OTLP 原始值是纳秒,必须四舍五入或截断到微秒;
- Jaeger Proto:时间戳和时长使用纳秒精度,通过
google.protobuf.Timestamp与google.protobuf.Duration类型表示。
这意味着 OTLP → Thrift 是有精度损失的转换(纳秒 → 微秒),而 OTLP → Proto 则无损。源码中microsecondsToUnixNano(在jaegerthrift_to_traces.go)正是 Thrift → OTLP 方向的微秒转纳秒实现;反过来,OTLP → Thrift 时需要将pcommon.Timestamp(纳秒)换算为微秒。对于需要跨格式比对开始时间与持续时间的场景(例如在 tempo-query 中同时服务 Thrift 与 Proto 两种 API),务必留意单位换算,避免毫秒级误判。
Status 与 error 标记
OTLP 的 Span Status 同样以 Span 标签的形式记录到 Jaeger,标签名遵循非 OTLP 映射规范中 Span Status 的约定。constants.go中明确固化了两个状态常量:
const ( statusError = "ERROR" statusOk = "OK" )Error 标记规则:当 Span Status 为ERROR时,必须添加一个值为布尔true的error标签,且该标签可以覆盖此前已有的任何同名值。
在jaegerproto_to_traces.go中,setInternalSpanStatus负责从 Jaeger 标签反解 OTLP Status:它会识别error标签、status.code、otel.status_code等约定键,并能从 HTTP 状态属性推导状态码(getStatusCodeFromHTTPStatusAttr依据 Span kind 与 HTTP 状态码决定是 Error 还是 Ok)。这套"标签双向传递状态"的机制,保证了跨系统时错误状态不会丢失。
Attributes 映射为 tags 标签
OTLP Span 的 Attribute(s) 必须作为 tags 上报给 Jaeger:
- 原始类型(字符串、整数、浮点、布尔)直接对应 Jaeger 标签的相应类型;
- 数组值必须按语义约定序列化为 JSON 风格的字符串(例如
[1, 2, 3])。
从实现看,attributeToJaegerProtoTag与appendTagsFromAttributes完成了从pcommon.Value到model.KeyValue的类型映射;反向的jTagsToInternalAttributes则把 Jaeger 标签还原为 OTLP 属性。对于无法直接表达的数组与 Map 类型,翻译器统一走字符串序列化通道,这是两种模型属性体系差异最小的平滑点,也是实践中最常用的映射路径。
Links 映射为 SpanReference 引用
OTLP 的 Link(s) 必须转换为 Jaeger 的SpanReference,引用类型使用FOLLOWS_FROM。由于 Jaeger 无法显式表示 Link 的属性,导出器可以额外把 Link 转换为 Span 的 Log,转换约定如下:
- 使用 Span 的开始时间作为该 Log 的时间戳;
- 设置 Log 标签
event=link; - 从对应 SpanContext 的字段中设置
trace_id和span_id两个 Log 标签; - 将 Link 的属性存储为 Log 标签。
顺序约束:由 Link 生成的 Span 引用必须添加在由 Parent ID 生成的引用(即CHILD_OF)之后。这保证了 Jaeger UI 渲染时父子关系始终优先、跨进程关联次之。反向转换时,jRefTypeToAttribute会把 Jaeger 的CHILD_OF/FOLLOWS_FROM引用类型还原为 OTLP Link 上的语义属性,便于后续查询分析。
Events 映射为 Logs 日志
OTLP Event 必须转换为 Jaeger Log:
- Event 的
time_unix_nano直接映射为 Log 的timestamp; - Event 的
attributes直接映射为 Log 的fields; - Event 的
name字段在 Jaeger Log 中没有直接对应物,但 OpenTracing 语义约定定义了特殊属性名,因此 Eventname应作为 Log 的fields项写入,键为event:
| OpenTelemetry Event 字段 | Jaeger 属性 |
|---|---|
name | event |
优先级规则:如果 Event 自身已包含键为event的属性,则该属性优先于Event 的name字段。这条规则在constants.go中以eventNameAttr = "event"常量固化,并在 Event/Log 双向转换逻辑中一致使用,确保"显式属性 > 隐式 name"的语义在转换后不产生歧义。
源码结构:翻译器的四个核心入口
从 translator/jaeger/ 目录的源码结构可以清晰看到该翻译器提供的全部公开能力,与上文各小节一一对应:
| 文件 | 公开入口 | 方向 |
|---|---|---|
traces_to_jaegerproto.go | ProtoFromTraces(td ptrace.Traces) []*model.Batch | OTLP → Jaeger Proto |
jaegerproto_to_traces.go | ProtoToTraces(batches []*model.Batch) (ptrace.Traces, error) | Jaeger Proto → OTLP |
jaegerthrift_to_traces.go | ThriftToTraces(batches *jaeger.Batch) (ptrace.Traces, error) | Jaeger Thrift → OTLP |
constants.go | statusError/statusOk/eventNameAttr等常量 | 共享映射常量 |
其中ProtoFromTraces内部通过resourceSpansToJaegerProto→resourceToJaegerProtoProcess+spanToJaegerProto完成整条资源与 Span 的转换链路;ProtoToTraces则先通过regroup/batchForProcess按进程(含对 Process 的哈希归类)重新组织 Batch,再由jSpanToInternal、jTagsToInternalAttributes、jLogsToSpanEvents逐层还原为 OTLP 结构。这种"文件职责单一、函数分层清晰"的设计,正是文档中每一条映射规则能够被精确落实为代码的原因。
实战印证:Tempo 的 tempo-query 如何消费该翻译器
在 Grafana Tempo 仓库中,该翻译器被 tempo-query 插件实际使用,用于把 Tempo 的 OTLP 查询结果转换回 Jaeger 模型、响应 Jaeger Query 的 gRPC 存储插件协议。
核心调用链位于 plugin.go 的getTrace方法中:
otTrace, err := (&ptrace.ProtoUnmarshaler{}).UnmarshalTraces(body) if err != nil { return nil, fmt.Errorf("error unmarshalling body to otlp trace %v: %w", traceID, err) } jaegerBatches := ot_jaeger.ProtoFromTraces(otTrace)具体流程为:Tempo 返回 OTLP 格式的 trace body → 用ptrace.ProtoUnmarshaler解析为 OTLP Traces → 调用ot_jaeger.ProtoFromTraces得到 Jaeger[]*model.Batch。由于文档规定 Resource 必须落在Span.Process上,而ProtoFromTraces生成的 Batch 已携带 Process,plugin.go 再逐 Span 回填s.Process = batch.Process,并依据Process.ServiceName构建ProcessMap返回给 Jaeger Query——这一步恰好印证了本文 Resource 映射 一节中"ServiceName 是 Jaeger 识别服务的关键字段"的结论。
此外,该插件同时实现了SpanReaderPluginServer、DependenciesReaderPluginServer、SpanWriterPluginServer三个 Jaeger 存储插件接口,把GetTrace/FindTraces/FindTraceIDs/GetServices/GetOperations等 Jaeger 查询语义翻译为 Tempo 的/api/traces/{traceID}、/api/search、/api/search/tag/{tag}/values等 REST 端点,构成"Jaeger UI → tempo-query → Tempo API"的完整查询链路。
小结
OpenTelemetry 与 Jaeger 的数据模型转换并不只是简单的字段改名,而是涉及字节序重解释、父引用编码差异、时间精度取舍、标签语义约定等多层细节。本文基于 Grafana Tempo 仓库内 vendored 的 翻译器文档 与 实现源码,完整梳理了 Thrift/Proto/OTLP 三格式间的全部核心映射规则,并通过 tempo-query 插件 展示了这些规则在生产链路上的真实落地方式。无论是自研导出器、排查跨系统 Trace 关联丢失问题,还是为 Tempo 扩展 Jaeger 兼容查询能力,本文的映射表与源码路径都可作为直接的参考依据。
【免费下载链接】tempoGrafana Tempo is a high volume, minimal dependency distributed tracing backend.项目地址: https://gitcode.com/GitHub_Trending/tempo1/tempo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考