ExpressJS (TypeScript) – Logging 與 Debug
主題:自訂 Logger(TypeScript)
簡介
在 Node.js 與 Express 應用程式中,日誌(logging) 是排除錯誤、監控系統健康度以及追蹤業務流程的必備工具。即使在開發階段,清晰、結構化的日誌也能讓我們快速定位問題;在上線後,良好的日誌更是診斷服務異常、分析使用者行為、符合合規需求的關鍵。
然而,單純使用 console.log 雖然簡單,但缺乏層級管理、時間戳記、結構化輸出與可擴充性。本文將帶你 從零開始,在 Express + TypeScript 專案中建立一套可自訂、可擴充、且支援多層級的 Logger,讓日誌不再是「雜訊」,而是有價值的資訊資產。
核心概念
1. 為什麼要自訂 Logger?
| 缺點 | console.log |
自訂 Logger |
|---|---|---|
| 無層級(info / warn / error) | ❌ | ✅ |
| 無時間戳記或請求 ID | ❌ | ✅ |
| 難以切換輸出目的地(檔案 / 雲端) | ❌ | ✅ |
| 無格式化(JSON / 顏色) | ❌ | ✅ |
| 無測試友善(mock) | ❌ | ✅ |
結論:在大型或長期維護的專案中,自訂 Logger 能提供一致的日誌結構、方便的除錯資訊,並且易於與第三方監控平台(如 Datadog、Loggly)整合。
2. Logger 的基本構成
- Log Level:決定訊息的重要程度(
debug、info、warn、error)。 - Transport:訊息的輸出目的地,常見有 Console、File、HTTP。
- Formatter:決定訊息的呈現方式,常見有 文字、JSON、彩色。
- Context:額外的元資料,如 requestId、userId、stack trace。
3. 以 winston 為基礎打造 TypeScript Logger
winston 是 Node 生態系中最成熟的日誌套件,支援多 Transport、格式化與層級管理。以下示範如何在 TypeScript 中封裝 winston,並提供 類別化 的 API,讓整個 Express 專案只需要注入一次 logger 即可。
3.1 安裝相依套件
npm i winston winston-daily-rotate-file
npm i -D @types/winston
3.2 建立 src/logger/Logger.ts
// src/logger/Logger.ts
import { createLogger, format, transports, Logger as WinstonLogger } from 'winston';
import DailyRotateFile from 'winston-daily-rotate-file';
import { Request } from 'express';
import * as path from 'path';
// ---------- Log Level ----------
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
// ---------- 自訂欄位 ----------
export interface LogMeta {
requestId?: string;
userId?: string;
[key: string]: any;
}
// ---------- Logger 類別 ----------
export class Logger {
private logger: WinstonLogger;
constructor() {
// 1️⃣ 設定共用的 Formatter
const baseFormat = format.combine(
format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }),
format.errors({ stack: true }), // 捕捉 stack trace
format.splat(),
format.json() // 輸出 JSON,方便後續搜尋
);
// 2️⃣ Console Transport(開發環境)
const consoleTransport = new transports.Console({
level: 'debug',
format: format.combine(
format.colorize(),
format.printf(({ timestamp, level, message, ...meta }) => {
const metaString = Object.keys(meta).length ? JSON.stringify(meta) : '';
return `${timestamp} ${level}: ${message} ${metaString}`;
})
),
});
// 3️⃣ File Transport(每日輪轉)
const fileTransport = new DailyRotateFile({
level: 'info',
dirname: path.resolve(__dirname, '../../logs'),
filename: 'app-%DATE%.log',
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: '20m',
maxFiles: '14d',
format: baseFormat,
});
// 4️⃣ 建立 Winston 實例
this.logger = createLogger({
level: 'debug',
transports: [consoleTransport, fileTransport],
exitOnError: false,
});
}
// ---------- 公開方法 ----------
private log(level: LogLevel, message: string, meta?: LogMeta) {
this.logger.log(level, message, meta);
}
debug(message: string, meta?: LogMeta) {
this.log('debug', message, meta);
}
info(message: string, meta?: LogMeta) {
this.log('info', message, meta);
}
warn(message: string, meta?: LogMeta) {
this.log('warn', message, meta);
}
error(message: string, meta?: LogMeta) {
this.log('error', message, meta);
}
// ---------- 取得 Request Context ----------
static fromRequest(req: Request): LogMeta {
return {
requestId: (req.headers['x-request-id'] as string) || '',
userId: (req.user && (req.user as any).id) || '',
method: req.method,
path: req.originalUrl,
ip: req.ip,
};
}
}
重點說明
format.json()讓每筆日誌都是 結構化 JSON,方便在 ElasticSearch、Splunk 等平台搜尋。DailyRotateFile會自動依日期切檔、壓縮舊檔,降低磁碟佔用。static fromRequest提供 自動抽取 request 相關資訊 的便利方法,日後可在 middleware 中直接掛載。
3.3 在 Express 中注入 Logger
// src/app.ts
import express, { Request, Response, NextFunction } from 'express';
import { Logger } from './logger/Logger';
const app = express();
const logger = new Logger();
// 1️⃣ 請求日誌 Middleware
app.use((req: Request, res: Response, next: NextFunction) => {
const start = Date.now();
// 在 response 完成時記錄結果
res.on('finish', () => {
const duration = Date.now() - start;
const meta = Logger.fromRequest(req);
meta.statusCode = res.statusCode;
meta.durationMs = duration;
logger.info('Request completed', meta);
});
next();
});
// 2️⃣ 測試路由
app.get('/hello', (req, res) => {
logger.debug('Entering /hello handler', Logger.fromRequest(req));
res.send('Hello World');
});
// 3️⃣ 全域錯誤處理
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
logger.error('Unhandled error', {
...Logger.fromRequest(req),
message: err.message,
stack: err.stack,
});
res.status(500).json({ error: 'Internal Server Error' });
});
export default app;
4. 進階範例:加入 環境變數切換 與 自訂 Transport
4.1 依環境切換 Log Level
// src/logger/Logger.ts (constructor 內部)
const env = process.env.NODE_ENV || 'development';
const consoleLevel: LogLevel = env === 'production' ? 'info' : 'debug';
const consoleTransport = new transports.Console({
level: consoleLevel,
// ... 其他設定保持不變
});
在 production 環境僅輸出
info以上等級,避免過多 debug 訊息洩漏。
4.2 實作 HTTP Transport(傳送至 Loggly)
import { HttpTransportOptions } from 'winston/lib/winston/transports';
import * as Transport from 'winston-transport';
class LogglyTransport extends Transport {
private endpoint: string;
private token: string;
constructor(opts: { endpoint: string; token: string }) {
super(opts);
this.endpoint = opts.endpoint;
this.token = opts.token;
}
log(info: any, callback: () => void) {
const payload = {
token: this.token,
...info,
};
// 使用 fetch 或 axios 發送
fetch(this.endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).catch(() => {}); // 錯誤不阻斷主流程
this.emit('logged', info);
callback();
}
}
// 在 Logger 建構子中加入
if (process.env.LOGGLY_TOKEN) {
this.logger.add(
new LogglyTransport({
level: 'warn',
endpoint: 'https://logs-01.loggly.com/inputs',
token: process.env.LOGGLY_TOKEN,
})
);
}
只要在環境變數設定
LOGGLY_TOKEN,即可自動把 warn 以上 的訊息送至 Loggly,無需改變程式碼其他部分。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方式 |
|---|---|---|
| 日誌過度 | 把所有 debug 訊息直接寫入檔案,導致磁碟爆炸。 |
使用 log rotation(winston-daily-rotate-file),並在 prod 只保留 info+。 |
| 缺少唯一識別碼 | 無法把同一次請求的多筆日誌串起來。 | 在 middleware 中產生 X-Request-Id(可使用 uuid),並透過 Logger.fromRequest 注入。 |
| 同步寫檔阻塞事件迴圈 | 使用 fs.appendFileSync 會卡住 Node。 |
讓 winston 的 transport 使用 非同步 I/O(預設即為非同步)。 |
| 錯誤堆疊遺失 | logger.error(err) 只印出 err.message。 |
使用 format.errors({ stack: true }),或手動傳入 err.stack。 |
| 測試時產生大量日誌 | 單元測試跑大量 console.log,干擾結果。 |
在測試環境把 logger 替換為 null transport(不輸出)。 |
最佳實踐 Checklist
- ✅ 層級設計:
debug < info < warn < error。 - ✅ 結構化:盡量使用 JSON,避免自由文字。
- ✅ 請求追蹤:加入
requestId、userId、durationMs。 - ✅ 環境分離:開發環境使用顏色化 Console,生產環境使用檔案 + 服務端上傳。
- ✅ 測試友善:提供
createTestLogger()回傳只寫入記憶體的 transport。
實際應用場景
API 監控
- 每筆請求記錄
method、path、statusCode、durationMs、requestId,配合 Grafana Loki 即可即時觀測 API latency。
- 每筆請求記錄
錯誤回報
- 當
error層級觸發時,同步把錯誤訊息與堆疊上傳至 Sentry,日誌仍保留完整上下文。
- 當
多租戶系統
- 在
LogMeta中加入tenantId,日後可以依租戶切分日誌,符合 GDPR / CCPA 等合規需求。
- 在
批次工作(Cron)
- 為每次執行的 batch job 產生唯一
jobId,透過 logger 記錄每一步的成功/失敗,方便事後追蹤。
- 為每次執行的 batch job 產生唯一
Performance Profiling
- 在關鍵函式前後手動
logger.debug('start', { tag: 'cache-miss' })/logger.debug('end', { tag: 'cache-miss', durationMs }),配合 Elastic APM 做效能分析。
- 在關鍵函式前後手動
總結
自訂 Logger 在 Express + TypeScript 專案中不僅是除錯的輔助工具,更是 可觀測性(Observability) 的核心組件。透過 winston 的 層級、Transport、Formatter,我們可以:
- 統一日誌結構,讓所有訊息具備時間戳、層級與自訂 meta。
- 彈性切換輸出,開發時使用彩色 Console,生產環境寫入每日輪轉檔案或上傳至雲端服務。
- 自動注入請求上下文,讓每筆日誌都能追蹤到同一次 HTTP 請求。
- 遵循最佳實踐,避免磁碟爆炸、堆疊遺失與測試噪音。
只要把上述的 Logger 類別與 middleware 直接套用於你的 Express 應用,即可在 開發、測試、部署 三個階段都擁有一致、可靠且具可擴充性的日誌系統。未來若有需要整合 ELK、Grafana Loki、Datadog 或 Sentry,只要再寫一個簡單的 Transport,即可無縫接軌。
從今天開始,讓你的程式碼說話,讓日誌為你服務! 🚀