Featured image of post Node.js 日志记录最佳实践:结构化且可扩展Featured image of post Node.js 日志记录最佳实践:结构化且可扩展

Node.js 日志记录最佳实践:结构化且可扩展

Node.js 日志记录最佳实践涵盖结构化 JSON 日志记录、pino 与 winston、日志级别、相关 ID、传输集成、日志轮换和 ELK/Loki。

生产级日志记录是 Node.js 应用程序开发中最容易被忽视的方面之一。虽然 console.log 适用于本地调试,但它在日志必须可搜索、结构化和可操作的分布式环境中会崩溃。本文介绍了构建可扩展的日志记录策略的基本模式。

为什么要结构化日志记录

传统的非结构化日志记录输出难以以编程方式解析的纯文本。考虑 console.log("User logged in:", userId)。在数百个服务实例中执行 grep 操作非常缓慢且容易出错。结构化日志记录将每个日志事件输出为 JSON 对象,使其可供机器读取并可由日志聚合系统查询。

// Unstructured - avoid in production
console.log("Payment processed:", paymentId, "for user:", userId);

// Structured - preferred
logger.info({ paymentId, userId, amount, currency }, "Payment processed");

JSON 格式可以在 Elasticsearch、Loki 和 Datadog 等工具中实现基于字段的过滤、警报和仪表板,而无需自定义解析逻辑。


选择记录器:Pino 与 Winston

两个库主导了 Node.js 日志记录领域。正确的选择取决于您的性能要求和生态系统需求。

特色皮诺温斯顿
速度每条日志行约 0.5 µs每条日志行约 3–5 µs
交通内置较少,可通过 pino-multi-stream 扩展丰富的生态系统(文件、HTTP、Syslog、自定义)
儿童伐木者占位符_0占位符_1
编辑通过 redact 选项内置需要 fast-redact 或手动方法
开发经验pino-pretty 用于人类可读的输出内置格式选项

Pino 是每一微秒都至关重要的高吞吐量服务的最佳选择。当您需要复杂的传输路由或将现有基础设施与其插件模型绑定时,Winston 会表现出色。

// Pino setup
const pino = require("pino");
const logger = pino({
  level: process.env.LOG_LEVEL || "info",
  redact: ["password", "authorization"],
  transport: process.env.NODE_ENV !== "production"
    ? { target: "pino-pretty" }
    : undefined,
});

日志级别以及何时使用它们

遵守 RFC 5424 日志级别可确保跨服务的一致性。每个级别都表示事件的严重性和可操作性。

水平价值何时使用
占位符_060应用程序崩溃迫在眉睫
占位符_05050请求失败,但进程继续
占位符_04040意外但非危急情况
占位符_030正常运营里程碑
占位符_020详细诊断信息
占位符_01010非常详细的执行流程

通过环境变量动态控制日志级别,以便您可以增加生产中的详细程度而无需重新部署:

const LOG_LEVEL_MAP = { fatal: 60, error: 50, warn: 40, info: 30, debug: 20, trace: 10 };
const currentLevel = LOG_LEVEL_MAP[process.env.LOG_LEVEL] || LOG_LEVEL_MAP.info;

请求关联 ID

在微服务架构中,单个用户请求可能会遍历多个服务。单独记录每个服务会使调试变得不可能。解决方案是为每个传入请求分配一个唯一的关联 ID,并将其跨服务边界传播。

const { AsyncLocalStorage } = require("async_hooks");
const asyncLocalStorage = new AsyncLocalStorage();

app.use((req, res, next) => {
  const correlationId = req.headers["x-correlation-id"] || crypto.randomUUID();
  res.setHeader("x-correlation-id", correlationId);
  req.log = logger.child({ correlationId });
  asyncLocalStorage.run(new Map([["correlationId", correlationId]]), () => next());
});

使用 AsyncLocalStorage 从任何异步上下文访问相关 ID,而无需将其传递给每个函数签名。通过 HTTP 客户端将标头转发到下游服务,以便端到端跟踪在整个架构中发挥作用。


交通一体化

传输定义日志输出的去向。控制台传输是容器化环境中的默认设置,但许多生产设置需要多个目的地。

// Example: logging to both console and a remote endpoint
const pino = require("pino");
const logger = pino({
  level: "info",
}, pino.multistream([
  { stream: process.stdout },
  { stream: pino.transport({ target: "pino-http", options: { uri: "https://logs.example.com" } }) },
]));

避免生产中的同步传输——它们会阻塞事件循环并抵消异步 I/O 的性能优势。


使用 ELK 和 Loki 进行集中日志记录

将日志发送到集中式系统会将原始文本转变为可搜索的可观察平台。

ELK Stack: 通过 Filebeat 或 Logstash 将 JSON 日志发送到 Elasticsearch,然后在 Kibana 中进行可视化。典型的 Docker Compose 设置包括 Elasticsearch、Logstash(带有 JSON 输入插件)和 Kibana。

Loki + Grafana: Loki 按标签而不是全文对日志流进行索引,这使得 Kubernetes 环境中的大容量日志记录更具成本效益。将其与 Grafana 配对以进行仪表板和警报。

# docker-compose.yml snippet for local ELK
services:
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.x
  logstash:
    image: docker.elastic.co/logstash/logstash:8.x
  kibana:
    image: docker.elastic.co/kibana/kibana:8.x

错误记录和编辑

始终记录错误及其完整堆栈跟踪。当在 err 键下传递时,Pino 会自动序列化 Error 对象:

try {
  await processPayment(order);
} catch (err) {
  logger.error({ err, orderId: order.id }, "Payment processing failed");
}

切勿记录敏感数据。 Pino 的内置修订从输出中删除指定的路径:

const logger = pino({
  redact: ["password", "authorization", "req.headers.cookie", "creditCard"],
});

性能考虑因素

日志记录就是 I/O,过多的 I/O 会降低应用程序的吞吐量。关键基准测试显示,Pino 每行日志记录时间约为 0.5 µs,而 Winston 为 3–5 µs。对于高流量端点,请考虑采样(仅记录一部分事件)并始终使用异步传输。在 warn 级别,像 logger.debug() 这样的较低级别的调用应该几乎是免费的;皮诺通过电平短路来实现这一点。


结论

结构化 JSON 日志记录、仔细的库选择、相关 ID 和集中聚合是可观察 Node.js 应用程序的基础。首先为您的工作负载选择正确的记录器,从第一天开始实施相关 ID,并将日志路由到支持基于字段的查询的系统。有了这些模式,调试生产问题就变成了运行搜索查询的问题,而不是梳理平面文件。