日誌與請求追蹤:打造可觀測的後端
當系統出問題時,日誌是你最好的朋友。好的日誌設計能讓除錯從數小時縮短到幾分鐘。本篇將介紹如何打造可觀測(Observable)的後端系統。
一、 為什麼需要日誌?
1.1 日誌的價值
| 場景 | 沒有日誌 | 有日誌 |
|---|---|---|
| 用戶報錯 | 「不知道發生什麼」 | 「找到錯誤位置了」 |
| 效能問題 | 「哪裡慢?」 | 「這個 API 平均 2 秒」 |
| 安全事件 | 「被入侵了?」 | 「攻擊者 IP 和行為記錄」 |
| 除錯 | 猜測 + 嘗試 | 直接定位問題 |
1.2 可觀測性三支柱
二、 Morgan:HTTP 請求日誌
2.1 基本使用
javascript
const express = require("express");
const morgan = require("morgan");
const app = express();
// 預設格式
app.use(morgan("combined"));
// 輸出:
// 127.0.0.1 - - [13/Jan/2025:12:00:00 +0000] "GET /users HTTP/1.1" 200 1234 "-" "Mozilla/5.0..."2.2 預設格式
| 格式 | 說明 |
|---|---|
combined | Apache 標準格式(生產推薦) |
common | Common Log Format |
dev | 彩色簡潔格式(開發用) |
short | 短格式 |
tiny | 最簡格式 |
javascript
// 開發環境
if (process.env.NODE_ENV === "development") {
app.use(morgan("dev"));
// 輸出:GET /users 200 2.341 ms - 1234
}
// 生產環境
if (process.env.NODE_ENV === "production") {
app.use(morgan("combined"));
}2.3 自定義格式
javascript
// 使用 tokens
morgan.format(
"custom",
":method :url :status :response-time ms - :res[content-length]"
);
app.use(morgan("custom"));
// 自定義 token
morgan.token("request-id", (req) => req.id);
morgan.token("user-id", (req) => req.user?.id || "anonymous");
morgan.format(
"detailed",
":request-id :user-id :method :url :status :response-time ms"
);2.4 輸出到檔案
javascript
const fs = require("fs");
const path = require("path");
// 建立寫入流
const accessLogStream = fs.createWriteStream(
path.join(__dirname, "access.log"),
{ flags: "a" }
);
// 輸出到檔案
app.use(morgan("combined", { stream: accessLogStream }));
// 同時輸出到控制台和檔案
app.use(morgan("dev"));
app.use(morgan("combined", { stream: accessLogStream }));2.5 條件日誌
javascript
// 只記錄錯誤
app.use(
morgan("combined", {
skip: (req, res) => res.statusCode < 400,
})
);
// 跳過健康檢查
app.use(
morgan("combined", {
skip: (req) => req.url === "/health",
})
);三、 Request ID
3.1 為什麼需要 Request ID?
當多個用戶同時發請求,日誌會交錯:
[log] Processing user...
[log] Database query...
[log] Processing user... # 這是哪個請求?
[log] Error: Not found # 這又是哪個?加上 Request ID:
[abc123] Processing user...
[abc123] Database query...
[def456] Processing user... # 不同請求!
[abc123] Error: Not found # 是 abc123 這個請求3.2 實作
javascript
const { v4: uuidv4 } = require("uuid");
// 中介軟體
function requestId(req, res, next) {
// 使用客戶端提供的,或生成新的
req.id = req.headers["x-request-id"] || uuidv4();
res.setHeader("X-Request-Id", req.id);
next();
}
app.use(requestId);
// 之後的日誌都可以使用 req.id
app.use((req, res, next) => {
console.log(`[${req.id}] ${req.method} ${req.url}`);
next();
});3.3 傳遞到其他服務
javascript
const axios = require("axios");
async function callOtherService(req, data) {
return axios.post("http://other-service/api", data, {
headers: {
"X-Request-Id": req.id, // 傳遞 Request ID
},
});
}四、 結構化日誌
4.1 為什麼需要結構化?
javascript
// ❌ 非結構化(難以搜尋和分析)
console.log("User john logged in from 192.168.1.1");
// ✅ 結構化(JSON 格式)
console.log(
JSON.stringify({
event: "user_login",
user: "john",
ip: "192.168.1.1",
timestamp: new Date().toISOString(),
})
);4.2 使用 Winston
javascript
const winston = require("winston");
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || "info",
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
defaultMeta: { service: "user-service" },
transports: [
new winston.transports.File({ filename: "error.log", level: "error" }),
new winston.transports.File({ filename: "combined.log" }),
],
});
// 開發環境加上控制台輸出
if (process.env.NODE_ENV !== "production") {
logger.add(
new winston.transports.Console({
format: winston.format.simple(),
})
);
}
// 使用
logger.info("User logged in", { userId: 123, ip: "192.168.1.1" });
logger.error("Database connection failed", { error: err.message });
// 輸出:
// {"level":"info","message":"User logged in","userId":123,"ip":"192.168.1.1","service":"user-service","timestamp":"2025-01-13T12:00:00.000Z"}4.3 使用 Pino
Pino 是效能更好的選擇:
javascript
const pino = require("pino");
const logger = pino({
level: process.env.LOG_LEVEL || "info",
transport: {
target: "pino-pretty",
options: { colorize: true },
},
});
// Express 整合
const pinoHttp = require("pino-http");
app.use(pinoHttp({ logger }));
// 使用
logger.info({ userId: 123 }, "User logged in");五、 日誌等級
5.1 標準等級
| 等級 | 用途 | 範例 |
|---|---|---|
error | 錯誤 | 資料庫連線失敗 |
warn | 警告 | 快取未命中 |
info | 資訊 | 用戶登入 |
http | HTTP | 請求記錄 |
debug | 除錯 | 變數內容 |
silly | 詳細 | 每行執行 |
5.2 使用建議
javascript
// 錯誤:必須記錄,需要處理
logger.error("Payment processing failed", { orderId, error: err.message });
// 警告:值得注意,但系統仍可運作
logger.warn("Rate limit approaching", { userId, remaining: 10 });
// 資訊:重要業務事件
logger.info("Order placed", { orderId, amount });
// 除錯:開發時有用的資訊
logger.debug("Cache lookup", { key, hit: true });5.3 動態切換等級
javascript
// 可以透過環境變數控制
// LOG_LEVEL=debug npm start
// 或透過 API 動態切換
app.post("/admin/log-level", (req, res) => {
logger.level = req.body.level;
res.json({ level: logger.level });
});六、 請求/回應日誌
6.1 完整記錄
javascript
function requestLogger(req, res, next) {
const start = Date.now();
// 記錄請求 Body(小心敏感資訊)
const requestBody = sanitizeBody(req.body);
// 原始的 res.json
const originalJson = res.json.bind(res);
res.json = (body) => {
const duration = Date.now() - start;
logger.info("HTTP Request", {
requestId: req.id,
method: req.method,
url: req.url,
query: req.query,
body: requestBody,
status: res.statusCode,
duration,
responseBody: sanitizeBody(body),
});
return originalJson(body);
};
next();
}
function sanitizeBody(body) {
if (!body) return null;
const sanitized = { ...body };
const sensitiveFields = ["password", "token", "creditCard", "ssn"];
sensitiveFields.forEach((field) => {
if (sanitized[field]) {
sanitized[field] = "[REDACTED]";
}
});
return sanitized;
}6.2 效能日誌
javascript
function performanceLogger(req, res, next) {
const start = process.hrtime();
res.on("finish", () => {
const [seconds, nanoseconds] = process.hrtime(start);
const duration = seconds * 1000 + nanoseconds / 1000000;
// 慢請求警告
if (duration > 1000) {
logger.warn("Slow request", {
requestId: req.id,
url: req.url,
duration: `${duration.toFixed(2)}ms`,
});
}
});
next();
}七、 錯誤日誌
7.1 完整錯誤資訊
javascript
function errorLogger(err, req, res, next) {
const errorInfo = {
requestId: req.id,
error: {
name: err.name,
message: err.message,
stack: err.stack,
code: err.code,
},
request: {
method: req.method,
url: req.url,
headers: sanitizeHeaders(req.headers),
body: sanitizeBody(req.body),
query: req.query,
params: req.params,
ip: req.ip,
user: req.user?.id,
},
};
logger.error("Unhandled error", errorInfo);
next(err);
}
function sanitizeHeaders(headers) {
const sanitized = { ...headers };
if (sanitized.authorization) {
sanitized.authorization = "[REDACTED]";
}
if (sanitized.cookie) {
sanitized.cookie = "[REDACTED]";
}
return sanitized;
}7.2 未捕獲的錯誤
javascript
process.on("uncaughtException", (err) => {
logger.fatal("Uncaught Exception", {
error: err.message,
stack: err.stack,
});
process.exit(1);
});
process.on("unhandledRejection", (reason) => {
logger.error("Unhandled Rejection", {
reason: reason instanceof Error ? reason.message : reason,
});
});八、 日誌聚合
8.1 輸出到外部服務
javascript
const { Logtail } = require("@logtail/node");
const logtail = new Logtail(process.env.LOGTAIL_TOKEN);
// Winston 整合
const logtailTransport = new winston.transports.Stream({
stream: logtail,
});
logger.add(logtailTransport);8.2 常見服務
| 服務 | 說明 |
|---|---|
| ELK Stack | Elasticsearch + Logstash + Kibana |
| Datadog | 全方位監控 |
| Logtail | 簡單日誌管理 |
| CloudWatch | AWS 內建 |
| Stackdriver | GCP 內建 |
九、 最佳實踐
9.1 該記什麼
javascript
// ✅ 記錄
logger.info("User created", { userId: 123 });
logger.info("Payment processed", { orderId: 456, amount: 99.99 });
logger.error("Database connection lost", { retryIn: 5000 });
// ✅ 記錄(但遮蔽敏感資訊)
logger.info("Login attempt", { email: "j***@example.com" });
// ❌ 不要記錄
logger.info("Password", { password: "secret123" });
logger.info("Credit card", { number: "4111111111111111" });9.2 日誌格式
javascript
// ✅ 結構化、可搜尋
{
"timestamp": "2025-01-13T12:00:00Z",
"level": "info",
"requestId": "abc-123",
"event": "user_login",
"userId": 123,
"ip": "192.168.1.1"
}
// ❌ 非結構化、難以分析
"[2025-01-13] INFO: User 123 logged in from 192.168.1.1"總結
| 概念 | 工具 | 用途 |
|---|---|---|
| HTTP 日誌 | Morgan | 請求記錄 |
| Request ID | uuid | 請求追蹤 |
| 結構化日誌 | Winston/Pino | 可搜尋日誌 |
| 日誌等級 | error/warn/info/debug | 分類過濾 |
| 日誌聚合 | ELK/Datadog | 集中管理 |
> **黃金法則**:
- 生產環境用 JSON 格式
- 一定要有 Request ID
- 敏感資訊必須遮蔽
- 慢請求要特別記錄
進階挑戰
- 建立一個日誌搜尋 API,可以根據 Request ID 找到所有相關日誌。
- 實作分散式追蹤:在微服務之間傳遞 Request ID。
- 設定日誌告警:當錯誤率超過閾值時發送通知。