查看原文
其他

Node.js 日志管理最佳实践

小懒 FED实验室 2024-02-12
关注下方公众号,获取更多系列文章

日志管理是将应用程序活动产生的信息记录到日志文件中的过程。日志成为是持久保存应用程序运行信息的一种最简单方法。

对于 Node.js 开发者,良好的日志管理对于监控 Node.js 服务器并排除故障至关重要。它们可以帮助您跟踪应用程序中的错误,发现性能优化的机会,并对系统进行不同类型的分析,从而做出快速的处理方案。

尽管日志是构建强大 Web 应用程序的一个重要方面,但仍被部分开发者忽视或轻描淡写。了解记录什么和如何记录可能是一件棘手的事情,因为通常很难理解在故障排除过程中需要哪些信息。

本文将阐述在 Node.js 应用程序中进行日志管理时应该遵循的一些最佳实践,希望对部分开发者有所帮助。

1.选择好用的 Node.js 日志库

Node.js 开发人员倾向于依赖运行时的控制台方法(如console.log())来记录事件,因为它内置于运行时并提供了类似于 Web 浏览器提供的JavaScript 控制台机制类似的、熟悉的API。

虽然 console.log() 有其用途,但它不适合在生产环境应用程序中实现日志记录。它缺乏对一些功能和配置选项的支持,这些功能和选项被认为是良好日志设置的必要条件。例如,尽管提供了 console.warn()console.error()console.debug() 等方法,但控制台方法并不支持警告、错误或调试等日志级别。它们只是简单地打印到标准输出或标准错误,而没有指示日志的严重性。

一个好的日志库提供了一个强大的功能集,使得集中、格式化和分发日志可以更容易满足您的需求。例如,一个典型的日志框架将提供各种选项来输出日志数据的位置(如终端、文件系统或数据库),同时还支持通过 HTTP 发送日志,如果您想将日志条目传输到日志管理服务上。

选择一个合适的日志库有三个主要考虑因素:记录、格式化和存储消息。您需要确保您选择的库以令人满意的方式解决了所有三个问题。选择日志库的另一个关键考虑因素是性能。由于日志记录器会在整个代码库中经常使用,它可能会影响应用程序的运行性能。因此,您还应该了解某个库的性能特性,并查看它与其他选择的比较情况。

在 Node.js 生态系统中,有几个流行的选项可供选择。它们中的大多数都提供类似的功能,但它们也有各自的区别。因此,您必须亲自试用它们,看看哪一个最适合您:

  • Winston:最流行的日志库,支持多种传输方式。这让你可以轻松配置自己喜欢的日志存储位置。
  • Pino:最大的吸引力在于它的速度。在许多情况下,它声称比其他产品快五倍。
  • Bunyan:一个功能丰富的日志框架,默认以 JSON 格式输出,并提供一个 CLI 工具用于查看日志。
  • Roarr:一种不同的日志记录器,可在 Node.js 和浏览器中运行。
  • log4js:不依赖运行时的日志框架。

在日常全栈开发中,小懒所在团队使用的 winston 来管理日志,选择它的理由不一定是对 winston 的完全任可和推崇,而是因为它是 Node.js 中最流行的日志框架,截止当前 star 数为 21.4k。

2.使用正确的日志级别

如果你在软件行业工作过一段时间,你可能对日志级别有所了解。它们为区分系统中的事件类型提供了一种方法,并为每个事件的重要性添加了上下文。如果在应用程序中正确使用日志级别,就可以很容易地区分需要立即处理的关键事件和纯粹的信息事件。

虽然不同的日志系统可能给严重程度级别取不同的名称,但概念基本相同。无论你选择哪种日志框架,你可能会遇到以下最常见的日志级别(按照严重程度递减):

  • FATAL:用于表示灾难性情况--应用程序无法恢复。在这个级别记录日志通常意味着程序的结束。
  • ERROR:表示系统中发生的错误情况,会中止特定操作,但不会影响整个系统。当第三方 API 返回错误时,可以在此级别记录日志。
  • WARN:表示运行时的条件不良或异常,但并不一定是错误。例如,在主要数据源不可用时,使用备份数据源可能会记录在这个级别。
  • INFO:消息信息。用户驱动或特定于应用程序的事件可在此级别记录。此级别的常见用途是记录有趣的运行时事件,如服务的启动或关闭。
  • DEBUG:用于表示故障排除所需的诊断信息。
  • TRACE:记录开发过程中关于应用程序行为的每个可能的细节。

winston 库默认使用以下日志级别,其中 error 是最严重的,silly 是最不严重的:

{
  "error"0,
  "warn"1,
  "info"2,
  "http"3,
  "verbose"4,
  "debug"5,
  "silly"6
}

如果默认值不符合您的需求,您可以在初始化自定义日志记录器时对其进行更改。

const { createLogger, format, transports } = require("winston");
 
const logLevels = {
  fatal0,
  error1,
  warn2,
  info3,
  debug4,
  trace5,
};
 
const logger = createLogger({
  levels: logLevels,
  transports: [new transports.Console()],
});

要记录信息时,可直接在代码中直接引用所需的级别,如下图所示:

// {"message":"System launch","level":"info"}
logger.info("System launch"); 
// {"message":"A critical failure!","level":"fatal"}
logger.fatal("A critical failure!"); 

Winston 还允许你在日志记录器和每个传输上定义级别属性,指定应记录的消息的最高级别。例如,你可以默认以信息级别运行程序,然后在需要排除故障或部署到测试环境时切换到调试或跟踪级别。你应该通过环境变量来控制这一设置。

const logger = winston.createLogger({
  level"warn",
  levels: logLevels,
  transports: [new transports.Console({ level"info" })],
});

3.使用结构化日志

在定义日志信息的展现格式时,优先考虑使日志条目易于阅读,无论是对人类还是机器。

日志的一个主要目的之一是进行事后调试,这就需要通过阅读日志条目来重现导致系统事件发生的步骤。具有良好可读性的日志条目将使开发人员和系统管理员在这项艰巨的任务中更加容易。同时,使用易于机器解析的结构化格式也很重要。这可以对日志进行一些自动化处理(例如用于报警或审计目的)。

JSON 是结构化日志条目的普遍首选,因为它无处不在,并且容易被人类阅读。它也非常易于机器解析,并且可以轻松转换为其他格式,甚至在使用其他编程语言时也是如此。在使用 JSON 记录日志时,需要使用标准模式来明确定义每个字段的语义。这也使得在分析日志条目时更容易找到所需的内容。

Winston 默认使用两个字段输出 JSON 字符串:messagelevel。前者包含正在记录的文本,而后者表示日志级别。通过 winston.format,可以通过使用 logform 来自定义输出格式。例如,如果您想要为每个日志条目添加时间戳,可以将时间戳和 json 格式结合使用,如下所示:

const { createLogger, format, transports } = require("winston");
 
const logger = createLogger({
  format: format.combine(format.timestamp(), format.json()),
  transports: [new transports.Console({})],
});

这将产生以下格式的日志记录:

{"message":"Connected to DB!","level":"info","timestamp":"2021-07-28T22:35:27.758Z"}
{"message":"Payment received","level":"info","timestamp":"2021-07-28T22:45:27.758Z"}

4.撰写描述性信息

日志条目应充分描述其所代表的事件。每条信息都应与当时的情况相符,并清楚地解释当时发生的事件。在紧急情况下,日志条目可能是帮助您了解所发生事件的唯一信息来源,因此做好这方面的日志记录非常重要!

下面是一个使用不恰当的日志信息来传达请求失败的示例:

Request failed, will retry.

上述信息没有提供任何详细信息:

  • 失败的具体请求
  • 失败的原因
  • 请求重试前的时长

我们或许可以从其他地方(如其他日志条目,甚至代码本身)找到其中一些问题的答案。不过,最好还是通过更具描述性的信息,使日志条目本身更有价值:

"POST" request to "https://example.com/api" failed. Response code: "429", response message: "too many requests". Retrying after "60" seconds.

第二条信息要好得多,因为它提供了有关请求失败的充分信息,包括状态代码和响应信息,还指出请求将在 60 秒后重试。如果你的所有信息都能像这样描述清楚,那么你在尝试理解日志时就会更轻松。优秀日志信息的其他示例包括以下内容:

Status of task id "1234" changed from "IN_PROGRESS" to "COMPLETED".
SomeMethod() processed "100" records in "35ms".
User registration failed: field "email" is not valid email address; field "password" is below the minimum 8 characters.

在撰写日志信息时,应包含与事件有关的所有相关细节,但不要过于冗长。这将避免其他日志阅读者(可能包括你未来的自己)被过多的信息所淹没。日志信息还应该能够独立存在;不要依赖以前的信息内容来为后面的条目提供背景。

5.添加适当的背景信息

除了编写描述性的日志消息外,您还需要在日志条目中包含适量的上下文。上下文使得能够快速重现事件发生之前的操作。将基本信息添加到日志中,例如事件的时间戳和发生事件的方法(或在错误情况下为堆栈跟踪)。您还应该添加与触发事件的操作流相关的数据点。这些数据点可能在操作流的不同部分生成,并在记录点进行汇总。

在计费服务的环境中,系统生成的日志条目可以包括多个数据点,包括:

  • 会话标识符
  • 用户名和ID
  • 产品或交易标识符
  • 用户当前所在页面

您可以使用上述每个数据点来跟踪用户在整个结账过程中的流程。如果发生重要事件,可用的数据将自动附加到日志输出中,并且可以确定:

  • 导致事件发生的情况(如经历事件的用户)
  • 发生事件的页面
  • 触发事件的交易和产品ID。
  • 这些数据点还让您可以根据共同标识符(如用户ID或产品ID)筛选日志条目。

Winston 提供了向每个生成的日志条目添加全局元数据(例如事件发生的组件或服务)的功能。在复杂的应用程序中,日志中的这些信息有助于排除故障,因为它们能够立即将您指向故障点。

在为组件或服务创建日志记录器时,您可以配置此功能:

const logger = createLogger({
  format: format.combine(format.timestamp(), format.json()),
  defaultMeta: {
    service"baidu-service",
  },
  transports: [new transports.Console({})],
});

服务字段将包含在日志记录器对象创建的所有日志中:

{"message":"Order \"1234\" was processed successfully","level":"info","service":"baidu-service","timestamp":"2021-07-29T10:56:14.651Z"}

要为单个条目添加元数据,需要创建一个上下文或元数据对象,在整个操作流程中传递,以便在记录点访问数据。您还可以利用子日志记录器的概念,在日志记录点添加元数据:

const ctx = {
  userId"090121",
  productId"baidu-product-id",
};

logger.child({ context: ctx }).info('Order "1234" was processed successfully');
// {"context":{"userId":"090121","productId":"baidu-product-id"},"message":"Order \"1234\" was processed successfully","level":"info","service":"billing-service","timestamp":"2021-07-29T12:20:13.249Z"}

6.避免记录敏感信息

无论您是在遵守严格合规规定的行业(如医疗保健或金融)中工作,还是在其他行业工作,避免在日志中包含敏感信息非常重要。

敏感信息包括安全号码、地址、密码、信用卡详细信息、访问令牌和类似的数据类型。由于日志消息通常以纯文本形式存储,如果日志落入坏人手中,这些数据将被暴露出来。您还需要确保您记录的某些信息不会违反适用于您产品运营地国家的法律法规。

您可以通过最小化系统中处理该数据的部分来避免在日志中意外泄露敏感数据。虽然这并不是一个百分百的解决方案,您还可以使用黑名单来防止特定字段进入日志中。

7.自动记录未捕获的异常和未处理的承诺拒绝

当遇到未捕获异常或未处理承诺拒绝时,最好的做法就是让程序崩溃。使用 PM2 等进程管理器自动重启进程,将程序恢复到干净的状态。

要了解此类事件发生的原因,还需要在退出前记录异常或承诺拒绝的详细信息。Winston 为这两种情况都提供了处理程序,可以在 logger 实例中进行配置:

const logger = createLogger({
  transports: [new transports.File({ filename"file.log" })],
  exceptionHandlers: [new transports.File({ filename"exceptions.log" })],
  rejectionHandlers: [new transports.File({ filename"rejections.log" })],
});

在上面的示例中,未捕获的异常将被记录到 exceptions.log 文件中,而未处理的拒绝将被记录到 rejections.log 文件中。日志条目将自动包含完整的堆栈跟踪以及与异常相关的进程参数和内存使用信息,为你提供查找问题根源所需的所有详细信息。

8.做好日志滚动和删除

可以根据日期、大小限制轮换日志,也可以根据计数或已过天数删除旧日志。需要安装额外的 Winston 库:

npm install winston-daily-rotate-file

现在,我们可以用以下代码将新的传输方式添加到 logger.js 文件中:

winston.createLogger({
  level'info',
  format: combine(infoFormat),
  transports: [
    new winston.transports.DailyRotateFile({
      filename,
      datePattern'YYYY-MM-DD',
      maxSize'10g',
      maxFiles10,
    }
  ],
});

在这段代码中,我们设置了日志按天滚动。我们还定义了:

  • maxSize:文件的最大大小,之后将进行滚动。可以是字节数,也可以是 KB、MB 或 GB 单位。如果使用单位,请添加 "k"、"m "或 "g"作为后缀。
  • maxFiles:保存日志的最大文件/天数。超过此期限后,最旧的日志文件将被删除。可以是文件数,也可以是天数。如果使用天数,请添加 "d" 作为后缀。

第二天,日志将被写入另一个文件。如果日志文件大小超过了定义的限制,日志就会被删除。此外,旧日志将在您定义的日期后自动删除。

9.做好日志同步和上传

在以上的最近实践基础上,我们要做好日志的定时同步和上传(一般在凌晨,流量最小,对服务机器压力较小)。为后续监控系统接入和例行看板建设做好准备。

总结

日志管理作为 Node.js 应用程序开发中的重要一环,需要更多的开发者重视起来,这样出现线上问题才能更好地实时分析、定位和快速修复。

‍大家都在看

继续滑动看下一个

Node.js 日志管理最佳实践

小懒 FED实验室
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存