Node

Node.js 日志与可观测性实战:从 pino 到 OpenTelemetry 的完整链路

✎ -- 字 🕐 -- 分钟
字号

在生产环境跑 Node.js 服务,最怕的不是业务代码写错了,而是出了问题之后"看不到"。一个接口耗时突然变长,你只看到一句"Timeout";一个容器反复重启,你却不知道最后一次打印了什么。日志,是工程师观察系统的第一扇窗;可观测性,则是把系统从内到外的状态都变成可查询的数据。今天这篇文章,我就从 Node.js 日志的最优实践说起,一路延伸到 OpenTelemetry 的 Trace、Metrics、Logs 三大支柱,给你一套可以直接落地的方案。

一、为什么日志是可观测性的基石

可观测性(Observability)这个词这几年很火,但它不是监控(Monitoring)的换皮。监控是问"系统有没有按预期工作",而可观测性是问"系统为什么没按预期工作"。Grafana 社区把它拆成三个支柱:

  • Logs(日志):离散事件,描述"发生了什么";
  • Metrics(指标):聚合数值,描述"有多频繁/多严重";
  • Traces(追踪):请求链路,描述"影响范围在哪里"。

在 Node.js 这种单线程、事件驱动的运行时下,日志尤其重要。因为一次用户请求可能触发多个异步操作,如果日志只记录结果,不记录上下文,你很难把一次错误和它的上游调用关联起来。所以日志必须从"文本输出"升级为"结构化数据",并且和 Traces、Metrics 打通。

二、Node.js 日志方案的演进:从 console 到 pino

2.1 console.log 为什么不行

很多初学者习惯用 console.log 调试。它确实简单,但生产环境用它等于埋雷:

  • 同步阻塞console.log 在 Node.js 内部走 process.stdout,默认是同步写入,高并发时会被 I/O 卡住;
  • 无日志级别:你无法按环境区分 debug/info/warn/error;
  • 非结构化:搜索时要写正则,聚合时极其痛苦;
  • 无轮转:日志文件会无限增长,直到磁盘打满。

所以生产环境一定要选择专业日志库。当前 Node.js 生态里,主流选手有 pino、winston、bunyan、log4js。下面是一张核心对比表:

特性pinowinstonbunyanlog4js
性能极高(最小开销)中等中等
JSON 输出默认 JSON需配置默认 JSON需配置
日志级别trace/debug/info/warn/error/fatal可自定义标准标准
传输器通过 worker 或 pino.transport内置 Transport原生 streams内置 appenders
维护状态活跃活跃维护较少活跃
适用场景高并发、微服务通用传统项目配置化项目

2.2 pino 实战:从基础配置到生产优化

pino 的设计哲学是"快且 JSON",它默认输出的就是结构化 JSON,直接对接 ELK/Loki 这类日志系统。下面是一个生产级初始化配置:

// logger.js
const pino = require('pino');

const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  base: {
    pid: process.pid,
    env: process.env.NODE_ENV || 'development',
    service: 'user-service'
  },
  // 开启 prettyPrint 仅在本地开发,生产一定输出 JSON
  transport: process.env.NODE_ENV === 'development'
    ? { target: 'pino-pretty', options: { colorize: true } }
    : undefined,
  // 序列化错误对象,避免堆栈丢失
  formatters: {
    bindings: (bindings) => ({
      pid: bindings.pid,
      env: process.env.NODE_ENV,
      service: bindings.service
    }),
    level: (label) => ({ level: label.toUpperCase() })
  },
  timestamp: pino.stdTimeFunctions.isoTime
});

module.exports = logger;

使用的时候直接引入,保持日志格式统一:

const logger = require('./logger');

logger.info({ userId: 42, action: 'login' }, 'User logged in');
logger.error({ err: new Error('DB timeout'), query: 'SELECT * FROM users' }, 'Query failed');

这里有几个关键点:

  • 日志级别由环境变量控制:本地 debug,生产 info/warn;
  • base 字段放公共上下文:服务名、环境、进程 ID,方便后续按维度筛选;
  • 错误对象作为 err 字段:pino 会自动序列化堆栈;
  • 生产不要 pretty-print:JSON 才是日志平台最容易解析的格式。

三、结构化日志:让日志从文本变成数据

所谓结构化日志,就是每一行日志都是一个固定字段的 JSON 对象。比如下面这条日志:

{"level":"ERROR","time":"2026-07-11T15:32:01.234Z","pid":1234,"service":"user-service","userId":42,"action":"login","err":{"type":"Error","message":"DB timeout","stack":"..."},"msg":"Query failed"}

它比纯文本日志的优势在于:

  • 可以用 jq、Loki LogQL、Elasticsearch DSL 直接按字段过滤;
  • 可以按 userIdorderId 做聚类,快速定位单次用户行为;
  • 可以方便地对接告警系统,例如 "ERROR 级别且包含 err.type=Timeout" 触发 PagerDuty。

一个推荐的日志字段约定如下:

字段含义示例
level日志级别INFO / WARN / ERROR
timeISO 8601 时间戳2026-07-11T15:32:01.234Z
service服务名user-service
traceId分布式追踪 IDabc123def456
spanId当前 span IDspan-789
userId / orderId业务上下文42 / 20260711001
msg人类可读摘要Query failed

四、OpenTelemetry:从日志到全链路可观测

光有好的日志还不够。现代服务都是微服务架构,一个请求可能经过网关、Node.js 服务、Python 服务、数据库、缓存、消息队列。如果每个服务只打印自己的日志,出了问题你需要手动把日志串起来。OpenTelemetry 就是来解决这个问题的。

4.1 核心概念:Trace / Span / Context

  • Trace:一次完整请求的链路,包含唯一的 traceId;
  • Span:链路中的一个操作单元,比如"查询数据库"、"调用下游 API";
  • Context:当前上下文,用于跨进程、跨服务传递 traceId。
Gateway Node Service Python Service Database 同一 Trace 下多个 Span 串联,traceId 贯穿始终 Span 1: Gateway → Span 2: Auth → Span 3: DB Query → Span 4: Downstream API

4.2 Node.js 接入 OpenTelemetry

在 Node.js 里接入 OpenTelemetry,通常不需要改业务代码。官方提供了自动探测(auto-instrumentations)包,可以自动为 Express、HTTP、MySQL、Redis、MongoDB 等库生成 Span。下面是一份最小可运行配置:

# 安装依赖
npm install @opentelemetry/api @opentelemetry/sdk-node \
  @opentelemetry/auto-instrumentations-node \
  @opentelemetry/exporter-trace-otlp-http \
  @opentelemetry/exporter-logs-otlp-http
// tracing.js
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
const { OTLPLogExporter } = require('@opentelemetry/exporter-logs-otlp-http');
const { BatchSpanProcessor } = require('@opentelemetry/sdk-trace-base');

const sdk = new NodeSDK({
  traceExporter: new OTLPTraceExporter({
    url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318/v1/traces'
  }),
  logRecordExporter: new OTLPLogExporter({
    url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318/v1/logs'
  }),
  instrumentations: [getNodeAutoInstrumentations()],
  serviceName: process.env.OTEL_SERVICE_NAME || 'user-service'
});

sdk.start();

process.on('SIGTERM', async () => {
  await sdk.shutdown();
  process.exit(0);
});

启动时引入这个文件即可:

node -r ./tracing.js app.js

4.3 手动创建 Span 与日志关联

自动探测只能覆盖通用框架。对于业务关键路径,建议手动创建 Span,并把日志和 Trace 关联起来:

const { trace, context } = require('@opentelemetry/api');
const logger = require('./logger');

async function placeOrder(userId, order) {
  const tracer = trace.getTracer('order-service');
  return tracer.startActiveSpan('place-order', async (span) => {
    try {
      span.setAttribute('user.id', userId);
      span.setAttribute('order.amount', order.amount);

      logger.info({ userId, orderId: order.id, traceId: span.spanContext().traceId }, 'Order placed');

      await db.orders.insert(order);
      span.addEvent('db.insert.done');

      await paymentGateway.charge(order);
      span.addEvent('payment.done');

      span.setStatus({ code: SpanStatusCode.OK });
      return { success: true };
    } catch (err) {
      span.recordException(err);
      span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
      logger.error({ err, userId, orderId: order.id }, 'Order failed');
      throw err;
    } finally {
      span.end();
    }
  });
}

这样,你的日志里会包含 traceId,而在 Trace 系统里也能看到对应的日志事件。两者一结合,排查效率会提升一个数量级。

五、指标 Metrics:把"有多严重"量化

日志和 Trace 擅长回答单次请求的问题,但如果你想回答"过去一小时错误率是多少"、"P99 延迟有没有恶化",就需要指标。OpenTelemetry 的 Metrics API 在 Node.js 里同样可用:

const { metrics } = require('@opentelemetry/api');

const meter = metrics.getMeter('user-service');

const requestCounter = meter.createCounter('http_requests_total', {
  description: 'Total HTTP requests'
});

const requestDuration = meter.createHistogram('http_request_duration_ms', {
  description: 'HTTP request duration in ms',
  unit: 'ms'
});

function onRequest(req, res, duration) {
  const labels = { method: req.method, route: req.route.path, status: res.statusCode };
  requestCounter.add(1, labels);
  requestDuration.record(duration, labels);
}

常见的 Node.js 指标包括:HTTP 请求数/延迟、GC 耗时、事件循环延迟、内存占用、CPU 使用率。建议把 Node.js 进程指标(通过 node:v8node:eventloop)和业务指标一起采集,才能看清全貌。

六、生产环境最佳实践

  1. 日志输出到 stdout/stderr:容器化环境不要写文件,让 Docker/Kubernetes 采集标准输出;
  2. 采样策略:全量 Trace 在流量大时吃不消,可以用尾部采样(Tail-based Sampling)或概率采样(Head-based Sampling);
  3. 敏感信息脱敏:日志里不要打印密码、Token、身份证号,必要时用哈希或掩码;
  4. 关联 Request ID:在 Express/Koa 中间件里生成 x-request-id,并注入到 logger 的 base 字段;
  5. 错误日志带完整上下文:不要只打印 error.message,把请求参数、用户、链路 ID 都带上;
  6. 日志轮转:如果必须写文件,用 pino-roll 或外部工具(logrotate)控制大小;
  7. 本地可观测性先行:先用 Docker Compose 跑起 Grafana + Tempo + Loki + Prometheus,验证整套链路再上线。

七、常见陷阱与排查

  • 日志里打对象:用 logger.info(obj) 会把对象当成 msg,容易丢失字段。正确做法是 logger.info(obj, 'message')
  • 同步日志阻塞事件循环:大量日志直接写文件会拖慢请求响应。用 pino 的 transport 或 worker 线程缓冲;
  • 日志级别设置过低:生产开 debug,磁盘和网络都会被拖垮。info 足够,必要时临时调高;
  • 忽略未捕获异常:Node.js 里未捕获的 Promise 拒绝不会自动退出,但会丢失上下文。建议加上:
process.on('unhandledRejection', (reason, promise) => {
  logger.fatal({ reason, promise }, 'Unhandled Rejection');
  process.exit(1);
});

process.on('uncaughtException', (err) => {
  logger.fatal({ err }, 'Uncaught Exception');
  process.exit(1);
});
  • Trace 没有跨服务传播:微服务间调用一定要把 traceparent 头带过去,否则链路会断成两截;
  • Collector 不可用导致主业务受影响:OpenTelemetry 默认有批量和重试,但仍建议设超时和队列上限,避免阻塞主线程。

八、总结

Node.js 的可观测性建设不是一蹴而就的。起步时,先用 pino 把结构化日志打好,把日志级别、字段约定、错误处理规范起来;进阶时,引入 OpenTelemetry 把 Trace、Metrics、Logs 串起来,让每一次请求都有迹可循;最终,把这些数据汇聚到 Grafana/ELK/Jaeger 这类平台,形成统一的排障视图。记住一句话:日志打得越好,上线后睡得越香。