首页 / 文章

构建可观测的Node.js API:日志记录、指标统计与追踪功能

了解结构化日志记录、请求追踪、指标统计以及规范化的警报机制是如何相互配合,从而大幅降低调试生产环境中的Node.js API的难度的。

发布者:REACTAPP.TOP阅读约 17 分钟4023 词

来源: When Your Node.js API Breaks: What I Learned About Logging and Observability

当你刚开始编写 Node.js API 时,大部分注意力都会集中在确认代码确实能正常运行上。

接口会返回你预期的响应。

数据库查询也能正确执行。

认证功能也按预期工作。

前端与后端之间可以毫无问题地通信。

到了这个阶段,你可能会认为 API 已经开发完成了。

但最终会有另一个问题浮现:

如果系统出故障而你恰好没有正在查看代码,那该怎么办?

系统上线后请求可能会失败。

数据库查询的耗时可能会逐渐增加。

外部服务可能会突然停止响应。

用户会告诉你系统出了问题。

某个接口会莫名其妙地开始抛出 500 错误。

尽管如此,服务本身可能仍然在正常运行。

真正的挑战在于弄清楚它为何会出现异常行为。

正是在这一点上,日志记录与可观测性不再只是附加功能,而变成了构建 API 时的核心要素。

编写一个能正常运行的 API 已经是一大挑战。

而编写一个在出问题时能够被有效诊断的 API,则是完全不同的另一回事。

日志记录是我首先需要重新思考的部分

和大多数学习编程的人一样,初学者通常习惯使用 console.log() 来记录日志。

大致如下所示:

console.log("Login request received");
console.log(user);
console.log("Database query completed");

这种方法在本地开发时效果不错。

速度很快。

操作简单。

而且在自己的机器上调试时,已有足够的上下文来理解输出内容。

一旦应用程序的复杂性增加,一切就会变得混乱。

你可能需要处理成百上千个并发请求。

还有许多不同的用户。

你的 API 会同时运行多个实例。

后台任务也会并行执行。

数据库调用会持续不断。

还会向第三方服务发起请求。

认证尝试也会频繁失败。

在如此大规模的情况下,仅仅将一堆非结构化的字符串打印到终端已无法发挥其价值。

这促使人们重新思考日志的定位。

一条日志记录不应只是简单宣告:

“发生了某件事。”

相反,一条优质的日志记录应能让人们了解究竟发生了什么、发生的时间、涉及系统的哪个部分,以及为彻底分析问题所需的所有相关细节。

有用的日志包含上下文信息

看看这两种方法的区别。

不太有用

Something went wrong

与以下方式相比:

{
  "level": "error",
  "message": "Database query failed",
  "method": "GET",
  "route": "/api/projects",
  "requestId": "abc123",
  "durationMs": 842
}

第二个版本能立即提供更有用的信息。

你可以立刻知道:

  • 实际发生了什么故障
  • 是哪个操作触发了故障
  • 使用了哪种HTTP方法
  • 哪个路由受到了影响
  • 可以追踪到的是哪条具体请求
  • 该操作的执行耗时多久

具体字段会因应用而异。

重要的是要有上下文信息。

结构化日志改变了我对日志的看法

事实证明,结构化日志这一概念的价值远超预期。

不必像这样将事件以纯文本、自由格式的形式记录:

User login failed for some reason

可以将其表示为结构化数据:

{
  "level": "warn",
  "event": "login_failed",
  "requestId": "abc123",
  "ip": "example",
  "timestamp": "..."
}

一旦日志被结构化,搜索和过滤就会变得容易得多。

可以查询诸如以下内容:

level = error

或者:

event = login_failed

或者:

route = /api/auth/login

当日志被集中到统一的日志平台中时,这种做法的优势尤为明显。

无需在原始文本中无休止地滚动查找,就可以提出精确、有针对性的查询。

不要记录所有内容

这是另一个容易陷入的陷阱。

在调试时,人们往往会希望获取更多数据而非更少。

结果就是会把能想到的所有内容都记录下来。

请求体。

响应体。

完整的用户对象。

数据库查询结果。

请求头。

认证令牌。

环境信息。

内部错误对象。

不久之后,你就会面对海量记录的数据。

这会引发两个独立的问题。

首先是找到真正重要的信息变得愈发困难。

第二个更为严重的问题是,你有可能意外捕获敏感数据。

这个问题值得单独设一节来讨论。

切勿将日志视为存放机密信息的安全场所

这已成为需要牢记的重要安全准则之一。

仅仅因为调试时方便,并不意味着日志是存储敏感凭证的合适地点。

通常需要格外小心的数据类别包括:

  • 用户密码
  • 用于访问的OAuth或令牌
  • 用于刷新会话的令牌
  • 用于API身份验证的密钥
  • 与会话管理相关的机密信息
  • 用于连接数据库的凭据
  • 用于验证会话的Cookie
  • 任何可识别个人身份或属于用户的隐私数据
  • 举个例子,这确实是个糟糕的主意:

    console.log(req.headers.authorization);
    

    它或许能在排查错误时为你节省几分钟时间。

    但那些数据会在调试会话结束后仍长期存在于日志中。

    你的日志系统本身也需要得到保护。

    如果有人未经授权访问了你的日志系统,他们可能会获取到你本不想公开的数据。

    请求ID变得极为有用

    想象一下客户端发送如下请求:

    POST /api/orders
    

    该请求会经过多个层级处理:

    Client
       ↓
    Load Balancer
       ↓
    Node.js API
       ↓
    Database
       ↓
    Payment service
    

    在传输过程中的某个环节,它就会失败。

    现在想象一下,与那个请求相关的每一条日志都带有相同的标识符:

    requestId = 7f91...
    

    你可以使用该标识符进行搜索,从而还原完整的事件顺序。

    例如:

    request started
          ↓
    authentication successful
          ↓
    database query started
          ↓
    database query completed
          ↓
    payment request started
          ↓
    payment service timeout
          ↓
    request failed
    

    如果没有共享的请求标识符,这些独立的日志条目就很难相互关联。

    有了统一的标识符后,它们就能构成一个连贯的叙述,而非零散、互不关联的事件。

    日志记录与调试是两种不同的活动

    随着时间的推移,这两者之间的区别变得越来越明显。

    在本地开发时,你可能会插入一些非常具体的代码来检查某个值:

    console.log(result);
    

    这对你来说,当下确实很有帮助。

    但用于生产环境的日志记录则需要不同的设计思路。

    生产环境中的日志应当:

    • 保持一致性
  • 可搜索
  • 结构化
  • 实用
  • 安全
  • 量级合理
  • 简单的调试打印语句与真正的可观测性策略并非同一回事。

    不同的日志级别各有其特定用途

    常见的做法是定义诸如以下的级别:

    debug
    info
    warn
    error
    

    不同工具和团队使用的具体名称各不相同。

    但其核心理念始终一致。

    调试

    在开发过程中主动调查或解决问题时所需的详细信息。

    信息

    正常运行期间发生的常规事件。

    例如:

    Server started
    Request completed
    Background job completed
    

    警告

    发生了异常情况,但应用程序仍能正常运行。

    例如:

    External service response was slow
    

    错误

    出现了故障,需要有人处理。

    例如:

    Database connection failed
    

    记住这些分类并非重点。

    关键在于始终如一地应用它们。

    HTTP 状态码也具有可观测性价值

    起初,状态码似乎只是为客户端服务的工具。

    实际上它们还能帮助我们了解 API 的真实运行状况。

    例如:

    200 → successful request
    201 → resource created
    400 → invalid request
    401 → authentication required/failed
    403 → forbidden
    404 → resource not found
    429 → rate limited
    500 → internal server error
    

    想象一下,您的控制面板突然显示出以下指标的激增:

    500 responses
    

    这是个值得调查的警告信号。

    或者想象以下指标的突然上升:

    429 responses
    

    这种增长可能表明存在异常的流量模式、过于激进的客户端,或是配置错误的速率限制。

    只要在 API 中始终如一地使用状态码,它们就能变成有意义的信号。

    错误信息应帮助您的团队,而非攻击者

    另一个值得学习的平衡点在于内部错误细节与展示给客户的内容之间的界限。

    假设数据库写入操作失败了。

    在系统内部,原始错误信息可能如下所示:

    unique constraint violation on users_email_key
    

    这样的信息确实对调试很有帮助。

    但直接将原始的数据库错误信息提供给最终用户通常并不合适。

    相反,API响应可以设计为如下形式:

    {
      "success": false,
      "message": "The email address is already registered."
    }
    

    与此同时,完整的技术细节仍保留在服务器端的日志中,这才是它们的归宿。

    这种方法能让团队获得所需信息,同时避免向调用API的用户泄露内部实现细节。

    堆栈跟踪需谨慎处理

    当出现故障时,堆栈跟踪对于定位问题所在具有极大的帮助。

    但它也能揭示系统架构的内部细节。

    正因如此,在不同场景下处理方式存在显著差异:

    开发阶段

    在此阶段,完整详细的错误信息通常确实非常有用。

    生产环境

    客户端通常应收到经过控制的通用错误响应,而完整的诊断细节则应保存在安全的日志中。

    生产环境中的 API 绝不应仅仅因为请求失败就返回其全部内部堆栈跟踪信息。

    接下来是指标的作用

    日志用于回答如下问题:

    刚刚发生了什么?

    指标则用于回答另一类问题:

    这种情况发生的频率有多高?

    示例包括:

    Requests per second
    Error rate
    Response latency
    Database connection count
    CPU usage
    Memory usage
    

    想象一个通常会这样响应的 API:

    150 ms
    

    然后平均延迟就会上升至:

    1200 ms
    

    即便每个请求仍然返回 200 状态码,显然已有变化发生。

    这种变化很重要。

    API 不一定出了故障。

    但它也没有像平常那样运行了。

    正是各项指标帮助我们发现这类变化。

    仅凭平均值可能会产生误导

    这又是一个重要的教训。

    假设你的 API 处理了 10,000 个请求。

    其中大部分的处理时间在:

    100–200 ms
    

    但有一小部分需要:

    5–10 seconds
    

    用所有请求的平均值很容易掩盖那部分异常值。

    这就是为什么通常会用百分位数来衡量延迟的原因。

    例如:

    p50
    p95
    p99
    

    大致来说:

    p50——代表典型请求的情况。

    p95——代表那些响应速度较慢的请求所经历的情况。

    p99——代表最糟糕情况下的流量状况。

    你并不总是需要追踪所有这些数据。

    但仅依赖平均值可能会掩盖实际问题。

    日志描述事件;指标揭示趋势

    在实践中,这种区分确实非常有用。

    想象一下:

    Metric:
    API latency increased
    

    这表明有某些东西发生了变化。

    接着你查看日志,发现:

    Database query took 4.8 seconds
    

    现在你就有了一个合理的成因。

    这两种数据来源可以相互补充。

    你可以这样理解它们之间的关系:

    Metrics
       ↓
    Something looks wrong
       ↓
    Logs
       ↓
    What happened?
       ↓
    Tracing
       ↓
    Where did it happen?
    

    这自然引出了另一个与可观测性相关的概念。

    从始至终的请求追踪

    通过追踪功能,你可以将单个请求视作一个完整的过程,观察它在系统各组件中的流转情况。

    例如:

    HTTP Request
         │
         ├── Authentication
         │
         ├── Controller
         │
         ├── Service
         │
         ├── Database query
         │
         └── External API
    

    流程中的每一步都可以携带自身的时间数据。

    你可能会发现,API层本身仅需要:

    200 ms
    

    而调用某些外部服务则会消耗:

    4 seconds
    

    如果没有追踪功能,通常只能看到类似这样的模糊描述:

    API is slow
    

    一旦启用了追踪功能,就可以提出更具体的问题:

    是这个请求的哪个环节导致了性能下降?

    这样的问题更有利于进行调试。

    日志、指标和追踪分别回答不同的问题

    这样理解这三种信息会更有帮助:

    SignalCore:问题日志——发生了什么?指标——频率如何,或数量多少?追踪——在请求处理过程中时间究竟用在了哪里?

    这三者之间存在一定重叠,但没有任何一种可以完全替代另外两种。

    一个设计良好的系统通常会同时依赖这三种方式。

    购买工具并不等同于实现可观测性

    这可能是整个过程中最重要的一点认识。

    人们很容易认为:

    “一旦我安装了某种可观测性工具,我的应用就能被监控了。”

    但实际上并非如此。

    工具擅长收集数据。

    但仍然需要有人来回答诸如以下问题:

    • 究竟需要测量什么?
    • 哪些内容值得记录?
    • 哪些事件具有意义?
    • 什么才算错误?
  • 什么条件需要触发警报?
  • 哪些字段属于敏感信息?
  • 这些数据应保存多久?
  • 如何将相关的请求关联起来?
  • 搭建工具本身往往并非最困难的部分。

    真正关键的是确定哪些信息才是重要的。

    OpenTelemetry 的作用

    在深入研究可观测性技术时,OpenTelemetry 不断被提及为一个值得了解的概念。

    它的价值在于规范了开发者处理遥测数据的方式,涵盖以下内容:

    • 追踪信息
    • 指标数据
    • 日志记录

    与依赖单一监控供应商的不同方案相比,OpenTelemetry 提供了一种与供应商无关的方式来生成和收集这些遥测数据。

    一个小型 Node.js 项目从一开始可能就不需要一套完整的可观测性解决方案。

    不过,尽早掌握这些概念能大大简化后续构建可扩展为更成熟系统的过程。

    遥测数据同样需要与日志相同的隐私保护措施

    这里有一个值得重申的安全要点。

    适用于日志的规则同样适用于遥测数据:

    收集更多数据并不一定意味着改进。

    你必须谨慎决定要纳入哪些内容:

    • 日志
    • 追踪信息
    • 指标数据
    • 错误跟踪工具
    • 监控仪表板

    在调试时,将整个 HTTP 请求体包含在追踪信息中或许会显得很方便。

    但请考虑如果该请求体包含以下内容会发生什么:

    password
    token
    personal information
    payment information
    

    突然之间,你的可观测性管道变成了另一个存储敏感数据的场所。

    安全问题并不仅限于日志——它还涉及整个可观测性体系。

    不同环境下的详细程度应有所差异

    在本地开发时,通常希望立即获得尽可能多的信息。

    这往往意味着需要开启更详细的日志记录功能。

    而在生产环境中,重点则转向:

    • 保持高信噪比
    • 性能开销
    • 安全风险
    • 数据的可搜索性
    • 数据保留策略
    • 警报机制

    在开发环境中表现良好的日志设置未必适合生产环境。

    这就是为何需要根据不同环境配置日志的原因之一。

    数据库行为需要被单独关注

    对于大多数后端系统而言,当 API 响应变慢时,数据库通常是首先需要检查的地方之一。

    想象一下这个接口突然开始需要数秒才能响应:

    GET /api/projects
    

    应用层看起来可能完全正常。

    但实际上底层的查询可能存在以下问题:

    • 缺少必要的索引
    • 检索的行数过多
    • 进行了不必要的连接操作
    • 执行次数超出预期
    • 因等待其他操作而阻塞
    • 使用了低效的执行计划

    这直接关联到之前关于如何合理设计 PostgreSQL 数据结构的讨论。

    良好的数据库设计与完善的可观测性相辅相成。

    单独依靠其中任何一方都是不够的。

    可观测性能让 N+1 问题显现出来

    以这样的场景为例:

    Get 100 projects
           ↓
    Get owner for project 1
    Get owner for project 2
    Get owner for project 3
    ...
    Get owner for project 100
    

    从功能上看,API 可能运行正常。

    但访问数据库的查询总数可能会远远超出预期。

    如果没有方式监控数据库活动,这类问题在流量尚未大到足以暴露问题之前会一直隐而不发。

    可观测性本身无法解决 N+1 模式的问题。

    它的作用是让人们首先注意到这种模式的存在。

    警报需要促使实际行动

    警报机制也是值得重新思考的领域。

    一旦每件小事都会触发警报,人们自然就会开始忽略它们。

    一个有价值的警报应该能让您回答以下问题:

    我现在需要处理这件事吗?

    类似这样的问题:

    Error rate has increased significantly
    

    或许值得标记出来。

    但是:

    One request took 1.2 seconds
    

    几乎可以肯定不值得为此打扰他人。

    具体的阈值会因系统而异。

    核心理念始终不变:

    警报应促使人们采取行动,而非仅仅制造噪音。

    可重复的流程能极大减轻生产环境中的调试压力

    当出现问题时,随机检查很少能快速找到原因。按照固定的步骤来操作会更有帮助。

    1. 确认问题确实存在

    首先确认确实发生了故障。

    Errors increased?
    Latency increased?
    Specific endpoint affected?
    

    2. 查明问题的影响范围

    确定问题是否仅限于:

    one user?
    one endpoint?
    all users?
    one API instance?
    database?
    external service?
    

    3. 查看近期有哪些变更

    检查是否出现了:

    deployment?
    configuration change?
    database migration?
    dependency update?
    

    4. 深入查看日志

    搜索内容:

    errors
    timeouts
    failed dependencies
    database failures
    unexpected status codes
    

    5. 审查指标数据

    查看内容:

    latency
    error rate
    traffic
    CPU
    memory
    database metrics
    

    6. 在可能的情况下追踪请求

    准确找出时间消耗在何处或故障起源于哪里。

    7. 解决根本原因

    不要只急于掩盖表面症状。

    这种结构化的处理方式远比重启服务器并寄希望于问题自行消失更为可靠。

    重启可能掩盖真正的问题

    有必要强调这一点,因为这种诱惑确实存在。

    想象一个运行速度极慢的应用程序。你重启它后,一切似乎又恢复正常了。看起来问题已经解决。

    但实际上原因可能是:

    memory leak
    

    或者资源耗尽:

    database connections
    

    又或者是某个高资源消耗的程序:

    background job
    

    重启只是消除了表面症状——并不一定解决了根本问题。

    完善的可观测性才能帮助你回溯并弄清楚为何重启最初看似有效。

    日志记录与可观测性中的常见误区

    以下是应当主动避免的错误。

    1. 将任意字符串写入日志

    这只会产生无用信息,毫无实际价值。

    2. 将敏感数据记录到日志中

    密码、令牌、机密信息以及个人数据都需要谨慎处理,绝不能随意对待。

    3. 忽略请求关联

    如果没有请求ID,追踪单个请求在多个环节中的流转就会变得极为困难。

    4>仅关注系统运行时间

    从技术上讲,API即便处于“运行”状态,也可能慢得令人难以忍受或存在部分功能故障。

    5. 过度依赖平均延迟值

    基于百分位数的分析能够揭示简单平均值所掩盖的问题。

    6. 忽视数据库性能

    即便应用层响应迅速,若底层数据库运行迟缓,整体性能仍会受到影响。

    7>设置过多的警报

    持续不断的警报声会让人感到疲惫,进而忽视这些警告。

    8>延迟添加可观测性功能

    等到问题发生后再才添加监控机制,只会让后续的故障排查更加困难。

    9>向客户端泄露内部错误细节

    详细的诊断信息应保存在服务器端的可控系统中,而非出现在面向客户的响应中。

    10>盲目添加工具而缺乏明确目的

    充斥着各种指标的控制面板并不一定具备实际效用。

    评估可观测性的检查清单

    在认为某个 API 具备足够的可观测性之前,先思考以下问题。

    日志记录

    • 重要的应用事件是否被记录下来?
    • 日志格式是否规范?
    • 日志级别是否统一应用?
    • 是否有足够的上下文信息用于故障排查?
    • 是否包含请求 ID?
    • 敏感信息是否已被遮蔽或完全排除?

    错误处理

    • 意外错误是否被捕获?
    • 客户端是否只能看到安全的通用错误信息?
    • 内部是否仍保留详细的诊断信息?
    • 错误能否与特定的请求关联起来?

    指标监控

    • 您是否了解自身的请求量?
    • 您是否知道错误率是多少?
    • 您是否了解响应延迟情况?
    • 您能否发现异常的流量模式?
  • 能否监控那些重要的依赖关系?
  • 数据库

    • 能否识别出执行缓慢的查询?
    • 能否发现与连接相关的问题?
    • 能否调查那些大量访问数据库的接口?
    • 能否察觉到数据库故障?

    追踪

    • 能否跟踪系统中那些重要的请求流程?
    • 能否发现执行缓慢的操作?
    • 能否查看对外部服务的调用情况?
    • 能否关联不同服务之间的活动?

    警报机制

    • 重要的故障是否真的会触发警报?
    • 阈值设置是否有实际意义?
    • 警报是否具备可操作性?
    • 能否控制警报的冗余数量?

    所有这些经验带来的核心启示

    观念上的最大转变在于认识到这一点:一个能够正常运行的应用程序并不等同于可以让人放心使用的应用程序。

    在本地开发时,只需打开终端即可实时查看运行状况。但生产环境并非如此。

    你需要应对多个用户、多台服务器、多种服务、后台任务、数据库、外部API,以及那些在自己机器上根本不会显现的故障。

    在如此大规模的环境中,可观测性变得至关重要。你必须能够回答诸如发生了什么、何时发生的、哪些人受到影响、是哪个请求触发了问题、系统的哪部分出现了故障、耗时多久、这是孤立事件还是重复出现的模式,以及在问题发生前是否有任何变化等问题。

    这正是日志记录和可观测性如此重要的原因。它们并非单纯的监控附加功能,而是系统自我解释能力的重要组成部分。

    接下来的发展方向

    你与后端系统相处的时间越长,就越能清楚地意识到,生产就绪实际上是一系列相互关联的决策构成的。身份认证用于保护系统的访问权限,授权则规定用户可以执行的操作,速率限制有助于控制流量,数据库设计则能保障数据的完整性,而日志记录和可观测性则能让您了解系统在任意时刻的实际运行状况。

    接下来的自然步骤是更深入地了解该技术链中的部署相关内容——容器、环境配置、CI/CD流水线、健康检查、优雅关闭机制、部署策略,以及当Node.js应用程序从本地机器部署到生产环境时所有会发生变化的部分。这类知识最好通过实际操作来学习,而非仅靠理论。

    结语

    人们很容易认为调试主要是找出那行有问题的代码。但实际上,往往更需要足够的上下文来理解围绕该行的整个系统。

    编写优质代码依然很重要。但当问题最终出现时——而问题终究会出现——能够了解究竟发生了什么同样至关重要。

    构建系统,保障其安全,使其可被监控。之后再致力于提升其操作便捷性。