《TypeScript编程实战》17.1 OpenTelemetry 追踪

本节从日志排障的局限出发,讲清 OpenTelemetry 的 Trace、Span 与 Context 数据模型,并给出 Node.js 侧可直接运行的 SDK 初始化、自动插桩与采样配置。你将学会在 Fastify 中间件里手工埋点、跨服务传播 traceparent、把 trace_id 写进结构化日志,并用语义约定统一 span 属性命名,避开采样过高与上下文丢失等常见坑。

本节目标:理解 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 指标与告警 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes