本节目标:理解 trace / span / context 三件套的数据模型;在 Node.js 服务里跑通 OpenTelemetry SDK 的初始化与自动插桩;掌握手工埋点、跨服务上下文传播、trace 与日志关联的写法;知道采样、属性基数与导出器配置里最容易踩的坑。
17.1 OpenTelemetry 追踪
在 3.3 结构化日志与脱敏
里,我们让每条日志都带上了统一的字段,排障时至少能按 trace_id 把一次请求相关的行捞出来。但日志只能回答「发生了什么」,回答不了「时间花在哪一步」——当一次请求穿过网关、HTTP 服务、缓存、数据库和队列时,日志是散落的点,而追踪是把这些点连成线的那个东西。
本节讲的是这条线的标准画法:OpenTelemetry。
17.1.1 为什么日志不够用
先看一个真实场景。用户反馈「下单接口偶尔要 3 秒」。翻日志你能看到:
10:00:01 INFO POST /orders 开始处理 userId=42
10:00:01 INFO cache miss key=cart:42
10:00:02 INFO db query orders insert 耗时 2100ms
10:00:03 INFO POST /orders 完成 status=201
问题来了:这 2100ms 是数据库本身慢,还是连接池排队?是网络抖动还是锁等待?日志没有分层结构,你无法知道这段时间内部还发生了什么。更麻烦的是跨服务:网关的日志、订单服务的日志、库存服务的日志各有各的格式和时间戳,想拼出完整链路只能靠人肉对齐时间。
追踪解决的就是这个问题:它把一次请求建模成一棵有父子关系、带时长的时间树,每个节点是一个 span。
17.1.2 数据模型:Trace、Span 与 Context
OpenTelemetry 的核心概念只有三个,值得背下来:
| 概念 | 含义 | 关键字段 |
|---|---|---|
| Trace | 一次完整请求的调用树,由同一个 trace_id 串起 | trace_id(32 位十六进制) |
| Span | 树上的一个节点,代表一段有开始结束的操作 | span_id、parent_span_id、起止时间、状态、属性 |
| Context | 承载「当前 span 是谁」的隐式传递载体 | 进程内 AsyncLocalStorage,跨进程用 HTTP 头 |
它们的关系是:一个 trace 包含多个 span,span 之间通过 parent_span_id 构成树;context 负责在函数调用链里隐式地告诉你「现在挂在哪个 span 下」。
有一个容易混淆的点:trace_id 全局唯一且贯穿所有服务,span_id 只在一次操作内唯一。跨服务时传播的是这两者的组合,加上采样标记,整体编码成一个字符串 traceparent,格式是固定的四段:
00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
│ │ │ │
│ │ │ └─ 采样标记(01=已采样)
│ │ └─ parent span_id
│ └─ trace_id(32 位)
└─ 版本号
这个头是 W3C 标准,也是 OpenTelemetry 的默认传播格式。理解它很重要:只要上游带了 traceparent,下游就必须复用同一个 trace_id,否则链路会在服务边界断掉。
17.1.3 初始化 SDK:必须在业务代码之前
OpenTelemetry 的 Node.js SDK 有一个硬性约束:它必须比被插桩的库先加载。因为自动插桩的原理是劫持 http、pg、ioredis 等模块的导出,如果 fastify 已经 import 进来,劫持就来不及了。
因此初始化代码要单独放一个文件,并在入口最顶部引入:
// src/telemetry.ts —— 这个文件必须第一个被 import
import { NodeSDK } from '@opentelemetry/sdk-node'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'
import { TraceIdRatioBasedSampler } from '@opentelemetry/sdk-trace-node'
import { resourceFromAttributes } from '@opentelemetry/resources'
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions'
const sdk = new NodeSDK({
resource: resourceFromAttributes({
[ATTR_SERVICE_NAME]: 'order-service',
[ATTR_SERVICE_VERSION]: process.env.APP_VERSION ?? 'dev',
'deployment.environment': process.env.NODE_ENV ?? 'development',
}),
traceExporter: new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? 'http://localhost:4318/v1/traces',
}),
instrumentations: [getNodeAutoInstrumentations()],
// 采样率:开发环境全采,生产环境通过环境变量下调
sampler: new TraceIdRatioBasedSampler(
Number(process.env.OTEL_TRACES_SAMPLER_ARG ?? 1),
),
})
sdk.start()
process.on('SIGTERM', () => {
// 关停前必须 flush,否则缓冲区里的 span 会丢
sdk.shutdown().finally(() => process.exit(0))
})
// src/index.ts —— 第一行就是它,顺序不能动
import './telemetry'
import { buildServer } from './server'
const app = buildServer()
await app.listen({ port: 3000, host: '0.0.0.0' })
resource 描述「这个 span 来自哪个服务」,是后续按服务筛选的前提。注意 ATTR_SERVICE_NAME 这类常量来自语义约定包——不要手写字符串字面量,约定值统一由包导出,拼错一个字母就会导致筛选失效,而且不会有任何报错。
instrumentations 里的 getNodeAutoInstrumentations() 默认会给 HTTP、Express/Fastify、PostgreSQL、MySQL、Redis、gRPC 等常见库自动埋点。它带来的便利是巨大的:不改一行业务代码,就能看到每个 HTTP 请求和每次数据库查询的 span。
17.1.4 自动插桩能看到什么
装好之后,一次 POST /orders 在 Jaeger 或 Tempo 里大概长这样:
POST /orders [====================] 2130ms
├─ order-service: 校验库存 [=] 12ms
├─ postgres: INSERT orders [==================] 2085ms
├─ redis: GET cart:42 [=] 3ms
└─ POST http://inventory/reserve [=] 18ms
一眼就能看出瓶颈在数据库写入。这就是追踪相对日志的核心价值:把「耗时」这件事从标量变成向量,让每一段归因到具体的操作。
自动插桩的边界也很清楚:它只知道框架层的操作,不知道你的业务语义。比如「校验库存」这个 span 是你手工加的,自动插桩给不出来。所以生产实践是「自动打底 + 手工补充」。
17.1.5 手工埋点:让业务步骤可见
手工埋点用 startActiveSpan,它的关键作用是把新 span 设为当前上下文,这样内部再发起的自动插桩 span 会自动挂在它下面:
import { trace, SpanStatusCode } from '@opentelemetry/api'
const tracer = trace.getTracer('order-service', '1.0.0')
export async function reserveStock(orderId: string, items: CartItem[]) {
return tracer.startActiveSpan('order.reserveStock', async (span) => {
try {
// 属性用语义约定风格的点分命名,便于按维度聚合
span.setAttribute('order.id', orderId)
span.setAttribute('order.item_count', items.length)
const result = await inventoryClient.reserve(orderId, items)
if (!result.ok) {
span.setAttribute('inventory.reason', result.reason)
// 业务失败不等于系统故障,用 UNSET 还是 ERROR 要看语义
span.setStatus({ code: SpanStatusCode.ERROR, message: result.reason })
}
return result
} catch (err) {
// recordException 会把栈信息写进 span,比只记 message 有用得多
span.recordException(err as Error)
span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message })
throw err
} finally {
// 无论成功失败都必须 end,否则 span 永远不导出
span.end()
}
})
}
三个细节决定了埋点质量:
第一,setAttribute 的键要有命名规范。社区约定是「名词域.名词」的点分风格,比如 http.request.method、db.system、order.id。自定义属性建议带业务前缀,避免和标准属性撞名。
第二,setStatus 只在真正出错时设为 ERROR。把「用户输入非法」这类正常的业务分支标成 ERROR,会让错误率指标失去意义。
第三,span.end() 必须放在 finally 里。少调一次,这个 span 就永远不会被导出——表现为「追踪里看不到这个操作」,而且不报错,极难排查。
17.1.6 上下文传播:跨服务不能断链
进程内传播由 SDK 用 AsyncLocalStorage 自动完成,你几乎不用管。跨进程传播必须显式配置,否则下游服务会自己开一条新 trace。
服务端接收:如果用了 getNodeAutoInstrumentations(),HTTP 入口的 traceparent 解析是自动的。但如果你是自定义的 HTTP 客户端(比如用 undici 手写请求),就必须手动注入:
import { propagation, context } from '@opentelemetry/api'
import { fetch } from 'undici'
export async function callInventory(path: string, body: unknown) {
const headers: Record<string, string> = { 'content-type': 'application/json' }
// 把当前上下文里的 traceparent 写进请求头
propagation.inject(context.active(), headers)
return fetch(`${process.env.INVENTORY_URL}${path}`, {
method: 'POST',
headers,
body: JSON.stringify(body),
})
}
propagation.inject 会把 context.active() 里的 trace 信息序列化成 traceparent 写进 headers 对象,覆盖 http、https、grpc 等不同载体的差异。
对应的服务端如果不在 Node 生态(比如 Go 或 Java),只要它遵循 W3C 标准,链路依然是连通的——这正是 OpenTelemetry 作为标准而不是某个厂商 SDK 的价值。反向代理侧的追踪接线可以延伸阅读 Nginx OpenTelemetry 集成 。
一个常见的断链原因是异步边界:如果你在 setTimeout、事件回调或手工创建线程池里执行逻辑,context.active() 可能已经不是当初那个了。补救办法是用 context.with(ctx, fn) 显式恢复上下文:
import { context, trace } from '@opentelemetry/api'
const ctx = context.active()
setTimeout(() => {
context.with(ctx, () => {
// 这里才能拿到正确的当前 span
trace.getActiveSpan()?.addEvent('delayed.task.started')
})
}, 1000)
17.1.7 采样:别让成本失控
追踪的成本随流量线性增长,全量采集在中等规模下就能压垮后端。采样策略有两类:
| 策略 | 决策时机 | 优点 | 缺点 |
|---|---|---|---|
| 头部采样 | 请求入口,一次性决定 | 实现简单、开销极低 | 无法按「慢请求」采样 |
| 尾部采样 | trace 结束后,看完整数据决定 | 可保留全部错误与慢请求 | 需要收集器聚合,资源占用高 |
头部采样在 SDK 里配置,用 TraceIdRatioBasedSampler 按比例丢弃:
import { TraceIdRatioBasedSampler } from '@opentelemetry/sdk-trace-node'
// 生产环境采样 10%,但保证 trace 内所有 span 决策一致
const sampler = new TraceIdRatioBasedSampler(0.1)
它的实现原理很巧妙:对 trace_id 做哈希后和阈值比较,因此同一个 trace 的所有服务会得到完全一致的采样决策,不会出现「A 服务采了、B 服务没采」的残缺链路。这是头部采样的硬性要求。
尾部采样则要在 OpenTelemetry Collector 里配 tail_sampling 处理器,把「有 error 的」「耗时超过 1 秒的」全量保留,其余按 1% 采样。代价是 Collector 必须缓存完整 trace 直到其结束,内存开销不小。
实践建议:先头部采样把总量压到可承受范围,再对错误与慢请求做尾部采样。两者的组合可以延伸阅读 OpenTelemetry Collector 深入 。
17.1.8 与日志关联
追踪和日志不是二选一。最好的排障体验是:从日志里的 trace_id 一跳进入完整链路,从链路里的 span 又能跳回日志。
实现方式是在日志里注入当前 trace 上下文。SDK 提供了一个现成的格式化扩展:
import { pino } from 'pino'
import { trace, context } from '@opentelemetry/api'
export const logger = pino({
mixin() {
const span = trace.getSpan(context.active())
if (!span) return {}
const { traceId, spanId } = span.spanContext()
return { trace_id: traceId, span_id: spanId }
},
})
mixin 会在每次打印时执行,因此日志天然带上当前 span 的 ID。之后在 Loki 或 Elasticsearch 里按 trace_id 搜索,就能拿到这次请求在所有服务里的全部日志。这套字段设计在 3.3 结构化日志与脱敏
里已经打过基础,这里只是补上了 OTel 提供的取值方式。
17.1.9 常见坑
第一个坑是初始化顺序错。telemetry.ts 必须在业务模块之前 import,用 CommonJS 时要用 require 前置,用 ESM 时要用 import './telemetry' 放在第一行。顺序错了不会报错,只是「追踪里什么都看不到」,非常容易浪费半天。
第二个坑是属性基数爆炸。给 span 加 user.id、order.id、url 这类高基数字段,会让后端存储成本激增,在 Jaeger 里还可能触发索引拒绝。高基数字段只加在 span 属性上可以,但绝不能当指标标签(下一节会展开)。
第三个坑是忘记 span.end()。前面强调过,漏掉 end 的 span 不会导出且不报错。用 startActiveSpan 的回调形式能靠 finally 兜住,尽量避免手工 tracer.startSpan 加裸 end。
第四个坑是在热路径里同步导出。SimpleSpanProcessor 是同步导出的,生产环境必须换成 BatchSpanProcessor,否则每次埋点都会阻塞事件循环。
第五个坑是进程退出丢数据。SIGTERM 时必须调 sdk.shutdown() 等待缓冲区刷出,否则最后一批 span 会随进程消失——而这批数据里往往正好包含关闭前的异常。这一流程要和 5.3 优雅关闭与健康检查
里的关闭钩子串起来。
17.1.10 在 HTTP 服务里落地
把上面的碎片拼成一个可用的中间件,思路是「每个请求一个根 span,业务埋点都挂在它下面」:
import type { FastifyInstance } from 'fastify'
import { trace, SpanStatusCode } from '@opentelemetry/api'
export function registerTracing(app: FastifyInstance) {
const tracer = trace.getTracer('http-layer')
app.addHook('onRequest', async (req) => {
// 自动插桩通常已经建好根 span,这里只做属性补全
const span = trace.getActiveSpan()
span?.setAttribute('http.route', req.routeOptions.url ?? req.url)
span?.setAttribute('user.id', (req.headers['x-user-id'] as string) ?? 'anonymous')
})
app.addHook('onResponse', async (req, reply) => {
const span = trace.getActiveSpan()
if (!span) return
span.setAttribute('http.status_code', reply.statusCode)
if (reply.statusCode >= 500) {
span.setStatus({ code: SpanStatusCode.ERROR })
}
})
}
要注意 onRequest 阶段 req.routeOptions.url 可能还是空的(路由尚未匹配),真正稳定的取值时机是 preHandler 之后。若发现路由名恒为 undefined,把属性补全挪到 onResponse 里即可——那时路由已经确定,且 span 还没 end。
至此,一条完整的链路就通了:入口生成 trace_id,中间件补全 HTTP 语义,业务代码用 startActiveSpan 划分步骤,数据库与缓存的 span 由自动插桩补齐,最后通过 OTLP 导出到 Collector。下一节我们讲与追踪配套的另一半:指标与告警——trace 用来「查个案」,metrics 用来「看整体」,两者缺一不可。
小结
本节的核心是:追踪把「请求耗时」从标量拆成了可归因的时间树。
- 数据模型只有三件套:trace 是调用树、span 是树上节点、context 负责隐式传递当前 span;
- 跨进程传播靠 W3C 标准的
traceparent头,格式为「版本-trace_id-span_id-采样标记」四段; - SDK 初始化必须在业务代码之前执行,这是自动插桩生效的硬前提,顺序错了不报错只是静默失效;
- 手工埋点优先用
startActiveSpan的回调形式,end()放finally,异常用recordException记录; - 属性命名遵循点分语义约定,高基数字段可以进 span 但绝不能进指标标签;
- 采样先做头部比例采样控制总量,再在 Collector 侧对错误与慢请求做尾部采样;
- 日志与追踪通过
mixin注入trace_id打通,排障时可以双向跳转; - 进程退出前必须
sdk.shutdown(),否则最后一批 span(往往含关键异常)会丢失。
阅读导航:上一节:16.3 契约版本演进与兼容 · 下一节:17.2 指标与告警 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。