TS/Node 服务可观测性:OpenTelemetry、结构化日志、指标与追踪

系统讲解 TypeScript/Node 服务的可观测性建设:可观测性三支柱、OpenTelemetry SDK 与自动/手动埋点、Span 与上下文传播、pino 结构化日志与 trace 关联、Counter/Histogram/Gauge 指标、W3C TraceContext、采样策略与 OTLP Collector 接入,以及生产排错实践。

引言

线上服务出问题时,最难的往往不是「修」,而是「知道发生了什么」。日志散落各处、指标口径不一、一次跨服务调用无法串起来——这就是可观测性要解决的问题:用日志、指标、追踪三支柱,把系统的内部状态从外部可推断。

本文聚焦 TypeScript/Node 服务的可观测性落地:从三支柱讲起,覆盖 OpenTelemetry SDK 的自动与手动埋点、Span 与上下文传播、pino 结构化日志与 trace 关联、指标类型、采样与 OTLP Collector,最后给出生产排错清单。

前置:/typescript-nodejs-backend/(Node 后端)、/typescript-microservices-nestjs/(微服务)、/typescript-typed-events-streams/(事件与流)。


目录


1. 可观测性三支柱

1.1 三者定位

日志 Logs :离散事件,信息最丰富、成本最高、最难聚合
指标 Metrics:数值聚合,成本低、适合告警与趋势,缺上下文
追踪 Traces:一次请求的跨服务链路,定位延迟瓶颈的利器

1.2 何时用哪个

需求首选理由
告警与趋势指标聚合成本低,适合阈值
定位具体错误日志信息完整,可搜索
定位延迟瓶颈追踪端到端链路可见
归因跨服务追踪 + 日志用 traceId 串联

1.3 关联是关键

三支柱各自孤岛时价值有限,用 traceId 把它们串起来才能从「指标异常」下钻到「具体请求日志」。

一句话总结:指标看趋势、日志看细节、追踪看链路——真正提升效率的不是三者齐备,而是用 traceId 把它们关联起来。


2. OpenTelemetry 架构与概念

2.1 为什么是 OTel

OpenTelemetry(OTel)是 CNCF 厂商中立的可观测性标准,提供统一 API + SDK + 数据协议(OTLP):埋点一次,可导出到任意后端(Jaeger/Tempo/Datadog)。

2.2 核心概念

TracerProvider 是创建 Tracer 的工厂,Tracer 创建 Span(一次操作的记录);MeterProvider 创建 Meter,Meter 创建 Counter/Histogram/Gauge;Resource 描述服务身份(service.name),Exporter 把数据推给后端。

2.3 初始化示例

// instrumentation.ts(必须在业务代码之前 import)
import { NodeSDK } from "@opentelemetry/sdk-node"
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node"

const sdk = new NodeSDK({
  resource: resourceFromAttributes({ "service.name": "order-service" }),
  traceExporter: new OTLPTraceExporter({ url: "http://collector:4318/v1/traces" }),
  instrumentations: [getNodeAutoInstrumentations()],
})
sdk.start()

一句话总结:OpenTelemetry 是厂商中立的埋点标准,TracerProvider 产 Span、MeterProvider 产指标、Exporter 推后端——埋一次点,后端随便换。


3. 自动埋点与手动埋点

3.1 自动埋点

getNodeAutoInstrumentations() 自动为 http、express、pg、ioredis、grpc 等常见库注入 Span,零代码即可获得基础链路:

instrumentations: [
  getNodeAutoInstrumentations({
    "@opentelemetry/instrumentation-fs": { enabled: false }, // 关掉噪声大的
  }),
]

3.2 手动埋点补充业务语义

自动埋点只知道「调了 HTTP」,不知道「这是下单流程」。手动埋点补上业务上下文:

import { trace, SpanStatusCode } from "@opentelemetry/api"
const tracer = trace.getTracer("order-service")

export async function createOrder(input: OrderInput) {
  return tracer.startActiveSpan("createOrder", async (span) => {
    span.setAttribute("order.items", input.items.length)
    try {
      const order = await repo.insert(input)
      span.setStatus({ code: SpanStatusCode.OK })
      return order
    } catch (err) {
      span.recordException(err as Error)
      span.setStatus({ code: SpanStatusCode.ERROR })
      throw err
    } finally { span.end() }
  })
}

3.3 埋点粒度

太粗只有 HTTP Span,定位不到 DB/缓存/序列化耗时;太细则 Span 爆炸、开销大。建议只埋「跨进程或跨资源边界」的操作(DB、缓存、外部 API、队列)。

一句话总结:自动埋点给基础链路,手动埋点补业务语义——把埋点放在跨资源边界上,既不遗漏也不过量。


4. 分布式追踪:Span 与 Context

4.1 Span 的构成

TraceId 是整条链路的全局唯一 ID,SpanId 标识单个 Span,ParentSpanId 指向父 Span 构成树形结构;Attributes 是键值对(http.method、db.statement 等),Events/Status 记录 Span 内事件与状态(OK / ERROR)。

4.2 父子关系

一次请求 = 一条 Trace
  ├─ HTTP GET /orders(root span)
  │   ├─ db.query select orders(child span)
  │   └─ http GET /inventory(child span,跨进程)

4.3 手动创建子 Span

import { trace } from "@opentelemetry/api"
const tracer = trace.getTracer("order-service")

async function withSpan<T>(name: string, fn: () => Promise<T>): Promise<T> {
  return tracer.startActiveSpan(name, async (span) => {
    try { return await fn() }
    finally { span.end() }
  })
}
await withSpan("db.query", () => db.query("select 1"))

一句话总结:Trace 由 TraceId 标识整条链路、Span 用 ParentSpanId 构成树——startActiveSpan 让子操作自动挂到当前上下文。


5. 结构化日志与 trace 关联

5.1 为什么结构化

字符串日志 log.info("user 123 created order 456") 无法可靠解析,字段只能靠正则;结构化日志 log.info({ userId, orderId }, "order created") 输出 JSON,字段可索引、可聚合。

5.2 用 pino

import pino from "pino"

export const logger = pino({
  level: process.env.LOG_LEVEL ?? "info",
  formatters: { level: (label) => ({ level: label }) },  // 输出 "info" 而非 30
  timestamp: pino.stdTimeFunctions.isoTime,
})
logger.info({ orderId: "o_1", total: 99 }, "order created")

5.3 注入 traceId

把当前 Span 的 traceId/spanId 注入每条日志,日志与追踪即可互跳:

import { trace } from "@opentelemetry/api"

export function withTrace(logger: pino.Logger) {
  const ctx = trace.getActiveSpan()?.spanContext()
  return logger.child({ traceId: ctx?.traceId, spanId: ctx?.spanId })
}

5.4 日志中的禁忌

记录 PII(手机号、身份证)有合规风险;记录完整请求体体积爆炸且易泄密;用 console.log 绕过结构化无法聚合;日志级别混乱(错误用 info)会让告警失灵。

一句话总结:结构化日志用字段而非字符串,把 traceId 注入每条日志即可与追踪互跳——同时避免 PII 与超大字段。


6. 指标:Counter、Histogram、Gauge

6.1 三种类型

Counter   :只增不减的累计量(请求数、错误数)
Histogram :分布统计(延迟分位数 P50/P95/P99)
Gauge     :瞬时值(连接数、队列深度、内存占用)

6.2 定义与使用

import { metrics } from "@opentelemetry/api"
const meter = metrics.getMeter("order-service")

const ordersTotal = meter.createCounter("orders_total")
const orderDuration = meter.createHistogram("order_duration_ms")
const queueDepth = meter.createGauge("queue_depth")

ordersTotal.add(1, { status: "success" })
orderDuration.record(elapsed, { route: "/orders" })
queueDepth.record(pending.length)

6.3 用直方图算分位数

Histogram 导出的是桶(bucket)计数,后端据此估算分位数。桶边界要覆盖真实分布:

const orderDuration = meter.createHistogram("order_duration_ms", {
  advice: { explicitBucketBoundaries: [5, 10, 25, 50, 100, 250, 500, 1000] },
})

6.4 标签基数陷阱

标签组合数即基数,基数过高会拖垮后端。反例是把 userId、requestId 当标签(基数无限);正例是 status、route、region 等低基数维度。

一句话总结:Counter 累计、Histogram 分布、Gauge 瞬时——直方图靠桶估算分位数,标签必须控制基数,切忌把用户 ID 当标签。


7. 上下文传播与 W3C TraceContext

7.1 传播的是什么

跨进程时必须把 traceId/spanId 通过 HTTP 头传给下游,链路才能串起来。OTel 默认用 W3C TraceContext:

traceparent: 00-<traceId(32hex)>-<spanId(16hex)>-<flags>
tracestate : 厂商自定义键值

7.2 自动与手动传播

自动埋点会为 http/fetch 自动注入 traceparent,接收端自动解析。非标准框架需手动处理:

import { propagation, context } from "@opentelemetry/api"

// 发送方:把上下文写入 headers
const headers: Record<string, string> = {}
propagation.inject(context.active(), headers)
await fetch("http://inventory/check", { headers })

// 接收方:从 headers 提取
const parentCtx = propagation.extract(context.active(), incomingHeaders)

7.3 常见断链原因

消息队列未透传 headers、自定义 RPC 协议未注入或提取、异步任务丢了 context(setTimeout 外的 context.active() 为空)、中间件提前 return 未继续执行——这四类是最常见的断链点。

一句话总结:跨进程传播靠 W3C traceparent 头,自动埋点会替你注入与提取——消息队列和自定义协议是最常见的断链点。


8. 采样策略与成本控制

8.1 为什么采样

全量追踪在高流量下成本极高。头部采样在请求入口就决定采不采,简单但可能漏掉错误;尾部采样收集完整 Trace 后再决定,能保留错误与慢请求,成本更高。

8.2 头部采样配置

import { ParentBasedSampler, TraceIdRatioBasedSampler } from "@opentelemetry/sdk-trace-node"

const sampler = new ParentBasedSampler({
  root: new TraceIdRatioBasedSampler(0.1),   // 采样 10%
})

8.3 在 Collector 做尾部采样

processors:
  tail_sampling:
    decision_wait: 10s
    policies:
      - name: errors
        type: status_code
        status_code: { status_codes: [ERROR] }
      - name: slow
        type: latency
        latency: { threshold_ms: 500 }
      - name: sample-10
        type: probabilistic
        probabilistic: { sampling_percentage: 10 }

这样错误与慢请求 100% 保留,正常请求按比例采样。

8.4 成本控制清单

关闭噪声大的自动埋点(fs、dns);控制 Span 属性数量与大小;指标控制标签基数;日志分级加采样,避免 debug 常开;用 Collector 统一做批处理与过滤。

一句话总结:头部采样简单但可能漏错误,尾部采样能保住错误与慢请求——生产常用「尾部采样 + 关闭噪声埋点 + 控制基数」组合。


9. 后端接入:OTLP 与 Collector

9.1 OTLP 协议

OTLP 是统一传输协议:gRPC 默认端口 4317、性能好;HTTP/Protobuf 或 HTTP/JSON 用端口 4318、穿透性好。

9.2 为什么需要 Collector

应用直连后端的问题是「后端一换所有服务都要改配置」且缺少统一批处理、过滤、采样、重试。Collector 作为中间层,把链路变成「应用 → Collector → 任意后端」。

9.3 应用侧配置

const sdk = new NodeSDK({
  traceExporter: new OTLPTraceExporter({ url: "http://collector:4318/v1/traces" }),
  metricReader: new PeriodicExportingMetricReader({
    exporter: new OTLPMetricExporter({ url: "http://collector:4318/v1/metrics" }),
    exportIntervalMillis: 10_000,
  }),
})

9.4 Collector 管道

receivers: [otlp]
processors: [batch, memory_limiter, tail_sampling]
exporters: [otlp/tempo, prometheus, otlp/datadog]
service:
  pipelines:
    traces: { receivers: [otlp], processors: [tail_sampling, batch], exporters: [otlp/tempo] }

一句话总结:应用只对接 OTLP,Collector 负责批处理、采样与多后端分发——换后端只改 Collector,不动业务代码。


10. 生产实践与排错

10.1 落地顺序

先接结构化日志(pino)加 traceId 注入,成本最低收益最高;再接入 OTel 自动埋点拿到基础链路;然后补关键业务手动埋点与指标;再上 Collector 做采样与多后端分发;最后配置告警与看板。

10.2 常见问题对照

现象可能原因排查
链路断开队列未透传 header查 traceparent 是否注入
日志无 traceIdcontext 丢失检查异步边界
指标缺失未 flush 或周期太长调 exportIntervalMillis
开销过高采样不足或埋点过密加采样、关噪声埋点
后端爆量标签基数高审查 label 维度

10.3 开销参考

自动埋点(含 DB/HTTP)约增加 3%~8% CPU,视流量而定;关闭 fs/dns 噪声埋点后通常回落到 2%~5%;结构化日志(pino)比 console.log 更快,但字段越多序列化越贵。

10.4 关键纪律

instrumentation.ts 必须在业务代码前 import,否则自动埋点失效;进程退出前 sdk.shutdown(),否则缓冲的 Span/指标丢失;traceId 要贯穿日志、指标 exemplar 与错误上报;采样与埋点策略走配置,不要硬编码。

一句话总结:可观测性落地从结构化日志起步,逐步加追踪与指标,最后用 Collector 统一治理——纪律是「早初始化、优雅关闭、处处带 traceId」。


延伸阅读

  • /typescript-nodejs-backend/ — Node 后端服务的工程实践
  • /typescript-microservices-nestjs/ — 微服务架构与可观测性接入
  • /typescript-typed-events-streams/ — 事件流与异步上下文
  • /typescript-security-hardening/ — 日志脱敏与安全合规
  • /typescript-async-concurrency-control/ — 异步上下文与并发控制
  • TypeScript 专题 — TypeScript 专题

继续阅读

探索更多技术文章

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

全部文章 返回首页

「typescript」更多文章

  1. TS 中的 LLM 应用开发:AI SDK、流式响应、工具调用与类型安全
  2. 边缘运行时与适配器:Vercel Edge、Cloudflare Workers 与 Web API 兼容
  3. Node.js 性能剖析:V8 采样、clinic、火焰图与堆快照