跳至主要內容
Skip to content

日誌與請求追蹤:打造可觀測的後端

當系統出問題時,日誌是你最好的朋友。好的日誌設計能讓除錯從數小時縮短到幾分鐘。本篇將介紹如何打造可觀測(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 預設格式

格式說明
combinedApache 標準格式(生產推薦)
commonCommon 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資訊用戶登入
httpHTTP請求記錄
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 StackElasticsearch + Logstash + Kibana
Datadog全方位監控
Logtail簡單日誌管理
CloudWatchAWS 內建
StackdriverGCP 內建

九、 最佳實踐

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 IDuuid請求追蹤
結構化日誌Winston/Pino可搜尋日誌
日誌等級error/warn/info/debug分類過濾
日誌聚合ELK/Datadog集中管理

> **黃金法則**:

  • 生產環境用 JSON 格式
  • 一定要有 Request ID
  • 敏感資訊必須遮蔽
  • 慢請求要特別記錄

進階挑戰

  1. 建立一個日誌搜尋 API,可以根據 Request ID 找到所有相關日誌。
  2. 實作分散式追蹤:在微服務之間傳遞 Request ID。
  3. 設定日誌告警:當錯誤率超過閾值時發送通知。

延伸閱讀與資源