本文 AI 產出,尚未審核

ExpressJS (TypeScript) – Logging 與 Debug

主題:自訂 Logger(TypeScript)


簡介

Node.jsExpress 應用程式中,日誌(logging) 是排除錯誤、監控系統健康度以及追蹤業務流程的必備工具。即使在開發階段,清晰、結構化的日誌也能讓我們快速定位問題;在上線後,良好的日誌更是診斷服務異常、分析使用者行為、符合合規需求的關鍵。

然而,單純使用 console.log 雖然簡單,但缺乏層級管理、時間戳記、結構化輸出與可擴充性。本文將帶你 從零開始,在 Express + TypeScript 專案中建立一套可自訂、可擴充、且支援多層級的 Logger,讓日誌不再是「雜訊」,而是有價值的資訊資產。


核心概念

1. 為什麼要自訂 Logger?

缺點 console.log 自訂 Logger
無層級(info / warn / error)
無時間戳記或請求 ID
難以切換輸出目的地(檔案 / 雲端)
無格式化(JSON / 顏色)
無測試友善(mock)

結論:在大型或長期維護的專案中,自訂 Logger 能提供一致的日誌結構、方便的除錯資訊,並且易於與第三方監控平台(如 Datadog、Loggly)整合。


2. Logger 的基本構成

  1. Log Level:決定訊息的重要程度(debuginfowarnerror)。
  2. Transport:訊息的輸出目的地,常見有 ConsoleFileHTTP
  3. Formatter:決定訊息的呈現方式,常見有 文字JSON彩色
  4. Context:額外的元資料,如 requestIduserIdstack 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 rotationwinston-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,避免自由文字。
  • 請求追蹤:加入 requestIduserIddurationMs
  • 環境分離:開發環境使用顏色化 Console,生產環境使用檔案 + 服務端上傳。
  • 測試友善:提供 createTestLogger() 回傳只寫入記憶體的 transport。

實際應用場景

  1. API 監控

    • 每筆請求記錄 method、path、statusCode、durationMs、requestId,配合 Grafana Loki 即可即時觀測 API latency。
  2. 錯誤回報

    • error 層級觸發時,同步把錯誤訊息與堆疊上傳至 Sentry,日誌仍保留完整上下文。
  3. 多租戶系統

    • LogMeta 中加入 tenantId,日後可以依租戶切分日誌,符合 GDPR / CCPA 等合規需求。
  4. 批次工作(Cron)

    • 為每次執行的 batch job 產生唯一 jobId,透過 logger 記錄每一步的成功/失敗,方便事後追蹤。
  5. Performance Profiling

    • 在關鍵函式前後手動 logger.debug('start', { tag: 'cache-miss' }) / logger.debug('end', { tag: 'cache-miss', durationMs }),配合 Elastic APM 做效能分析。

總結

自訂 LoggerExpress + TypeScript 專案中不僅是除錯的輔助工具,更是 可觀測性(Observability) 的核心組件。透過 winston層級、Transport、Formatter,我們可以:

  1. 統一日誌結構,讓所有訊息具備時間戳、層級與自訂 meta。
  2. 彈性切換輸出,開發時使用彩色 Console,生產環境寫入每日輪轉檔案或上傳至雲端服務。
  3. 自動注入請求上下文,讓每筆日誌都能追蹤到同一次 HTTP 請求。
  4. 遵循最佳實踐,避免磁碟爆炸、堆疊遺失與測試噪音。

只要把上述的 Logger 類別與 middleware 直接套用於你的 Express 應用,即可在 開發、測試、部署 三個階段都擁有一致、可靠且具可擴充性的日誌系統。未來若有需要整合 ELK、Grafana Loki、DatadogSentry,只要再寫一個簡單的 Transport,即可無縫接軌。

從今天開始,讓你的程式碼說話,讓日誌為你服務! 🚀