在生产环境跑 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。下面是一张核心对比表:
| 特性 | pino | winston | bunyan | log4js |
|---|---|---|---|---|
| 性能 | 极高(最小开销) | 中等 | 高 | 中等 |
| 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 直接按字段过滤; - 可以按
userId、orderId做聚类,快速定位单次用户行为; - 可以方便地对接告警系统,例如 "ERROR 级别且包含 err.type=Timeout" 触发 PagerDuty。
一个推荐的日志字段约定如下:
| 字段 | 含义 | 示例 |
|---|---|---|
| level | 日志级别 | INFO / WARN / ERROR |
| time | ISO 8601 时间戳 | 2026-07-11T15:32:01.234Z |
| service | 服务名 | user-service |
| traceId | 分布式追踪 ID | abc123def456 |
| spanId | 当前 span ID | span-789 |
| userId / orderId | 业务上下文 | 42 / 20260711001 |
| msg | 人类可读摘要 | Query failed |
四、OpenTelemetry:从日志到全链路可观测
光有好的日志还不够。现代服务都是微服务架构,一个请求可能经过网关、Node.js 服务、Python 服务、数据库、缓存、消息队列。如果每个服务只打印自己的日志,出了问题你需要手动把日志串起来。OpenTelemetry 就是来解决这个问题的。
4.1 核心概念:Trace / Span / Context
- Trace:一次完整请求的链路,包含唯一的 traceId;
- Span:链路中的一个操作单元,比如"查询数据库"、"调用下游 API";
- Context:当前上下文,用于跨进程、跨服务传递 traceId。
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:v8、node:eventloop)和业务指标一起采集,才能看清全貌。
六、生产环境最佳实践
- 日志输出到 stdout/stderr:容器化环境不要写文件,让 Docker/Kubernetes 采集标准输出;
- 采样策略:全量 Trace 在流量大时吃不消,可以用尾部采样(Tail-based Sampling)或概率采样(Head-based Sampling);
- 敏感信息脱敏:日志里不要打印密码、Token、身份证号,必要时用哈希或掩码;
- 关联 Request ID:在 Express/Koa 中间件里生成
x-request-id,并注入到 logger 的 base 字段; - 错误日志带完整上下文:不要只打印
error.message,把请求参数、用户、链路 ID 都带上; - 日志轮转:如果必须写文件,用
pino-roll或外部工具(logrotate)控制大小; - 本地可观测性先行:先用 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 这类平台,形成统一的排障视图。记住一句话:日志打得越好,上线后睡得越香。