本文 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 ID、Performance Timing 等資訊,只需在此層面加碼即可。
2️⃣ 日誌等級與結構化日誌
- 等級 (Level):
error、warn、info、debug、verbose。在開發環境多使用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.ts中app.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.log、combined.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 被寫出前取得實際的 statusCode 與 response time。- Request ID 透過 UUID 產生,並寫入
X-Request-IdHeader,方便分散式追蹤。
範例 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.body、req.headers 全部寫入 |
日誌檔案爆炸、敏感資訊外洩 | 過濾敏感欄位,僅記錄必要資訊 |
同步寫檔:使用 fs.writeFileSync 直接寫檔 |
阻塞 event‑loop,導致請求延遲 | 使用 winston、pino 等非同步、批次寫入的套件 |
忘記在 Production 關閉 debug |
大量噪音、磁碟空間耗盡 | 依 NODE_ENV 設定 log level,上線僅保留 info、error |
| 未設定 Log Rotation | 單一檔案持續增長,導致磁碟滿 | 使用 winston-daily-rotate-file 或系統 logrotate |
| 直接在 Controller 裡 log | 重複程式碼、難以維護 | 把日誌邏輯抽離到 middleware / service 層 |
| 未加上 Request ID | 分散式系統中難以追蹤單筆請求 | 透過 cls-hooked、async‑hooks 或第三方 tracing 套件 (如 OpenTelemetry) 產生全域 ID |
其他建議
- 環境變數:使用
.env管理LOG_LEVEL、LOG_DIR,避免硬編碼。 - 結構化日誌:盡量以 JSON 輸出,配合 ELK、Grafana Loki 進行即時查詢。
- 統一格式:在所有服務中使用相同的欄位名稱(如
timestamp,level,requestId,message),方便跨服務聚合。 - 測試覆蓋:在單元測試中 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 等級時發送至 Slack 或 PagerDuty,並在日誌中保留 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 專案中,讓系統在每一次請求的背後,都留下清晰、可追蹤的足跡吧!