W3C TraceContext 规范
2020 年 W3C 发布了 TraceContext 推荐标准——HTTP header 怎么写、怎么解析。全行业(Microsoft、Google、Datadog、Honeycomb)达成共识,现在是事实标准。
GET /api/orders HTTP/1.1 Host: inventory.internal traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 tracestate: vendor1=value1,vendor2=value2 baggage: userId=u_123,tenantId=acme
三个 header:
version-trace_id-parent_id-flags。这就是 SpanContext 序列化。traceparent 拆解
00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 │ │ │ │ │ │ │ └─ trace flags(01 = sampled) │ │ └─ parent span_id(上游调用者的 span) │ └─ trace_id(整条 trace 唯一) └─ version(00,目前唯一版本)
接收端解析完后,把它作为当前 active context,然后创建一个 parent=00f067aa0ba902b7 的新 span。于是两个进程的 span 就挂成父子关系了。
Propagator(传播器)
SDK 里负责"从 headers 取出来 / 放回去"的组件叫 Propagator。OTel 默认用 W3C 组合:
import { propagation } from "@opentelemetry/api"; import { W3CTraceContextPropagator, W3CBaggagePropagator, CompositePropagator, } from "@opentelemetry/core"; propagation.setGlobalPropagator( new CompositePropagator({ propagators: [ new W3CTraceContextPropagator(), new W3CBaggagePropagator(), ], }) );
自动 instrumentation 会自动:
- 出站 HTTP 请求 → inject(塞 header)
- 入站 HTTP 请求 → extract(读 header)
- gRPC、Kafka producer/consumer 同理
手动 inject / extract
// 要往自定义消息里塞 traceparent const headers: Record<string, string> = {}; propagation.inject(context.active(), headers); // 现在 headers 里有 traceparent / baggage // 接收端 const parent = propagation.extract(context.active(), headers); // 基于 parent 创建新 span tracer.startActiveSpan("process", { kind: SpanKind.CONSUMER }, parent, (span) => { // ... });
同进程内:异步边界
跨进程靠 HTTP header,同一个进程里 context 怎么随 async 流转?各语言方案不同——这是 OTel 最难踩的坑之一。
Node.js:AsyncLocalStorage
// OTel Node SDK 默认用 AsyncHooksContextManager // 它基于 async_hooks,在 Promise/setTimeout/回调里保留 context tracer.startActiveSpan("outer", async (outer) => { await delay(100); // Promise resolve 后 context 还在 setTimeout(() => { const inner = tracer.startSpan("inner"); // 自动识别 outer 为 parent inner.end(); }, 50); });
Node 的 EventEmitter 发出的事件回调可能丢失 context——特别是通过
emit 同步调用的那些。解决:在 on 回调里手动 context.with(ctx, cb),或改用支持 async_hooks 的事件 API。
Python:contextvars
# Python 3.7+ 的 contextvars 自动在 asyncio 里传 context import asyncio from opentelemetry import trace async def handle(): with tracer.start_as_current_span("outer"): await asyncio.sleep(0.1) with tracer.start_as_current_span("inner"): # parent = outer pass # 跨 threading.Thread 不自动,需手动 copy_context import contextvars ctx = contextvars.copy_context() threading.Thread(target=lambda: ctx.run(task)).start()
Go:context.Context
// Go 的 context.Context 是显式参数—— OTel 也用这个 func handle(ctx context.Context) { ctx, span := tracer.Start(ctx, "outer") defer span.End() doWork(ctx) // 必须把 ctx 传下去 } func doWork(ctx context.Context) { _, span := tracer.Start(ctx, "inner") // parent = outer defer span.End() }
Go 最清晰——编译器强制你传 ctx,不传就漏。缺点是函数签名到处要加 ctx context.Context。
跨异步任务:Queue / Job
一个 HTTP 请求入队,后台 worker 几秒/几分钟后消费——同一条 trace 需要跨越这个时间和进程的鸿沟。
// 生产者(HTTP handler) app.post("/checkout", (req, res) => { const headers: Record<string, string> = {}; propagation.inject(context.active(), headers); queue.enqueue({ job: "send_email", payload: { userId: req.body.userId }, traceHeaders: headers, // ← 把 context 序列化进 job }); res.json({ ok: true }); }); // 消费者(Worker) worker.on("job", (job) => { const parent = propagation.extract(context.active(), job.traceHeaders); tracer.startActiveSpan( "send_email", { kind: SpanKind.CONSUMER }, parent, (span) => { /* ... */ } ); });
Kafka / RabbitMQ / SQS 的自动 instrumentation 都会帮你做这件事——在消息 header / attributes 里塞 traceparent。
跨 Kafka 的消息
消息中间件有个特殊之处:一条消息可能被批量消费——consumer 一次处理 100 条,每条属于不同的 trace。怎么画链路?
OTel Kafka instrumentation 默认用前者,可配置用 link 方式。
跨浏览器:前端 trace
// 前端也可以用 OTel,让 trace 从浏览器开始 import { WebTracerProvider } from "@opentelemetry/sdk-trace-web"; import { FetchInstrumentation } from "@opentelemetry/instrumentation-fetch"; const provider = new WebTracerProvider(); registerInstrumentations({ instrumentations: [ new FetchInstrumentation({ propagateTraceHeaderCorsUrls: /api\.myapp\.com/, // 只给自己后端注入 }), ], });
浏览器 fetch 默认不带自定义 header 到跨域。
traceparent 属于自定义 header,后端必须在 Access-Control-Allow-Headers 里显式放行 traceparent, tracestate, baggage,否则 preflight 会挂。
跨语言兼容:B3 propagator
Zipkin 世界的传统 propagator 叫 B3,用 X-B3-TraceId / X-B3-SpanId 几个 header。如果你的老系统用 Zipkin,需要兼容:
import { B3Propagator, B3InjectEncoding } from "@opentelemetry/propagator-b3"; new CompositePropagator({ propagators: [ new W3CTraceContextPropagator(), new B3Propagator({ injectEncoding: B3InjectEncoding.MULTI_HEADER }), new W3CBaggagePropagator(), ], });
Extract 时会按顺序试,第一个命中的生效——迁移期一套代码两边都能跑。
Jaeger 专有 propagator
类似地,Jaeger 原生用 uber-trace-id header。OTel 也提供 JaegerPropagator——不过 Jaeger 后端本身现在就吃 W3C,所以一般不需要了。
链路断了怎么排查
经常有人抱怨:Jaeger 上看 trace 只有一个 span,该有的子 span 去哪了?
1. 上下游都启动了 OTel SDK?
2. 都装了对应协议的 instrumentation(http、grpc 等)?
3. header 真的被发送?用
curl -v 看 traceparent 是否存在4. 下游的 CORS 配置放行了
traceparent?5. 采样策略一致?ParentBased 才能保证父采样子也采样
6. 如果过中间件 / 代理(nginx、envoy),它有没有保留 header?(默认一般保留)
Resource:服务身份
Resource 不是 context 的一部分,但和它关联密切——它描述"这些 span/metric 来自谁"。
import { Resource } from "@opentelemetry/resources"; import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions"; new Resource({ [SemanticResourceAttributes.SERVICE_NAME]: "order-service", [SemanticResourceAttributes.SERVICE_VERSION]: "1.2.0", [SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]: "prod", [SemanticResourceAttributes.K8S_POD_NAME]: process.env.HOSTNAME, });
SDK 内部也会自动侦测 container/host/k8s 信息并合并。第 8 章会详讲 Resource 语义约定。
本章小结
- W3C TraceContext 是行业标准:
traceparent/tracestate/baggage - Propagator 负责 inject(出站)和 extract(入站)
- 同进程异步:Node 用 AsyncLocalStorage、Python 用 contextvars、Go 显式传 ctx
- 跨消息队列:在消息 header 里塞 traceparent,consumer 手动 extract
- 浏览器前端:WebTracerProvider + 后端 CORS 放行 traceparent
- 老系统用 B3? CompositePropagator 多 propagator 同时生效
- Resource 描述 span 的"所属方"——service.name / version / env / k8s pod