在 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,否則軌跡會在事件難以除錯的地方中斷。
常見問題
什麼是結構化日誌?
結構化日誌是指將日誌項目寫成帶有名稱、型別欄位(例如 requestId、statusCode、organizationId)的物件,而不是自由格式的文字句子。它讓日誌聚合器與查詢工具可以直接對這些欄位進行過濾與搜尋,而不需要用 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 正是讓你在事件調查期間在這三者之間切換而不失去上下文的關鍵。
延伸閱讀
- 上一篇:Monitoring a SaaS in Production
- 下一篇:Audit Logs You Can Trust
- 系列起點:Choosing the Right Tech Stack for Your SaaS
關於作者
嗨,我是 Aman Singh — 專精於可擴展 SaaS 產品、分散式系統、雲端架構與 AI 驅動應用程式的高級全端工程師。
我撰寫關於系統設計、全端工程、分散式系統、Redis、PostgreSQL、AWS、Node.js 與 NestJS 的文章。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.