本文 AI 產出,尚未審核

ExpressJS (TypeScript) – Logging 與 Debug

主題:API Request Logging


簡介

Web API 的開發與維運過程中,請求日誌 是不可或缺的資訊來源。它不僅能協助我們快速定位錯誤、分析效能瓶頸,還能在安全稽核或法規遵循時提供完整的行為記錄。對於使用 ExpressJS 搭配 TypeScript 的開發者而言,建立一套結構化、可擴充且易於維護的請求日誌機制,能大幅提升開發效率與系統可觀測性。

本篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,逐步帶領讀者打造一套完整的 API Request Logging 解決方案,適合剛踏入 Express 的新手,也能為已有專案的中級開發者提供即時可用的參考。


核心概念

1️⃣ 為什麼要在 Middleware 層面記錄請求?

Express 的 middleware 是請求與回應流過的每一道關卡,將日誌寫在這裡有以下好處:

  • 統一入口:所有路由都會經過同一段程式碼,避免遺漏。
  • 可取得完整上下文:包括 HTTP 方法、URL、Headers、Query、Body 以及回應狀態碼。
  • 易於擴充:未來想加入 Correlation IDPerformance Timing 等資訊,只需在此層面加碼即可。

2️⃣ 日誌等級與結構化日誌

  • 等級 (Level)errorwarninfodebugverbose。在開發環境多使用 debug,上線則以 info 為主。
  • 結構化 (JSON):將日誌以 JSON 格式輸出,方便日後透過 ELK、Grafana Loki 等工具進行搜尋與分析。

3️⃣ 常見的日誌套件

套件 特色 典型用法
morgan 輕量、即插即用的 HTTP 請求日誌 快速建立開發用的請求日誌
winston 多傳輸 (Transport) 支援、格式化彈性大 建立統一的應用程式日誌
pino 超高效能、內建 JSON 輸出 高流量服務的首選
cls-hooked / async‑hooks 跨 async 呼叫維持上下文 (如 requestId) 實作分散式追蹤

以下將以 TypeScript 為例,示範如何結合上述套件,打造完整的 API Request Logging。


程式碼範例

範例 1️⃣:使用 morgan 建立簡易請求日誌(開發環境)

// src/middleware/devLogger.ts
import morgan, { StreamOptions } from 'morgan';
import { Request, Response, NextFunction } from 'express';

// 定義自訂的輸出格式,包含 method、url、status、response-time
const devFormat = ':method :url :status - :response-time ms';

// 直接寫入 console
const stream: StreamOptions = {
  write: (message) => process.stdout.write(message),
};

export const devLogger = morgan(devFormat, { stream });

說明

  • morgan 只需要一行設定即可在 console 中即時看到每筆請求。
  • app.tsapp.use(devLogger); 即可套用。

範例 2️⃣:使用 winston 建立統一的日誌服務

// src/logger.ts
import { createLogger, format, transports, Logger } from 'winston';
import * as path from 'path';

// 依環境決定日誌等級
const env = process.env.NODE_ENV || 'development';
const level = env === 'production' ? 'info' : 'debug';

export const logger: Logger = createLogger({
  level,
  format: format.combine(
    format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }),
    format.errors({ stack: true }),
    format.splat(),
    format.json() // 輸出為 JSON,方便日後分析
  ),
  transports: [
    // 輸出到檔案,依天分割
    new transports.File({
      filename: path.join(__dirname, '../logs', 'error.log'),
      level: 'error',
    }),
    new transports.File({
      filename: path.join(__dirname, '../logs', 'combined.log'),
    }),
  ],
});

// 在非 production 時,同時印到 console,使用簡易文字格式
if (env !== 'production') {
  logger.add(
    new transports.Console({
      format: format.combine(format.colorize(), format.simple()),
    })
  );
}

說明

  • logger 會根據 NODE_ENV 自動切換等級與輸出方式。
  • 兩個檔案 error.logcombined.log 讓錯誤與一般資訊分離,符合 log‑rotation 的最佳實踐。

範例 3️⃣:自訂 Middleware,將請求資訊寫入 winston

// src/middleware/requestLogger.ts
import { Request, Response, NextFunction } from 'express';
import { logger } from '../logger';
import onHeaders from 'on-headers';

// 產生唯一的 Request ID(簡易版)
import { v4 as uuidv4 } from 'uuid';

export const requestLogger = (
  req: Request,
  _res: Response,
  next: NextFunction
) => {
  const requestId = uuidv4();
  const startTime = process.hrtime();

  // 把 requestId 加到 response header,方便前端追蹤
  req.headers['x-request-id'] = requestId;
  _res.setHeader('X-Request-Id', requestId);

  // 在 response header 完成前,先記錄回傳時間
  onHeaders(_res, () => {
    const diff = process.hrtime(startTime);
    const responseTime = diff[0] * 1e3 + diff[1] / 1e6; // ms

    logger.info(
      '[%s] %s %s %s %d - %d ms',
      requestId,
      req.method,
      req.originalUrl,
      JSON.stringify(req.body),
      _res.statusCode,
      responseTime.toFixed(3)
    );
  });

  next();
};

說明

  • 使用 on-headers 讓我們在 response headers 被寫出前取得實際的 statusCoderesponse time
  • Request ID 透過 UUID 產生,並寫入 X-Request-Id Header,方便分散式追蹤。

範例 4️⃣:結合 async‑hooks(或 cls-hooked)保持跨層級的 Request ID

// src/context/requestContext.ts
import { createNamespace, getNamespace } from 'cls-hooked';

export const REQUEST_NS = 'request-context';
export const requestNamespace = createNamespace(REQUEST_NS);

/**
 * 取得當前請求的唯一 ID,若不存在則回傳 undefined
 */
export const getRequestId = (): string | undefined => {
  const ns = getNamespace(REQUEST_NS);
  return ns?.get('requestId');
};
// src/middleware/contextMiddleware.ts
import { Request, Response, NextFunction } from 'express';
import { requestNamespace } from '../context/requestContext';
import { v4 as uuidv4 } from 'uuid';

export const contextMiddleware = (
  req: Request,
  _res: Response,
  next: NextFunction
) => {
  requestNamespace.run(() => {
    const requestId = uuidv4();
    requestNamespace.set('requestId', requestId);
    // 同步寫入 header,供外部使用
    _res.setHeader('X-Request-Id', requestId);
    next();
  });
};

說明

  • cls-hooked 讓我們在 非同步呼叫鏈 中仍能取得同一筆請求的 requestId,不需要把它傳遞到每個函式。
  • 在任何地方呼叫 getRequestId(),即可拿到當前請求的 ID,方便 log correlation

範例 5️⃣:將敏感資訊過濾後寫入日誌

// src/middleware/safeLogger.ts
import { Request, Response, NextFunction } from 'express';
import { logger } from '../logger';
import { getRequestId } from '../context/requestContext';

// 需要過濾的欄位清單(可依需求擴充)
const SENSITIVE_FIELDS = ['password', 'creditCard', 'authorization'];

const filterBody = (body: any) => {
  if (!body || typeof body !== 'object') return body;
  const filtered = { ...body };
  for (const key of SENSITIVE_FIELDS) {
    if (key in filtered) filtered[key] = '[FILTERED]';
  }
  return filtered;
};

export const safeLogger = (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  const start = Date.now();
  const requestId = getRequestId() ?? '-';

  res.on('finish', () => {
    const duration = Date.now() - start;
    logger.info({
      requestId,
      method: req.method,
      url: req.originalUrl,
      status: res.statusCode,
      responseTime: `${duration}ms`,
      // 只保留非敏感的 body
      requestBody: filterBody(req.body),
    });
  });

  next();
};

說明

  • 在日誌中 過濾敏感欄位,避免意外洩漏。
  • 透過 res.on('finish') 確保回應已完整送出,才記錄最終的 status 與耗時。

常見陷阱與最佳實踐

陷阱 可能的影響 最佳實踐
過度記錄:把整個 req.bodyreq.headers 全部寫入 日誌檔案爆炸、敏感資訊外洩 過濾敏感欄位,僅記錄必要資訊
同步寫檔:使用 fs.writeFileSync 直接寫檔 阻塞 event‑loop,導致請求延遲 使用 winstonpino 等非同步、批次寫入的套件
忘記在 Production 關閉 debug 大量噪音、磁碟空間耗盡 NODE_ENV 設定 log level,上線僅保留 infoerror
未設定 Log Rotation 單一檔案持續增長,導致磁碟滿 使用 winston-daily-rotate-file 或系統 logrotate
直接在 Controller 裡 log 重複程式碼、難以維護 日誌邏輯抽離到 middleware / service 層
未加上 Request ID 分散式系統中難以追蹤單筆請求 透過 cls-hookedasync‑hooks 或第三方 tracing 套件 (如 OpenTelemetry) 產生全域 ID

其他建議

  1. 環境變數:使用 .env 管理 LOG_LEVELLOG_DIR,避免硬編碼。
  2. 結構化日誌:盡量以 JSON 輸出,配合 ELKGrafana Loki 進行即時查詢。
  3. 統一格式:在所有服務中使用相同的欄位名稱(如 timestamp, level, requestId, message),方便跨服務聚合。
  4. 測試覆蓋:在單元測試中 mock logger,驗證日誌內容是否符合預期,防止因程式碼變更遺漏關鍵資訊。

實際應用場景

場景 為何需要 Request Logging 具體做法
微服務 跨服務調用時需要追蹤單筆請求的完整流程 在每個服務的入口使用 contextMiddleware + requestLogger,將 X-Request-Id 透過 HTTP Header 傳遞下去
API 金流 必須保留交易紀錄以符合 PCI‑DSS 規範 設定 只寫入交易成功/失敗的狀態碼與時間,過濾掉卡號、金額等敏感欄位,並將日誌寫入加密的檔案或安全的 log 服務
性能瓶頸分析 需要知道每條 API 的平均回應時間 requestLogger 中加入 responseTime,配合 Grafana Dashboard 顯示 95th percentile
安全事件偵測 想要快速定位不正常的請求模式(如暴力破解) 設定 winston 只在 warn/error 等級時發送至 SlackPagerDuty,並在日誌中保留 IP、User‑Agent 等資訊
法規稽核 需要保存一定天數的 API 呼叫紀錄 使用 winston-daily-rotate-file 設定保留 30 天,並在備份腳本中將舊檔上傳至 S3/Blob 儲存

總結

  • API Request Logging 是提升系統可觀測性與維運效率的基礎。
  • 透過 middleware 結合 winston(或 pino)與 cls‑hooked,我們可以在 TypeScript 專案中建立結構化、可追蹤、可過濾的日誌。
  • 記得 過濾敏感資訊、設定適當的 log level、使用 log rotation,以免產生安全與效能問題。
  • 在微服務、金流、性能監控與安全稽核等實務場景中,適當的請求日誌不僅能協助開發人員快速定位問題,更能在法規遵循與商業決策上發揮關鍵價值。

把日誌寫好,就等於把未來的除錯成本減少了 80%。現在就把本文的範例套用到你的 Express + TypeScript 專案中,讓系統在每一次請求的背後,都留下清晰、可追蹤的足跡吧!