Guide/guides

ロギングガイド

Guren のロギングは、RFC 5424 に沿ったログレベル、複数のチャンネル、コンテキスト付きの記録に対応しています。

ロギングガイド

Guren のロギングは、RFC 5424 に沿ったログレベル、複数のチャンネル、コンテキスト付きの記録に対応しています。

コアコンセプト

  • LogManager: ログのチャンネルをまとめて管理する中心のオブジェクト
  • Logger: コンテキストを付けてログエントリを書き込むインスタンス
  • LogChannel: ログエントリの出力先(コンソール、ファイルなど)
  • LogLevel: RFC 5424 に沿った重大度(emergency、alert、critical、error、warning、notice、info、debug)

基本的な使い方

ファサードを使用(推奨)

いちばん手軽なのは、LogManager を通して記録する方法です。LogManager は、使うときにコンテナから解決されます。

// Resolve the logger from the container
const Log = app.container.make('log')

Log.info('アプリケーションが起動しました')
Log.error('問題が発生しました', { error: '接続に失敗しました' })
Log.channel('file').warning('ファイルにのみ記録')

クイックスタート

LogManager を自分でインスタンス化して使うこともできます。

import { LogManager } from '@guren/core'

const log = new LogManager({
  default: 'console',
  channels: {
    console: { driver: 'console', level: 'debug' },
  },
})

log.info('アプリケーションが起動しました')
log.error('問題が発生しました', { error: '接続に失敗しました' })

ログレベル

ログレベルには RFC 5424 の重大度を使います。次の例は重大なものから順に並べています。

log.emergency('システムが使用不能')
log.alert('即時対応が必要')
log.critical('重大な状態')
log.error('エラー状態')
log.warning('警告状態')  // または log.warn()
log.notice('正常だが重要な状態')
log.info('情報メッセージ')
log.debug('デバッグレベルのメッセージ')

コンテキスト付きロギング

// 個別のログエントリにコンテキストを追加
log.info('ユーザーがログイン', { userId: 123, ip: '192.168.1.1' })

// 永続的なコンテキストを持つロガーを作成
const requestLog = log.withContext({ requestId: 'abc-123' })
requestLog.info('リクエスト処理中')    // requestIdを含む
requestLog.info('リクエスト完了')     // requestIdを含む

// 子ロガーを作成
const userLog = requestLog.child({ userId: 456 })
userLog.info('ユーザーアクション')  // requestIdとuserIdを含む

チャンネル

コンソールチャンネル

色を付け、書式を整えてコンソールに出力します。

const log = new LogManager({
  default: 'console',
  channels: {
    console: {
      driver: 'console',
      level: 'debug',          // ログ出力の最小レベル
      colors: true,            // ANSIカラーを有効化
      timestamps: true,        // タイムスタンプを含める
      format: 'text',          // 'text' または 'json'
    },
  },
})

ファイルチャンネル

1 つのファイルにログを書き込みます。

const log = new LogManager({
  default: 'file',
  channels: {
    file: {
      driver: 'file',
      path: './storage/logs/app.log',
      level: 'info',
      format: 'json',  // 'text' または 'json'
    },
  },
})

デイリーファイルチャンネル

ログファイルを日ごとに切り替え、古いファイルは自動で削除します。

const log = new LogManager({
  default: 'daily',
  channels: {
    daily: {
      driver: 'daily',
      path: './storage/logs/app.log',
      days: 14,        // 14日間ログを保持
      level: 'info',
      format: 'json',
    },
  },
})

ファイル名には app-2024-01-15.log のように日付が付きます。

スタックチャンネル

複数のチャンネルをまとめて 1 つのチャンネルとして扱えます。

const log = new LogManager({
  default: 'stack',
  channels: {
    console: { driver: 'console', level: 'debug' },
    file: { driver: 'daily', path: './storage/logs/app.log', days: 14 },
    stack: {
      driver: 'stack',
      channels: ['console', 'file'],  // 両方に書き込み
    },
  },
})

複数チャンネルの使用

チャンネルの切り替え

// デフォルトチャンネルを使用
log.info('デフォルトを使用')

// 特定のチャンネルを使用
log.channel('console').debug('デバッグ情報')
log.channel('file').error('ファイルのみに記録')

一時的なスタック

// 一時的なスタックを作成
log.stack(['console', 'file']).critical('重大なエラー!')

設定

完全な設定例

import { LogManager } from '@guren/core'

const log = new LogManager({
  default: 'stack',
  channels: {
    // 開発用コンソール
    console: {
      driver: 'console',
      level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
      colors: true,
      timestamps: true,
      format: 'text',
    },

    // 本番用の日次ローテーションファイル
    daily: {
      driver: 'daily',
      path: './storage/logs/app.log',
      level: 'info',
      days: 30,
      format: 'json',
    },

    // エラー専用ログ
    errors: {
      driver: 'daily',
      path: './storage/logs/errors.log',
      level: 'error',
      days: 90,
      format: 'json',
    },

    // デフォルトロギング用スタック
    stack: {
      driver: 'stack',
      channels: ['console', 'daily'],
    },

    // 重大アラート
    critical: {
      driver: 'stack',
      channels: ['console', 'daily', 'errors'],
    },
  },
})

独自の LogManager を束縛する

LogServiceProvider は、createApp({ logging }) の設定から組み立てたマネージャをコンテナに束縛します。マネージャを自分で組み立てる場合は、独自のプロバイダから束縛してください。

import { ServiceProvider, LogManager } from '@guren/core'

export default class LoggingProvider extends ServiceProvider {
  register(): void {
    this.container.instance('log', new LogManager({
      default: 'stack',
      channels: { /* ... */ },
    }))
  }
}

note

setLogManager() と getLogManager() は 2.23.0 で非推奨になり、3.0.0 で削除されます。この 2 つはプロセスに 1 つだけのマネージャを読み書きするので、1 つのプロセスに 2 つのアプリがあるとマネージャが共有されてしまいます。bunx guren upgrade が自動で書き換えるのは、プロバイダやジョブの中の呼び出しだけです。モジュールのトップレベルにある呼び出しは報告されるだけなので、上のような束縛に手で置き換えてください。

カスタムチャンネル

カスタムチャンネルの作成

import type { LogChannel, LogEntry } from '@guren/core'

class SlackChannel implements LogChannel {
  constructor(private webhookUrl: string) {}

  async log(entry: LogEntry): Promise<void> {
    if (entry.level !== 'critical' && entry.level !== 'emergency') {
      return // critical/emergencyのみSlackに送信
    }

    await fetch(this.webhookUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        text: `[${entry.level.toUpperCase()}] ${entry.message}`,
        attachments: [{
          fields: Object.entries(entry.context).map(([k, v]) => ({
            title: k,
            value: String(v),
            short: true,
          })),
        }],
      }),
    })
  }
}

カスタムドライバーの登録

const log = new LogManager({
  default: 'stack',
  channels: {
    slack: { driver: 'slack', webhookUrl: process.env.SLACK_WEBHOOK },
    stack: { driver: 'stack', channels: ['console', 'slack'] },
  },
})

log.registerDriver('slack', (config) => {
  return new SlackChannel(config.webhookUrl as string)
})

note

Cloudflare Workers では、レスポンスを返した時点で終わっていない処理は破棄されます。リクエストの処理中に log() を呼んだ場合、非同期チャンネルの書き込み(上の例の fetch)はそのリクエストの waitUntil に渡されるので、レスポンスを返した後でもエントリは届きます。起動時のようにリクエストの外で呼んだログは、waitUntil を通さずに書き込まれます。ほかのランタイムではプロセスがリクエストより長く動き続けるので、この点は気にしなくて構いません。

リクエストロギング

ミドルウェアの例

import { defineMiddleware, getRequestContainer } from '@guren/core'

export const requestLogging = defineMiddleware(async (c, next) => {
  const log = getRequestContainer(c).make('log')
  const requestId = crypto.randomUUID()
  const requestLog = log.withContext({
    requestId,
    method: c.req.method,
    path: c.req.path,
  })

  const start = Date.now()
  requestLog.info('リクエスト開始')

  // ハンドラーで使用するためコンテキストにロガーを保存
  c.set('log', requestLog)

  try {
    await next()

    requestLog.info('リクエスト完了', {
      status: c.res.status,
      duration: Date.now() - start,
    })
  } catch (error) {
    requestLog.error('リクエスト失敗', {
      error: error instanceof Error ? error.message : '不明なエラー',
      duration: Date.now() - start,
    })
    throw error
  }
})

テスト

import { describe, test, expect, beforeEach, mock } from 'bun:test'
import { LogManager, Logger, LOG_LEVEL_PRIORITY } from '@guren/core'
import type { LogChannel, LogEntry } from '@guren/core'

// テスト用メモリチャンネルを作成
class MemoryChannel implements LogChannel {
  entries: LogEntry[] = []

  log(entry: LogEntry): void {
    this.entries.push(entry)
  }

  clear(): void {
    this.entries = []
  }
}

describe('ロギング', () => {
  let memoryChannel: MemoryChannel
  let log: LogManager

  beforeEach(() => {
    memoryChannel = new MemoryChannel()
    log = new LogManager({
      default: 'memory',
      channels: {
        memory: { driver: 'memory' },
      },
    })
    log.registerDriver('memory', () => memoryChannel)
  })

  test('異なるレベルでログを記録する', () => {
    log.info('情報メッセージ')
    log.error('エラーメッセージ')

    expect(memoryChannel.entries).toHaveLength(2)
    expect(memoryChannel.entries[0].level).toBe('info')
    expect(memoryChannel.entries[1].level).toBe('error')
  })

  test('ログエントリにコンテキストを含める', () => {
    log.info('ユーザーアクション', { userId: 123 })

    expect(memoryChannel.entries[0].context).toEqual({ userId: 123 })
  })

  test('withContextが永続的なコンテキストを追加する', () => {
    const requestLog = log.withContext({ requestId: 'abc' })
    requestLog.info('最初')
    requestLog.info('2番目', { extra: 'data' })

    expect(memoryChannel.entries[0].context).toEqual({ requestId: 'abc' })
    expect(memoryChannel.entries[1].context).toEqual({ requestId: 'abc', extra: 'data' })
  })
})

ログ出力フォーマット

テキストフォーマット

[2024-01-15T10:30:00.000Z] INFO      ユーザーがログイン {"userId":123,"ip":"192.168.1.1"}
[2024-01-15T10:30:01.000Z] ERROR     データベース接続失敗 {"error":"ECONNREFUSED"}

JSONフォーマット

{"timestamp":"2024-01-15T10:30:00.000Z","level":"info","message":"ユーザーがログイン","userId":123,"ip":"192.168.1.1"}
{"timestamp":"2024-01-15T10:30:01.000Z","level":"error","message":"データベース接続失敗","error":"ECONNREFUSED"}

ベストプラクティス

  1. レベルを使い分ける: 何でも error にはせず、開発中にだけ見たい情報は debug にします。

  2. コンテキストを添える: ユーザー ID やリクエスト ID など、デバッグに必要な情報を一緒に記録します。

  3. 構造化して記録する: 本番では JSON 形式にしておくと、解析も検索も楽になります。

  4. ログファイルをローテーションする: ディスクがいっぱいにならないよう、daily ドライバーを使います。

  5. 本番ではレベルを絞る: 本番のコンソールは 'info' 以上だけを出すように設定します。

  6. コンテキスト付きのロガーを作る: リクエスト単位で記録するときは withContext() を使います。

  7. 機密データを記録しない: パスワード、トークン、個人情報はログに出さないでください。

  8. 終了時にチャンネルを閉じる: 書き込み待ちのエントリを出し切るため、log.close() を呼びます。