Chapter 05

把 context 送到天涯海角

Span 的父子关系是"逻辑"——但分布式里服务各在各的进程。Context 传播解决的是:如何让 trace_id 跟着请求走完 HTTP、gRPC、Kafka、异步任务的每一段路。W3C TraceContext 把这件事标准化了。

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:

traceparent(必选)
格式 version-trace_id-parent_id-flags这就是 SpanContext 序列化
tracestate(可选)
多厂商共存时的厂商私有数据,通常你不手写。
baggage(可选)
业务键值对,第 2 章讲过。

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 会自动:

手动 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);
});
EventEmitter 陷阱
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。怎么画链路?

每条消息一个 CONSUMER span,各有 parent
和 producer 严格对应——链路清晰,但如果你还有一个 batch 整体操作,需要额外包一层
一个 batch span + N 个 link
batch span 不归属任何 trace(新 trace),但用 Span Links 挂到每条消息的原 trace—— Jaeger 里虚线箭头

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/,  // 只给自己后端注入
    }),
  ],
});
CORS 陷阱
浏览器 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 语义约定。

本章小结