Monitoring a SaaS in Production 這篇文章中,我們已設定好指標與告警,讓團隊能在問題發生後數分鐘內得知情況。指標能告訴你「有異常尖峰」,但很少能說明「原因」。這正是日誌要解決的問題,也是我們在此要處理的事。

本文是 Full Stack SaaS Masterclass 系列的一部分,這個系列從零開始實作多租戶 SaaS 產品。模組 4 討論的營運議題,正是把「示範」變成「可實際營運的產品」所需。日誌是這套架構的基礎:事件回應、客服工單、稽核軌跡,最終都會回到「日誌裡寫了什麼」。

大多數 Node.js 應用程式一開始都使用 console.log,而且使用的時間比應有的長,因為它在筆電上一次只處理一個請求時運作良好。但當同時有兩個請求在執行,它們的日誌行會在共用終端機中交錯,或是客服需要查詢工單,卻只能用 grep 搜尋一大堆非結構化文字時,問題就出現了。結構化日誌能解決這個問題,而且成本低到值得盡早導入。

為什麼純文字日誌不再適用

console.log 產生的是字串。字串適合人類在終端機上閱讀,但對任何試圖大規模查詢日誌的系統來說卻是敵人。一旦日誌傳送到 CloudWatch、Datadog 或其他聚合器,下游工具就必須用 regex 把字串拆解,才能取出真正需要的欄位:request ID、user ID、status code、duration。基於 regex 的解析很容易出錯,只要改動日誌訊息中的一個字,就會讓所有依賴它的搜尋與告警失效。

結構化日誌反轉了這個做法:不是寫完句子再讓別人解析,而是從一開始就寫成帶有名稱欄位的 JSON 物件。

{
  "level": "error",
  "time": "2023-09-14T10:22:31.482Z",
  "requestId": "8f14e45f-ceea-467e-add1-a889b9f6bfaa",
  "organizationId": "org_9f2c1a",
  "userId": "usr_71b30d",
  "route": "POST /invoices",
  "statusCode": 500,
  "durationMs": 812,
  "msg": "Failed to create invoice: unique constraint violation"
}

Enter fullscreen mode Exit fullscreen mode

這裡的每個欄位都可以直接查詢,不需要解析。「顯示過去一小時內 org_9f2c1a 組織的所有 500 錯誤」只需要過濾器,而非 regex。這個差異就是全部的價值所在,而且隨著系統規模擴大,這個價值也會累積:第十位工程師在除錯事件時,能受益於第一位工程師所設定的結構化欄位。

選擇日誌函式庫並整合到 NestJS

Node.js 有幾個可靠的結構化日誌函式庫。pino 是 NestJS 後端的不錯預設選擇。它以降低每次呼叫的開銷為核心(使用快速路徑的 JSON 序列化,而非大多數日誌函式庫使用的通用 JSON.stringify),並透過 nestjs-pino 與 NestJS 無縫整合。

// main.ts
import { NestFactory } from '@nestjs/core';
import { Logger } from 'nestjs-pino';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule, { bufferLogs: true });
  app.useLogger(app.get(Logger));
  await app.listen(3000);
}
bootstrap();

Enter fullscreen mode Exit fullscreen mode

// logger.module.ts
import { Module } from '@nestjs/common';
import { LoggerModule } from 'nestjs-pino';

@Module({
  imports: [
    LoggerModule.forRoot({
      pinoHttp: {
        level: process.env.LOG_LEVEL ?? 'info',
        redact: ['req.headers.authorization', 'req.headers.cookie', '*.password', '*.token'],
        serializers: {
          req: (req) => ({ method: req.method, url: req.url }),
          res: (res) => ({ statusCode: res.statusCode }),
        },
        transport:
          process.env.NODE_ENV === 'development'
            ? { target: 'pino-pretty', options: { singleLine: true } }
            : undefined,
      },
    }),
  ],
})
export class AppLoggerModule {}

Enter fullscreen mode Exit fullscreen mode

這個設定中有兩件事值得特別注意。首先,redact 不是事後才想到的功能;它正是區分「日誌行」與「洩漏憑證」的關鍵,應該在初始設定時就加入,而不是等到安全審查發現問題後才補救。其次,pino-pretty 傳輸僅限於開發環境使用。在生產環境中,你希望原始 JSON 直接輸出到 stdout,因為這是日誌聚合器所期望的格式,而先進行美化處理只會增加無人閱讀的 CPU 負擔。

設定完成後,要在服務中注入日誌器非常直接:

import { Injectable } from '@nestjs/common';
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';

@Injectable()
export class InvoiceService {
  constructor(@InjectPinoLogger(InvoiceService.name) private readonly logger: PinoLogger) {}

  async createInvoice(organizationId: string, amount: number): Promise<void> {
    this.logger.info({ organizationId, amount }, 'Creating invoice');

    try {
      // ... invoice creation logic
    } catch (error) {
      this.logger.error({ organizationId, err: error }, 'Invoice creation failed');
      throw error;
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

這裡的 err 欄位很重要:pino 的預設錯誤序列化器會將 Error 物件展開成獨立的訊息與堆疊追蹤 JSON 欄位,而非像 console.log(error) 那樣進行字串插值。這使得你可以在所有服務中搜尋特定例外類型,而不需要逐一打開日誌行。

將關聯 ID 貫穿每一行日誌

在事件調查時,單獨一行日誌很少有幫助。你真正需要的是屬於同一個請求的所有日誌行,涵蓋它所觸及的每個服務,而除非所有這些日誌行都共享一個共同 ID,否則僅靠結構化欄位無法達成。這就是關聯 ID(通常稱為 request ID 或 trace ID)的用途。

在 NestJS 應用程式中傳遞關聯 ID,而不需要在每個函式呼叫中都作為參數傳遞,最乾淨的方法是使用 Node 的 AsyncLocalStorage,它為每個請求提供獨立的隔離上下文,且能跨越 await 邊界持續存在。

// request-context.ts
import { AsyncLocalStorage } from 'async_hooks';
import { randomUUID } from 'crypto';

interface RequestContext {
  requestId: string;
  organizationId?: string;
}

export const requestContext = new AsyncLocalStorage<RequestContext>();

export function getRequestId(): string {
  return requestContext.getStore()?.requestId ?? 'no-context';
}

Enter fullscreen mode Exit fullscreen mode

// request-context.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { randomUUID } from 'crypto';
import { requestContext } from './request-context';

@Injectable()
export class RequestContextMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction): void {
    const requestId = (req.headers['x-request-id'] as string) ?? randomUUID();
    res.setHeader('x-request-id', requestId);

    requestContext.run({ requestId }, () => next());
  }
}

Enter fullscreen mode Exit fullscreen mode

任何在該請求呼叫堆疊中的日誌呼叫,包括由它觸發的佇列工作(如果 ID 被轉發到工作負載中),現在都可以呼叫 getRequestId() 並自動附加它。將它連接到 pino 設定的 mixin 選項,這樣工程師就不需要記得手動傳遞:

LoggerModule.forRoot({
  pinoHttp: {
    mixin() {
      return { requestId: getRequestId() };
    },
  },
});

Enter fullscreen mode Exit fullscreen mode

這是在結構化之後,對日誌設定來說單一最高效益的增強。如果沒有它,要重建「這個失敗結帳過程中發生的一切」就必須猜測時間戳,並希望沒有其他並行事件發生。有了它,只需要一個查詢即可。

權衡:結構化日誌的成本

這些都不是免費的,在視之為明顯優勢之前,值得誠實地檢視這些成本。

結構化日誌在本地開發時,直接在終端機閱讀的體驗較不舒適,這就是為什麼上面有 pino-pretty 傳輸,專門用來緩和本地工作的體驗,同時保持生產環境輸出為原始 JSON。序列化在每次日誌呼叫時都有真實(雖然很小)的 CPU 成本,如果熱路徑在每次迴圈迭代而非每次請求時都記錄日誌,這就會有影響。而遮蔽設定也需要維護。每個可能包含憑證、密碼、API 金鑰、會話權杖的新欄位,都需要加入遮蔽清單,而一旦初始設定完成,這個清單很容易被遺忘。

對於幾乎任何超出最早原型階段的 SaaS 後端來說,好處都大於這些成本,但值得明確指出這些成本,而不是假裝結構化日誌是沒有維護負擔的純粹升級。

值得規劃的生產環境陷阱

將 PII 記錄到非結構化欄位。 結構化無法防止有人把客戶的電子郵件地址放入自由格式的 msg 字串或沒人審查的 properties 物件中。遮蔽規則只會捕捉它們已知名稱的欄位;對任何新增到日誌呼叫的欄位,都應該用與審查 API 回應相同的嚴謹態度對待。

日誌量超出你願意支付的成本。 結構化 JSON 日誌比簡潔的文字訊息更大,而在生產環境中保持詳細的 debug 等級日誌會讓成本倍增。為每個環境設定 LOG_LEVEL,在生產環境預設為 info,並將 debug 保留給有意的、有限時的調查,而非永久設定。

優雅關閉時遺失日誌。 如果程序在傳輸完成刷新緩衝日誌行之前就退出,任何解釋當機原因的資訊正好就是聚合器中缺少的。請確保你的關閉鉤子會等待日誌器刷新後才讓程序退出,特別是當日誌在傳送到其他伺服器之前會先批次處理時。

關聯 ID 在 HTTP 邊界就停止。 只存在於 API 程序內的請求 ID,遠不如轉發到每個下游呼叫更有用:它所排入的 BullMQ 工作、對第三方 API 的出站 HTTP 呼叫、發布到另一個服務的訊息。在每個邊界都有意地轉發它,否則軌跡會在事件通常變得有趣的地方中斷。

重點摘要

  • 結構化日誌是帶有名稱欄位的 JSON 物件,而非句子,這使得聚合器可以直接查詢,而不需要使用脆弱的 regex 解析。
  • Pino 是 NestJS 後端的不錯預設選擇:快速的 JSON 序列化、透過 nestjs-pino 與 NestJS 的一流整合,以及內建的遮蔽與錯誤序列化。
  • 憑證與權杖的遮蔽規則應該在初始設定時就加入,而不是等到安全審查後才補救。
  • AsyncLocalStorage 讓你能自動將請求範圍的關聯 ID 附加到每一行日誌,這是在一次查詢中重建事件與猜測時間戳之間的差別。
  • 生產環境日誌等級預設為 info,並將 debug 保留給有意的、有限時的調查,因為詳細日誌一旦傳送到聚合器,就會產生真實的成本。
  • 在每個邊界(HTTP 呼叫、佇列工作、Webhook)轉發關聯 ID,否則軌跡會在事件難以除錯的地方中斷。

常見問題

什麼是結構化日誌?
結構化日誌是指將日誌項目寫成帶有名稱、型別欄位(例如 requestIdstatusCodeorganizationId)的物件,而不是自由格式的文字句子。它讓日誌聚合器與查詢工具可以直接對這些欄位進行過濾與搜尋,而不需要用 regex 解析訊息字串。

對於 Node.js 後端,pino 比 winston 更好嗎?
兩者都是有能力的結構化日誌函式庫。Pino 通常是 JSON 序列化較快的選擇,且透過 nestjs-pino 與 NestJS 直接整合,這也是為什麼它成為新 NestJS 專案的常見預設選擇。Winston 有較長的使用紀錄與更大的外掛生態系,如果你需要它已經支援的特定傳輸,這可能很重要。

如何在多個服務的請求中關聯日誌?
在系統邊緣產生或轉發關聯 ID(通常稱為 request ID 或 trace ID),將它儲存在 AsyncLocalStorage 上下文中以持續整個請求期間,並在每個下游呼叫(包括佇列工作與出站 HTTP 請求)中明確轉發。每個從該上下文中讀取的日誌行都可以自動附加相同的 ID。

什麼不應該出現在日誌行中?
密碼、會話權杖、API 金鑰、授權標頭與完整的支付卡詳細資料都不應該以明文形式記錄。在日誌器層級為已知的敏感欄位名稱設定遮蔽規則,並對任何新增到日誌呼叫的欄位,應用與審查 API 回應相同的嚴謹態度。

我應該在生產環境中以 debug 等級記錄日誌嗎?
一般來說不應該。生產環境日誌等級預設為 info,並將 debug 保留給有意的、有限時的調查,因為 debug 等級日誌會在每個請求中倍增量與成本,而大多數時候並沒有對應的好處。

結構化日誌是否取代了指標或追蹤系統的需求?
不會。指標能快速告訴你有問題,追蹤顯示請求如何在服務間移動,而日誌則解釋特定請求失敗的具體原因。它們是互補的,而這裡描述的關聯 ID 正是讓你在事件調查期間在這三者之間切換而不失去上下文的關鍵。

延伸閱讀


關於作者

嗨,我是 Aman Singh — 專精於可擴展 SaaS 產品、分散式系統、雲端架構與 AI 驅動應用程式的高級全端工程師。

我撰寫關於系統設計、全端工程、分散式系統、Redis、PostgreSQL、AWS、Node.js 與 NestJS 的文章。