猫でもわかるWeb開発・プログラミング

本業エンジニアリングマネージャー。副業Webエンジニア。Web開発のヒントや、副業、日常生活のことを書きます。

JavaScript / TypeScript の Logger 選定 on Docker環境

はじめに

TypeScript + Docker でサーバーサイド開発をしており、コードに Logger を導入したかったため、ライブラリ選定を行いました。

今回は Docker を使っており、 AWS ECS にデプロイしているため、ログを標準出力に出したいです。通常ログはファイルに出力することが多いが、 Docker を使っている場合は標準出力に出す場合が多いです。

特に、 AWS ECS を使っている場合は、標準出力に出たログを簡単に CloudWatch Logs に送ることができるメリットもあります。

CloudWatch Logs にログを送る場合、JSON 形式でログを出していれば、勝手に CloudWatch Logs でパースしてくれて、検索が便利です。

ということで、要件は以下の通り

  • 標準出力にログを出せる
  • JSON 形式でログを出せる

ライブラリの選択肢

標準出力にログを出力する場合、ライブラリを導入しなくても、 console.log で済ませる方法もあります。

  • ライブラリを導入しない(console.log で済ませる)
  • ライブラリを導入する

この中で、winston が特に利用者数が多く、使い勝手も問題なかったため、 winston を導入することにしました。

console.log を導入するか迷いました。

ライブラリを使わず console.log を利用するメリットは、ライブラリのアップデートといった雑務がなくなることですが、

  • JSON フォーマッタなどの実装コストがかなり減ること
  • ログのフォーマット等を変更するのが容易であること
    • 将来的に JSON 以外のフォーマットで出したくなるかもしれないし、標準出力以外に出したくなるかもしれない

といった理由から winston を採用します。

実装

シンプルな実装

winston の logger を直接使うのではなく、一つラップしてあげて、自分たちのコードベースに合うようにしてあげると使いやすくなります。

winston の使い方は以下の通りです

  • 環境変数で LOG_LEVEL を指定することで、「開発環境では debug ログを出す」といったことも可能にしています
    • winston のログレベルには、 debug, info, warn, error などがあり、例えば、 level に info を指定すると info 以上のログしか出力されなくなります。debug ログは出ないようになります。
    • ログレベルの使い分けは以下のようにしています
      • error 緊急の対応を必要とする場合
      • warn 緊急ではないが、大量に出力された場合には調査を必要とする場合など
      • info 検知する必要がないが、エラーの調査時などに残っていて欲しいログ
      • debug 開発環境で開発するときだけ出てれば良いログ
  • transports には出力を複数指定できますが、今回は標準出力にだけ出ればいいので、 Console を指定します
import winston from "winston"

class MyLogger {
  private logger: winston.Logger

  public constructor() {
    this.logger = winston.createLogger({
      level: process.env.LOG_LEVEL ?? 'info',
      transports: [
        new winston.transports.Console()
      ],
    })
  }

  /**
   * 開発環境でのみ出力したい内容を出力する
   */
  public debug(message: string, extra: object = {}) {
    this.logger.debug(message, {extra: extra})
  }

  /**
   * エラーではないが、調査の際に必要となるようなログを出力する
   * 本番環境でも出力される
   */
  public info(message: string, extra: object = {}) {
    this.logger.info(message, {extra: extra})
  }

  /**
   * アラートを鳴らすほどではないが、検知しておきたいエラーなどを出力する
   */
  public warn(message: string, extra: object = {}) {
    this.logger.warn(message, {extra: extra})
  }

  /**
   * アラートを鳴らすべきエラーログを出力する
   */
  public error(message: string, extra: object = {}) {
    this.logger.error(message, {extra: extra})
  }
}

凝った実装

「ログは出していたけど userId を出力するの忘れていた!」といったことがないように、ログに出したいパラメータは、ラップしたクラスの引数で必須にしておくと、忘れることがありません。

よくあるのは、 Context クラスを作成し、それを Logger の引数で渡すことです。リクエストごとに requestId を発行して Context に埋め込んでおくなどすると、一連のログが追いやすくなり便利になります。

import winston from "winston"

class MyLogger {
  private logger: winston.Logger

  public constructor() {
    this.logger = winston.createLogger({
      level: process.env.LOG_LEVEL ?? 'info',
      transports: [
        new winston.transports.Console()
      ],
    })
  }

  /**
   * エラーではないが、調査の際に必要となるようなログを出力する
   * 本番環境でも出力される
   */
  public info(context: Context, message: string, extra: object = {}) {
    this.logger.info(message, {
      requestId: context.requestId, 
      requestUrl: context.requestUrl, 
      userId: context.userId, 
      extra: extra})
  }
}

class Context {
  public constructor(requestUrl: string, requestId: string, userId: string) {
    ...
  }
}