Skip to content
返回博客
教程

traceparent 请求头详解:W3C Trace Context 完全指南

逐字段拆解 traceparent 请求头:四段十六进制的含义、哪些写法会被判为无效、以及链路为何在服务之间断掉。附免费在线解码器。

13 分钟

traceparent 请求头详解:W3C Trace Context 完全指南

traceparent 请求头是一行 ASCII 字符,分布式链路追踪靠它把一次请求的身份带到这次请求经过的每一个服务。在当前版本下,它的长度正好是 55 个字符,由四个用短横线分隔的字段组成:

00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
│  │                                │                │
│  │                                │                └─ trace-flags(2 位十六进制,1 字节)
│  │                                └─ parent-id     (16 位十六进制,8 字节)
│  └─ trace-id                                       (32 位十六进制,16 字节)
└─ version                                           (2 位十六进制,1 字节)

请求往下游走的时候,其中两个字段的表现截然不同。trace-id 在每一跳都保持不变,它是这次请求的名字,从边缘代理一直到最后一次数据库调用。parent-id 则每一跳都被改写,因为它标识的是调用你的那个 span(跨度),而不是整次请求。把这两者混为一谈,是「我的链路看起来不对」这类工单里相当大的一部分成因。

结构本身就这么多。字段表格讲不出来的是另外几件事:什么样的请求头算无效,合规的接收方拿到无效请求头会做什么,以及在两个都声称支持链路追踪的服务之间,这个请求头是怎么悄悄消失的。手头如果有真实的请求头,可以边读边把它粘进免费的 traceparent 解码器:它会拆开每个字段、逐位展开标志字节,并指出损坏的请求头具体踩了哪一条规则。

traceparent 请求头速览

traceparent 请求头是一个 HTTP 请求头,负责把同一条分布式链路的身份在服务之间传下去。它由四个用短横线分隔的十六进制字段组成:version、trace-id、parent-id 和 trace-flags,当前版本下总长正好 55 个字符。trace-id 标识整次请求,parent-id 标识调用你的那个 span。

字段十六进制位数字节数标识什么每跳是否变化?
version21后面的字段遵循哪个格式版本。目前恒为 00
trace-id3216端到端的整次请求
parent-id168调用方的那个 span(调用者的 span ID)
trace-flags21一个 8 位字段,第 0 位是 sampled很少

52 个十六进制字符加上三个短横线,正好 55 个字符。版本为 00 时,任何其他长度都是无效的,而长度恰恰是肉眼最容易核对的一项。

请求头里的所有内容都是小写十六进制。这里说的不是「十六进制,大小写不敏感」那种松散约定,规范明确要求小写。W3C Trace Context 规范的语法只接受 0-9a-f,别的一律不认,所以一个数值完全正确、只是写成了大写的 trace ID,到了下游照样被丢掉。

逐字段拆解

四个字段的宽度、无效取值和出错方式各不相同。

version:为什么不能写死成 00

今天版本字节是 00,而且短期内还会是 00。但 ff 被明确禁止:规范把它保留为无效值,所以以 ff 开头的请求头,不管后面写得多标准,一到就死。

更要紧的规则管的是那些你还没见过的版本。写成 if (version !== '00') reject() 的解析器是错的,而且错得很贵。规范要求接收方在版本号更高、且请求头长度不短于已知格式时尝试解析:读走你认识的字段,容忍末尾多出来的数据,然后继续。反过来直接拒绝,意味着只要上游有任何一方升级,你的服务就成了链路中断、新链路另起的那个分界点。

// 错误:让你的服务成为链路的葬身之地
if (version !== '00') throw new Error('bad traceparent');

// 正确:解析你能看懂的那段前缀
if (version !== '00' && header.length >= 55) {
  // 读取 version、trace-id、parent-id、trace-flags;忽略其余部分
}

trace-id,16 字节的请求身份

32 个小写十六进制字符,在整条链路的生命周期内保持不变。不管最初是哪个服务生成的它,之后每一跳都原样往下传。你在可观测性后端里搜一条链路时,粘进搜索框的就是这串字符。

它的取值受两条规则约束:必须是 32 个十六进制字符,且不能全为零。00000000000000000000000000000000 不表示「一条还没有数据的链路」;规范把它点名为无效值,并要求接收方忽略整个请求头。实际场景里,全零的 trace ID 通常意味着某个 SDK 从未初始化,或者某段中间件因为拿不到真实上下文而塞了个占位值进去。

trace-id 是 128 位,和 UUID 一样宽,但它不是 UUID。里面没有版本位、没有变体位、没有短横线,也没有任何结构可言:就是 16 个不透明的字节。你没法从中解析出一个 v4;反过来,把 UUID 的短横线去掉也不会自动变成合法的 trace-id,因为版本和变体那几个半字节让它的随机性并不均匀。想看清 UUID 在这 128 位里究竟占用了什么,UUID 到底编码了哪些信息一文梳理了完整布局,而 UUID 生成器会把版本位和变体位直接标在原处。

parent-id:调用你的那个 span,8 字节

16 个十六进制字符,每一跳都会被重写。这个字段本身不复杂,惹麻烦的是它的名字:W3C 规范叫它 parent-id,OpenTelemetry 把同样这 8 个字节叫做 span ID(跨度 ID),两者是同一样东西的两个观察角度。站在你的服务这边看,它是父级;站在调用方那边看,它是对方刚刚为这次出站请求创建的那个 span 的 ID。

所以当服务 A 调用服务 B 时,A 把自己的 span ID 填进 parent-id 这一格。B 随后创建一个子 span,等 B 去调用 C,填进去的就换成了 B 的 span ID。全程 trace-id 不动。这就是整个传播算法的全部内容。

全零的 parent-id 同样无效,理由和 trace-id 一致:0000000000000000 说明调用方并没有给出一个真实的 span,这时应该丢弃整个请求头,而不是勉强接受一半。

trace-flags 看着像布尔值,其实是八个比特位

你见到的绝大多数请求头都以 01 结尾,所以很自然会把这个字段当成「是 / 否」。但它是一个字节,各比特位都有明确分工:

  • 第 0 位,掩码 0x01sampled(已采样)
  • 第 1 位,掩码 0x02random-trace-id,由 Trace Context Level 2 新增
  • 第 2–7 位:保留位,接收时忽略,出站请求时清零

各种组合解码出来是这样:

十六进制二进制sampledrandom-trace-idflags === 0x01 成立吗?
0000000000
0100000001
0200000010
0300000011否 ← 问题就出在这里

把最后一行再看一遍。标志位为 03 的链路是已采样的。任何拿整个字节去和 01 比较的代码,都会把它判成未采样,而且是静默判错,只影响恰好设置了 Level 2 标志的那部分流量。这是最糟糕的一种故障形态:它看上去像采样率配置有问题,而不像解析代码有 bug。

const flags = parseInt(traceFlags, 16);

// 错误:把位字段当成了枚举值
const sampled = traceFlags === '01';

// 正确
const sampled       = (flags & 0x01) !== 0;
const randomTraceId = (flags & 0x02) !== 0;

random-trace-id 到底断言了什么?断言 trace-id 至少最右边 7 个字节是用均匀随机数生成的。这听着很学院派,直到你考虑一致性采样:如果下游系统想保留 1% 的链路,并且需要每个服务各自独立地对「哪 1%」得出相同结论,那它可以直接对这几个字节取模,而不必先对整个 ID 做哈希。这个标志位就是上游给出的「这么做是安全的」承诺。

什么样的 traceparent 算无效

语法表到处都能查到,拒收条件却很少有人列全。版本 00 请求头的完整拒收清单如下:

现象规则结果
00-4BF92F35...-01语法只接受小写十六进制无效(数值是对的,请求头照样被拒)
ff-...规范禁止使用版本 ff无效
trace-id 为 00000000000000000000000000000000全零 trace-id 是被点名的无效值无效
parent-id 为 0000000000000000全零 parent-id 是被点名的无效值无效
trace-id 不是 32 个十六进制字符定长无效
parent-id 不是 16 个十六进制字符定长无效
trace-flags 不是 2 个十六进制字符定长无效
版本为 00,长度却不是正好 55 个字符只有未来版本才允许尾部有额外数据无效
出现 0-9a-f 和短横线之外的任何字符不是十六进制无效

被拒之后会发生什么:

合规的接收方不会修复一个无效的 traceparent 请求头,也不会把它继续往下传。它会丢弃这个请求头,用新生成的 trace-id 另起一条全新的链路。

也就是说,你在屏幕上看到的症状并不是一条断掉的链路,而是两条互不相连的短链路:一条在发出坏请求头的那个服务处戛然而止,另一条则在接收方那里凭空开始。整个过程没有任何地方被标记为错误。两条链路单独看都很健康。人们会花一整个下午去找它们之间缺失的那一环,而答案往往是某段中间件把十六进制串转成了大写,或者某个手工拼出来的请求头只有 54 个字符。

长度和大小写,是两种盯着看也看不出来的故障模式。把请求头粘进解码器,它会直接点出踩到的是哪一条规则,省得你一个一个数字符。

tracestate:搭档请求头,规则比看上去多

traceparent 携带标准化的身份信息,tracestate 请求头则用来携带各家厂商想附带的任何东西,格式是逗号分隔的 key=value 成员:

tracestate: rojo=00f067aa0ba902b7,congo=t61rcWkgMzE

遇到不认识的 key,实现方必须原样转发。这正是整个设计的目标:厂商可以把私有状态搭载在标准链路上,而不要求每一跳都看得懂它。

不过这套语法管得比看上去严,有几条规则能解释线上真实出现过的现象。

32 个列表成员是硬上限。 这条上限写在语法里,不是什么建议:list = list-member 0*31( OWS "," OWS list-member )。有 33 个成员的 tracestate 不是「多了一条记录的 tracestate」,而是一个无效请求头,接收方有权把整个头丢掉。有一种看起来像魔法的现象正好由此解释:厂商数据在边缘还在,往里走两跳还在,到第五跳彻底不见了。每一跳都在往里追加自己的成员,列表越过 32 之后,整个请求头被整体丢弃,而不是被裁剪。

值的长度是 1 到 256 个字符,永远不能为空。 值的产生式以一个必需的非空白字符结尾,所以 vendor= 不是「一个没有值的 key」,而是一处语法错误。值里只能出现可打印 ASCII 字符,且绝不能包含逗号或等号。

key 的语法在 Level 1 和 Level 2 之间变过。 Level 1 通过 tenant@vendor 这一产生式来定义 key,其中 @ 是结构性分隔符。Level 2 把它换成了一个扁平的字符集:key 以小写字母或数字开头,后续字符可以是 a-z0-9_-*/@。在 Level 2 下,@ 只是个普通字符,key 可以以数字开头,而 a@b@c 是一个完全合法的 key,按 Level 1 的产生式则会被拒。如果你的代理按 Level 1 校验、服务却按 Level 2 生成 key,就会出现一边接受另一边拒收的局面,请求头恰好在某一跳消失。

还有两条容易漏掉。一是重复的 key 直接判为无效。二是改了 traceparent 的 parent-id,就必须把自己的 tracestate 条目移到列表最前面,这个列表按「最近的在前」排序。省掉这一步,陈旧的厂商状态就留在了列表头部,读取方会把它当成当前值。

最后一条比较宽松:空的列表成员是合法的。中间设备删掉某个条目时,常常会把逗号留下,于是变成 rojo=1,,congo=2。规范明确允许这种写法,所以解析器应该跳过空成员继续往下走,而不是判定请求头格式错误。解码器里的 tracestate 视图会逐条列出每个成员并逐条校验,同时实时给出相对 32 个成员上限的计数,通常比人肉数逗号快得多。

分布式链路追踪请求头是怎么传下去的:一次请求,四跳

跟着一次请求,走过边缘代理、API 服务和两个下游服务:

客户端
  │  (没有 traceparent——边缘就是根节点)

边缘代理        生成 trace-id 4bf9…4736,span 00f0…02b7
  │  traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01

API 服务        读取它,创建 span a1b2c3d4e5f60718
  │  traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-a1b2c3d4e5f60718-01

订单服务        读取它,创建 span 9f8e7d6c5b4a3928
  │  traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-9f8e7d6c5b4a3928-01

库存服务

每一跳都做同样的三件事:读取入站请求头;为每次出站调用把 parent-id 换成自己的 span ID;原样转发 trace-id 和标志位。如果根本没有入站请求头(比如上面那个客户端),接收方就是根节点:由它生成 trace-id,并为整条下游链路做出采样决定。

你可以手工注入一个请求头,端到端地测试整条链路:

curl -sS -o /dev/null -w '%{http_code}\n' \
  -H 'traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' \
  -H 'tracestate: rojo=00f067aa0ba902b7,congo=t61rcWkgMzE' \
  https://example.com/api

把线上抓到的请求头拿到预发环境重放一次,就能在后端看到同一个 trace-id 出现。如果还要加认证或请求体,cURL 命令生成器可以帮你拼好各项参数;调试时会用到的请求头和详细输出选项,curl 命令速查表里都有。

想知道服务实际收到了什么、而不是你以为自己发了什么,可以起一个用完即弃的回显服务器,把某一跳指向它:

python3 - <<'PY'
from http.server import BaseHTTPRequestHandler, HTTPServer

class Echo(BaseHTTPRequestHandler):
    def do_GET(self):
        for name, value in self.headers.items():
            print(f"{name}: {value}")
        self.send_response(200)
        self.end_headers()
        self.wfile.write(b"ok\n")

HTTPServer(("127.0.0.1", 8080), Echo).serve_forever()
PY

然后执行 curl -H 'traceparent: …' http://127.0.0.1:8080/,看看另一头出来的是什么。「代理把我的请求头吃了」这类排查,有一半到这里就结束了。

trace-flags 的 sampled 位是上游的决定,不是一张回执

trace-flags 里 sampled 位为 1,意思是上游服务决定记录这条链路。它并不保证数据真的到达了你的后端。

头部采样(head-based sampling)在根节点、在任何事情发生之前就做出决定,然后一路往下传:成本低,跨服务一致,但也是盲的,它无法预知这次请求即将失败。尾部采样(tail-based sampling)会把 span 缓冲到链路结束再做决定,因此可以保留所有包含错误的链路,代价是要把 span 存在内存里,并且要求所有服务的 span 都落到同一个采集器。

在尾部采样下,一条链路可以在每一跳都带着 01,最后仍然被丢弃。限流和导出配额同样会丢数据。所以「边缘是 01、界面上却查不到链路」未必是传播出了问题;先去看采集器自己的丢弃指标,再回头查请求头。

反过来的情况在日常里更常见。如果入站标志位是 00,说明调用方跑过自己的采样器并选择不记录。你的服务没有任何配置问题,去审自己的采样器纯属浪费时间;该问的是上游哪个服务在决定不采样。

在各种传播格式之间转换

W3C Trace Context 赢了,但仍有大量系统说着更老的方言,网关则在中间做翻译。同一个 traceparent 示例,写成四种格式是这样:

格式请求头示例对应的值
W3Ctraceparent00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
B3 单请求头b34bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-1
B3 多请求头X-B3-TraceIdX-B3-SpanIdX-B3-Sampled4bf92f3577b34da6a3ce929d0e0e473600f067aa0ba902b71
Datadogx-datadog-trace-idx-datadog-parent-id_dd.p.tid 标签11803532876627986230676679744482843434bf92f3577b34da6
AWS X-RayX-Amzn-Trace-IdRoot=1-4bf92f35-77b34da6a3ce929d0e0e4736;Parent=00f067aa0ba902b7;Sampled=1

Datadog:高低 64 位的拆分

Datadog 的标识符比 128 位 trace ID 出现得更早,而它的兼容层正是大多数转换出错的地方。x-datadog-trace-id 以十进制字符串携带 64 位; 64 位则单独走,以十六进制形式放在 _dd.p.tid 标签里,而这个标签本身是随 x-datadog-tags 请求头传输的。

const traceparent = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01';
const [, traceId, parentId] = traceparent.split('-');

const datadogTraceId  = BigInt('0x' + traceId.slice(16)).toString(10);
const higher64Hex     = traceId.slice(0, 16);
const datadogParentId = BigInt('0x' + parentId).toString(10);

console.log('x-datadog-trace-id:',  datadogTraceId);  // 11803532876627986230
console.log('x-datadog-tags:',      higher64Hex);     // _dd.p.tid=4bf92f3577b34da6
console.log('x-datadog-parent-id:', datadogParentId); // 67667974448284343

经典错误是把全部 128 位转成一个十进制数字:

BigInt('0x' + traceId).toString(10);
// 100985939111033328018442752961257817910 —— 在界面里什么都匹配不到

这个值本身算得没错。它是「错误的那个量」的正确十进制表示,也正因为如此,它能顺利通过代码评审,然后悄无声息地匹配到零条链路。

第二个陷阱是数值精度。64 位标识符超过了 Number.MAX_SAFE_INTEGER,也就是 9007199254740991,所以任何让 trace ID 变成 JavaScript number 的代码路径,都会破坏它的低位数字。把 trace ID 一律当字符串保存,只有确实需要做算术时才动用 BigInt;如果一个 ID 在 JSON 里是不带引号传过来的,那你看到它的时候它已经坏了。

AWS X-Ray:那个并不存在的时间戳

X-Ray 的 trace ID 长成 1-{8 hex}-{24 hex} 这样,开头 8 个十六进制字符是以 epoch 秒表示的创建时间。从 W3C 转换过来是纯机械操作:

const traceId = '4bf92f3577b34da6a3ce929d0e0e4736';
const epochHex     = traceId.slice(0, 8);              // 4bf92f35
const epochSeconds = parseInt(epochHex, 16);           // 1274621749
const xrayId       = `1-${epochHex}-${traceId.slice(8)}`;
// 1-4bf92f35-77b34da6a3ce929d0e0e4736
// X-Amzn-Trace-Id: Root=1-4bf92f35-77b34da6a3ce929d0e0e4736;Parent=00f067aa0ba902b7;Sampled=1

new Date(epochSeconds * 1000).toISOString();           // 2010-05-23T13:35:49.000Z

看看这个日期。规范里的示例请求头解出来是 2010 年 5 月,显然是胡说八道,而这恰恰是重点。W3C 的 trace-id 里没有时间戳。 16 个随机字节,你把前 4 个当成一个整数去读,它照样能给出一个看起来很像样的 epoch 值;除非这个标识符真的出自 X-Ray,否则这个数字毫无意义。从任意 trace-id 里解出时间,等于读了个随机数然后信以为真。

如果这个 ID 确实来自 X-Ray,那这个转换就很有用:把那 8 个十六进制字符丢进 Unix 时间戳转换器就能得到可读日期,而时间戳完全指南覆盖了随之而来的秒与毫秒之分、以及时区方面的坑。

B3:来自 Zipkin 的血统

B3 出自 Zipkin,是你在较老的服务网格里会遇到的格式。单请求头形式是 traceId-spanId-sampled,其中 sampled 字段是 10,而不是一个十六进制字节,于是 Level 2 的 random-trace-id 位无处安放,转换过程中直接丢失。多请求头形式把同样的值拆到 X-B3-TraceIdX-B3-SpanIdX-B3-Sampled 里。

历史遗留的麻烦在于宽度。B3 的 trace ID 可能是 64 位的,也就是 16 个十六进制字符而非 32 个。把 64 位的 B3 ID 转成 W3C,需要在左边补零凑够 32 位;反向转换则要决定是否截断。左侧补零是安全的,截断不安全:两条只在高位字节上有差异的链路会被压成同一条。

traceparent 在生产环境里会在哪儿丢失

以上所有内容都假设请求头能送达。现实里它常常送不到。

浏览器在跨域调用时把它丢掉

现象: 前端有链路,后端也有链路,但两者对不上。或者跨域请求直接以 CORS 错误告终。

成因: traceparent 是自定义请求头,加上它会让请求变成非简单请求,从而触发 OPTIONS 预检。如果服务端的预检响应没有在 Access-Control-Allow-Headers 里列出这个头,浏览器就会拦下真正的请求。另外,OpenTelemetry 的浏览器端探针默认不会往跨域请求里注入链路请求头,除非你先告诉它哪些源是允许的。

修法: 服务端在预检响应里返回 Access-Control-Allow-Headers: traceparent, tracestate;浏览器 SDK 里把 propagateTraceHeaderCorsUrls 设成能匹配你 API 源的模式。两者缺一不可,只做一边症状不变。如果预检返回的状态码不在意料之中,先对着 HTTP 状态码速查表核对一下,再去怀疑请求头。

代理、WAF 和负载均衡器会剥掉不认识的请求头

现象: 直接 curl 服务时请求头在,同样的请求走网关就没了。

成因: 基于允许清单的转发。大量代理配置、WAF 规则集和托管负载均衡器只转发自己认识的请求头,而 traceparent 不在默认清单里。还有一些服务网格会改写这个请求头:生成自己的 trace-id,把你的丢掉。

修法: 用前面那个回显服务器做二分定位,依次把它挂到每一跳后面,看是哪一层把请求头丢了。然后在那一层的转发规则里显式放行 traceparenttracestate。如果代理是 nginx,还要注意:决定放行哪些请求头的是处理该路由的那个配置块,而实际处理某条路由的块未必是你以为的那个;Nginx location 优先级规则能解释为什么一段请求头配置看起来会被完全忽略。这类问题在全链路追踪里的表现就是调用链断裂:上游有 span,下游另起一条,断点就在中间那一跳的代理配置上。

消息队列没有 HTTP 请求头

现象: 请求一旦变成后台任务,链路当场结束。

成因: 跨过那道边界时根本没有 HTTP 请求,也就没有承载请求头的地方。Kafka 有记录头(record headers),SQS 有消息属性(message attributes),但 HTTP 探针都不会替你把它们填上。

修法: 在生产者侧把上下文注入消息,在消费者侧再取出来。每个 OpenTelemetry SDK 都为此提供了 injectextract,传输格式仍是同一串 W3C 字符串,变的只是载体:从 HTTP 请求头映射换成了消息元数据。各语言的载体接口可以查 OpenTelemetry 上下文传播文档

大小写,以及 HTTP/2 到底把什么转成了小写

现象: 代码评审时争论不清 Traceparent 这种写法到底行不行。

成因: 两条互不相干的规则被混成了一条。HTTP/1.1 的请求头名字大小写不敏感,HTTP/2 则要求名字在传输时必须以小写编码,这两条说的都是名字。与之无关的另一条是:请求头里的十六进制必须小写,因为 W3C 语法就这么规定,没有哪个协议版本会替你把这件事纠正过来。

修法: 名字一律写作 traceparent,值一律不要大写。会规范化请求头名字的网关,不会顺手规范化你的十六进制字符;一个大写的 trace-id 会一路顺畅地穿过所有传输层,直到最后真正解析它的那个应用把它拒掉。

入站的 traceparent 该不该信?

从公网进来的 traceparent 是用户可控输入,一串由匿名客户端自行决定的字符,而大多数服务想都不想就收下了。

由此带来三种风险。第一是链路拼接:攻击者把在别处观察到的 trace-id 发过来,就能让自己的请求被缝进一条已有链路里,既污染调用图,也可能把内部耗时暴露给任何能读到这条链路的人。第二是配额燃烧:把 01 写死,等于强制每个请求都被采样,一波不算大的流量洪峰会变成一张非常大的数据摄入账单,更糟的情况是把你真正需要的链路挤掉。第三是跨租户关联:不同租户的请求复用同一个 trace-id,会把本不相干的记录串到一起,而你的工具链会把它们当成同一次逻辑操作。

务实的立场是:边缘照收,但不轻信。校验语法,把格式错误的请求头拒掉,而不是让它继续往内网传。对于未认证流量,不要沿用入站的标志位,而是重新跑一遍自己的采样决策,这样外部客户端就无法把你的采样器钉死在「永远记录」上。对于已认证流量,沿用调用方的决定通常没问题,因为你知道对方是谁。

还有,把 trace-id 当成公开信息。它从来就不是秘密:它会出现在日志里、错误页面上、响应头中,以及粘进工单的截图里。不要往里面编码用户 ID、租户名或任何有含义的东西,也永远不要拿它当授权凭据。它只是一个关联标识符,也只应该是这个。

常见问题

traceparent 和 tracestate 有什么区别?

traceparent 携带标准化的身份信息,也就是 trace-id、parent-id 和采样标志位,每个实现都必须能读懂它。tracestate 携带厂商私有状态,不认识它的实现原样转发即可。两者是绑定的:当 traceparent 无效时,规范要求把 tracestate 一并忽略。

为什么我的链路在调用链中途又重新开始了?

链路在调用链中途重新开始,几乎总是因为某一跳收到的请求头没通过语法校验,于是把它丢掉并生成了新的 trace-id。大写十六进制、全零 trace-id、长度不是正好 55 个字符,都会导致这个结果。如果请求头本身没问题,接下来的嫌疑对象是剥掉它的代理,以及失败的跨域预检。

从浏览器发送 traceparent 需要配置 CORS 吗?

需要,CORS 必须配置。traceparent 是自定义请求头,会让请求变成非简单请求并触发预检,服务端必须在 Access-Control-Allow-Headers 里列出 traceparent。此外 OpenTelemetry 的浏览器端探针还需要配置 propagateTraceHeaderCorsUrls,因为它默认不会向跨域请求注入链路请求头。

怎么把链路上下文透传过 Kafka 或 SQS?

在生产者侧把 traceparent 的值写进 Kafka 的记录头或 SQS 的消息属性,在消费者侧再读出来以恢复上下文。各语言的 OpenTelemetry SDK 都提供了 injectextract。格式不变,变的只是载体,不再是 HTTP 请求头映射。

trace ID 可以放心地出现在日志或响应里吗?

可以,trace ID 放进日志或响应是安全的。它是一个随机标识符,既不含身份信息,也不具备授权能力。但它确实能跨系统关联记录,所以永远不要把用户 ID 或租户名编码进去,也永远不要拿它当作任何事情的凭证。把它当成公开的关联键来对待,记录、返回和分享都是安全的。

traceparent 请求头是谁生成的?

traceparent 由链路上第一个服务生成,也就是收到请求时发现没有这个请求头的那一跳,通常是边缘代理、API 网关或者浏览器端 SDK。它因此成为整条链路的根节点:生成 trace-id,创建第一个 span,做出采样决定;之后的每一跳只改写 parent-id。

traceparent 请求头是必须的吗?

traceparent 在协议层面是可选的,不带它的请求同样完全合法,接收方直接成为一条新链路的根节点。说它必须,只在实用意义上成立:没有它,跨服务边界做的那些工作就没办法被关联进同一条链路。

traceparent 会带来明显的额外开销吗?

基本没有。traceparent 是 55 字节,tracestate 通常再多出几百字节,和一次 TLS 握手或任何真实负载相比都可以忽略。链路追踪真正的成本在于导出和存储被采样的 span,而不是在链路上多带几个分布式追踪请求头。

标签: distributed-tracing opentelemetry observability http-headers w3c