Published 20 Nov 2023

每個App都需要一個Logger

做一個可以在development和production環境都可以用到的logger

debug
logger
sentry
error handling
每個App都需要一個Logger

小弟仍然在網頁開發的學習道路上,因此本文並不算是教程,只是小弟在學習過程中發現到的覺得值得分享的編程做法。由於寫作水平有限,很多術語實在不知道怎麼翻譯,因此會有不少中英夾雜的情況,實在抱歉。如文中的語句或邏輯不清晰,請見諒。歡迎對文章分享您的看法以及聯絡交流,謝謝!

每個 Javascript 開發者在開發時都會用到 console.log 來查看 api 返回的結果或者函數的結果,尤其是在 debug 過程中,console.log 更是必不可少,可以最直觀地把 error 呈現在 broswer 或者 terminal 中。在 Development 環境中,我們並不介意 console.log 的結果直接顯示在 browser 中,但是在 Production 環境中則不一樣。一來是不希望讓用戶看到原始的 error 的信息(對用戶沒任何作用,APP 應該有 error-handling 的機制呈現恰當的錯誤信息給用戶),二來是希望 這些 error 信息可以被如 Sentry 這類專門檢測網站或者原生 APP(下文統稱 APP) 的表現及報錯的軟件可以捕抓並記錄下來,方便開發者之後進行修補。這時候就需要一個 Logger 承擔起 APP 的檢測任務,能夠把所有在 APP 發生的事件包括 error 都捕抓並在合適的時候顯示出來。


接下來會寫一個Logger Class專門處理在不同運行環境中,不同重要程度的 log。這些重要程度包括:debug, info, log, warn, error。

例子會用 next.js 作為網站的 framework,連接 sentry 實現 Production 環境的報錯。

引入需要的 dependency

date-fns/format用作處理時間數據。

import * as Sentry from "@sentry/nextjs";
import format from "date-fns/format";

定義 Log 的重要程度的 Type

這裡用enum而不是直接用 type 是為了在 runtime 時都可以直接調用LogLevel,降低編程 typo 的出錯機會。

export enum LogLevel {
  Debug = 'debug',
  Info = 'info',
  Log = 'log',
  Warn = 'warn',
  Error = 'error',
}

定義發送到SentryMetadata 的 Type

type Metadata = {
  /**
   * 用到Sentry breadcrumb types, 預設是  `default`. 詳情可以到下面鏈接:
   * @see https://develop.sentry.dev/sdk/event-payloads/breadcrumbs/#breadcrumb-types
   */
  type?:
    | "default"
    | "debug"
    | "error"
    | "navigation"
    | "http"
    | "info"
    | "query"
    | "transaction"
    | "ui"
    | "user",
 
  /**
   * tags會放到Sentry.captureException的函數裡面,詳情可以看下面鏈接
   *
   * @see https://github.com/getsentry/sentry-javascript/blob/903addf9a1a1534a6cb2ba3143654b918a86f6dd/packages/types/src/misc.ts#L65
   */
  tags?: {
    [key: string]:
      | number
      | string
      | boolean
      | bigint
      | symbol
      | null
      | undefined,
  },
 
  /**
   * 任何其它額外的數據都可以通過 extra 這個參數放在Sentry.captureException的函數裡面,在之後debug時使用。
   */
  [key: string]: unknown,
} & Parameters<typeof Sentry.captureException>[1];

定義Transport的 Type

Logger 在 Development 環境會用 console.log,而在 Production 環境會通過 sentry/nextjs (Sentry SDK)發送到 Sentry,不需要使用 console.log。因此會寫兩個 Transport,分別是consoleTransportsentryTransport

基本 Transport Type
type Transport = (
  level: LogLevel,
  message: string | Error,
  metadata: Metadata,
  timestamp: number
) => void;
consoleTransport Type
export const consoleTransport: Transport = (
  level,
  message,
  metadata,
  timestamp
) => {
  const extra = Object.keys(metadata).length
    ? " " + JSON.stringify(metadata, null, "  ")
    : "";
 
  const log = {
    [LogLevel.Debug]: console.debug,
    [LogLevel.Info]: console.info,
    [LogLevel.Log]: console.log,
    [LogLevel.Warn]: console.warn,
    [LogLevel.Error]: console.error,
  }[level];
 
  log(`${format(timestamp, "HH:mm:ss")} ${message.toString()}${extra}`);
};
sentryTransport Type
export const sentryTransport: Transport = (
  level,
  message,
  { type, tags, ...metadata },
  timestamp,
) => {
  if (typeof message === 'string') {
    const severity = (
      {
        [LogLevel.Debug]: 'debug',
        [LogLevel.Info]: 'info',
        [LogLevel.Log]: 'log',
        [LogLevel.Warn]: 'warning',
        [LogLevel.Error]: 'error',
      } as const
    )[level];
 
    Sentry.addBreadcrumb({
      message,
      data: metadata,
      type: type || 'default',
      level: severity,
      timestamp: timestamp / 1000, // Sentry expects seconds
    });
 
    if (level === 'error' || level === 'warn' || level === 'log') {
      const messageLevel = ({
        [LogLevel.Log]: 'log',
        [LogLevel.Warn]: 'warning',
        [LogLevel.Error]: 'error',
      }[level] || 'log') as Sentry.Breadcrumb['level'];
 
      Sentry.captureMessage(message, {
        level: messageLevel,
        tags,
        extra: metadata,
      });
    }
  } else {
    /**
     * It's otherwise an Error and should be reported with captureException
     */
    Sentry.captureException(message, {
      tags,
      extra: metadata,
    });
  }
};

最後兩個前置動作

創建 debugContext.ts 文件

為在本地 debug 時更好地對 error message 進行分類,加入DebugContext,在DebugContext object 中加任意的資料,例如想標明 error 來自 session 組件的,便加入 session: 'session' 。DebugContext稍後會被引入至logger.ts使用。

/**
 * 當在APP的其它地方,注意不要直接從這個文件引入,而是用`logger.DebugContext`
 */
export const DebugContext = {
  // e.g. composer: 'composer'
  session: 'session',
  notifications: 'notifications',
} as const

創建 Logger Class

到了打 Boss 的時候了,接下來寫 Logger Class

export class Logger {
  LogLevel = LogLevel;
  DebugContext = DebugContext;
  enabled: boolean;
  level: LogLevel;
  transports: Transport[] = [];
 
  protected debugContextRegexes: RegExp[] = [];
 
  constructor({
    enabled = process.env.NODE_ENV !== 'test',
    level = process.env.NEXT_PUBLIC_LOG_LEVEL as LogLevel,
    debug = '',
  }: {
    enabled?: boolean;
    level?: LogLevel;
    debug?: string;
  } = {}) {
    this.enabled = enabled !== false;
    this.level = debug ? LogLevel.Debug : level ?? LogLevel.Info; //default to info
 
    this.debugContextRegexes = (debug || '').split(',').map(context => {
      return new RegExp(context.replace(/[^\w:*]/, '').replace(/\*/g, '.*'));
    });
  }
 
  debug(message: string, metadata: Metadata = {}, context?: string) {
    if (context && !this.debugContextRegexes.find(reg => reg.test(context))) {
      return;
    }
    this.transport(LogLevel.Debug, message, metadata);
  }
 
  info(message: string, metadata: Metadata = {}) {
    this.transport(LogLevel.Info, message, metadata);
  }
  log(message: string, metadata: Metadata = {}) {
    this.transport(LogLevel.Log, message, metadata);
  }
  warn(message: string, metadata: Metadata = {}) {
    this.transport(LogLevel.Warn, message, metadata);
  }
  error(error: string | Error, metadata: Metadata = {}) {
    this.transport(LogLevel.Error, error, metadata);
  }
 
  addTransport(transport: Transport) {
    this.transports.push(transport);
    return () => {
      this.transports.splice(this.transports.indexOf(transport));
    };
  }
 
  disable() {
    this.enabled = false;
  }
 
  enable() {
    this.enabled = true;
  }
 
  protected transport(
    level: LogLevel,
    message: string | Error,
    metadata: Metadata = {},
  ) {
    if (!this.enabled) {
      return;
    }
    if (!enabledLogLevels[this.level].includes(level)) {
      return;
    }
 
    const timestamp = Date.now();
    const meta = metadata || {};
    for (const transport of this.transports) {
      transport(level, message, meta, timestamp);
    }
 
    add({
      id: nanoid(),
      timestamp,
      level,
      message,
      metadata: meta,
    });
  }
}
 
export const logger = new Logger()

最後設定 logger 在不同環境中放入不同的 Transport

在 Development 環境用 console, Production 用 Sentry, Test 不做任何 log

if (env.IS_DEV && !env.IS_TEST) {
  logger.addTransport(consoleTransport);
} else if (env.IS_PROD) {
  logger.addTransport(sentryTransport);
}
Lion Shi
Lion ShiWeb Developer