Ougai

Rubyにおける構造化ログの有名な選択肢。標準Loggerクラスを拡張し、プリティプリント、子ロガー、JSONロギング、スタックトレース付き例外処理などの便利な機能を追加。構造化ログに特化したライブラリ。

Ruby構造化ログJSONBunyan互換ログ分析高性能

ライブラリ

Ougai

概要

Ougaiは、Ruby向けに設計された構造化ログライブラリで、メッセージ、カスタムデータ、例外を簡単に処理し、JSONまたは人間が読みやすい形式でログを生成します。Ruby標準のLoggerクラスのサブクラスとして実装され、Node.jsのBunyanやPinoと互換性のあるJSON形式を出力。標準出力にはAmazing Printを使用した美しいフォーマットを提供し、構造化データの独立フィールド化により効率的なログ分析を実現。本番環境での高度なログ管理に最適化された現代的なRubyロギングソリューションです。

詳細

Ougaiは「構造化ログ」のコンセプトを中心に設計されたRubyライブラリで、従来のテキストベースログの制約を克服します。Ruby標準ライブラリのLoggerを継承しながら、メッセージに埋め込まれたコンテキスト情報を独立したログ構造のフィールドとして提供。出力形式はNode.jsエコシステムの標準であるBunyan/Pino互換のJSONフォーマットで、専用ログビューアコマンドでのブラウジングが可能。ログレベルには分析しやすい数値形式を採用し、「level >= 30」のようなクエリで効率的なフィルタリングを実現。例外ログ時には自動的にerr プロパティを追加し、name、message、stackプロパティでエラー情報を構造化して記録します。

主な特徴

  • 構造化JSON出力: Bunyan/Pino互換の標準JSON形式でのログ出力
  • Ruby Logger継承: 標準Loggerの全機能をサポートし既存コードとの互換性維持
  • 独立フィールド化: コンテキスト情報をメッセージから分離し独立フィールドとして記録
  • 数値ログレベル: 効率的なフィルタリングクエリを可能にする数値ベースレベル管理
  • 例外自動構造化: エラー発生時のerr プロパティ自動生成と詳細情報記録
  • 専用ビューア対応: Bunyan/Pino専用コマンドでのログブラウジングサポート

メリット・デメリット

メリット

  • 構造化ログによる高度なログ分析とクエリ機能を提供
  • Node.jsエコシステムとの互換性で豊富なツールチェーン活用が可能
  • Ruby標準Loggerとの完全互換性で既存プロジェクトへの導入が容易
  • JSON形式出力により現代的なログ管理基盤との統合が簡単
  • コンテキスト情報の独立化で後処理分析の効率性が大幅向上
  • 数値ベースログレベルによる柔軟で効率的なフィルタリング

デメリット

  • JSON出力により人間による直接読み取りが困難な場合がある
  • 構造化ログの概念理解と設計に初期学習コストが必要
  • 大量ログ出力時のJSON シリアライゼーションによるパフォーマンス影響
  • レガシーなテキストベースログ処理ツールとの互換性問題
  • 適切な構造化設計なしでは逆にログの複雑化を招くリスク
  • Ruby以外の言語環境との統合時に追加考慮事項が発生

参考ページ

書き方の例

基本セットアップ

# Gemfile
gem 'ougai'

# インストール
bundle install

# 基本的な使用法
require 'ougai'

# 標準出力への基本ロガー
logger = Ougai::Logger.new($stdout)

# ファイル出力ロガー
file_logger = Ougai::Logger.new('application.log')

# JSON フォーマッター付きロガー
json_logger = Ougai::Logger.new($stdout)
json_logger.formatter = Ougai::Formatters::Bunyan.new

# 人間が読みやすいフォーマッター(開発用)
dev_logger = Ougai::Logger.new($stdout)
dev_logger.formatter = Ougai::Formatters::Readable.new

基本的なログ出力

require 'ougai'

logger = Ougai::Logger.new($stdout)

# 基本的なメッセージログ
logger.info('Application started')
logger.debug('Debug information')
logger.warn('Warning message')
logger.error('Error occurred')
logger.fatal('Fatal error')

# 出力例(JSON形式):
# {"name":"main","hostname":"localhost","pid":12345,"level":30,"time":"2024-01-15T10:30:25.123Z","v":0,"msg":"Application started"}

# 構造化データ付きログ
user_id = 12345
action = 'login'
ip_address = '192.168.1.100'

logger.info('User action performed', {
  user_id: user_id,
  action: action,
  ip_address: ip_address,
  timestamp: Time.now
})

# メッセージ後にハッシュで追加データを指定
logger.info('Order processed', {
  order_id: 'ORD-12345',
  customer_id: 'CUST-67890',
  amount: 150.75,
  currency: 'USD',
  payment_method: 'credit_card'
})

# ネストした構造化データ
logger.info('API request completed', {
  request: {
    method: 'POST',
    path: '/api/v1/users',
    headers: {
      'Content-Type' => 'application/json',
      'Authorization' => 'Bearer [REDACTED]'
    }
  },
  response: {
    status: 201,
    size: 1024,
    duration_ms: 245
  }
})

高度な設定

require 'ougai'

# アプリケーション全体のロガー設定
class ApplicationLogger
  def self.setup
    logger = Ougai::Logger.new($stdout)
    
    # 本番環境ではJSON、開発環境では読みやすい形式
    if Rails.env.production?
      logger.formatter = Ougai::Formatters::Bunyan.new
    else
      logger.formatter = Ougai::Formatters::Readable.new
    end
    
    # アプリケーション固有情報を追加
    logger.before_log = lambda do |data|
      data[:app_name] = 'MyRailsApp'
      data[:version] = Rails.application.config.version
      data[:environment] = Rails.env
    end
    
    logger
  end
end

# ロガーの初期化
$logger = ApplicationLogger.setup

# カスタムフィールド付きログ
class UserService
  def initialize
    @logger = $logger.child(service: 'UserService')
  end
  
  def create_user(params)
    user_id = SecureRandom.uuid
    
    @logger.info('User creation started', {
      user_id: user_id,
      email: params[:email],
      registration_source: params[:source] || 'web'
    })
    
    begin
      # ユーザー作成処理
      user = User.create!(params)
      
      @logger.info('User created successfully', {
        user_id: user.id,
        email: user.email,
        created_at: user.created_at
      })
      
      user
    rescue StandardError => e
      @logger.error('User creation failed', {
        user_id: user_id,
        error_class: e.class.name,
        error_message: e.message,
        params: params.except(:password) # パスワードを除外
      })
      raise
    end
  end
end

# パフォーマンス測定
class PerformanceLogger
  def initialize(logger)
    @logger = logger.child(component: 'PerformanceMonitor')
  end
  
  def measure(operation_name)
    start_time = Time.now
    @logger.debug('Operation started', operation: operation_name)
    
    result = yield
    
    duration = Time.now - start_time
    
    if duration > 1.0
      @logger.warn('Slow operation detected', {
        operation: operation_name,
        duration_seconds: duration.round(3),
        threshold_exceeded: true
      })
    else
      @logger.info('Operation completed', {
        operation: operation_name,
        duration_seconds: duration.round(3)
      })
    end
    
    result
  rescue StandardError => e
    duration = Time.now - start_time
    @logger.error('Operation failed', {
      operation: operation_name,
      duration_seconds: duration.round(3),
      error: e.message
    })
    raise
  end
end

# 使用例
perf_logger = PerformanceLogger.new($logger)

result = perf_logger.measure('database_query') do
  User.joins(:orders).where(active: true).count
end

エラーハンドリング

require 'ougai'

class ErrorLogger
  def initialize
    @logger = Ougai::Logger.new($stdout)
    @logger.formatter = Ougai::Formatters::Bunyan.new
  end
  
  def log_exception(exception, context = {})
    # Ougaiは例外を自動的に構造化
    @logger.error('Exception occurred', context.merge(
      exception_class: exception.class.name,
      exception_message: exception.message,
      backtrace: exception.backtrace&.first(10) # 最初の10行のみ
    ))
  end
  
  def log_validation_error(model, context = {})
    if model.errors.any?
      @logger.error('Validation failed', context.merge(
        model_class: model.class.name,
        validation_errors: model.errors.full_messages,
        invalid_attributes: model.errors.keys
      ))
    end
  end
  
  def log_api_error(response, request_context = {})
    @logger.error('API request failed', request_context.merge(
      status_code: response.status,
      response_body: response.body,
      headers: response.headers.to_hash
    ))
  end
end

# HTTP リクエストのログ記録
class HttpLogger
  def initialize
    @logger = Ougai::Logger.new($stdout).child(component: 'HTTP')
  end
  
  def log_request(request)
    @logger.info('HTTP request started', {
      method: request.method,
      path: request.path,
      remote_ip: request.remote_ip,
      user_agent: request.user_agent,
      params: sanitize_params(request.params),
      session_id: request.session.id
    })
  end
  
  def log_response(response, duration)
    level = response.status >= 400 ? :error : :info
    
    @logger.send(level, 'HTTP request completed', {
      status: response.status,
      content_type: response.content_type,
      content_length: response.body.length,
      duration_ms: (duration * 1000).round(2)
    })
  end
  
  private
  
  def sanitize_params(params)
    # 機密情報をフィルタリング
    filtered = params.deep_dup
    filtered.delete('password')
    filtered.delete('password_confirmation')
    filtered.delete('token')
    filtered
  end
end

# データベースクエリのログ記録
class DatabaseLogger
  def initialize
    @logger = Ougai::Logger.new($stdout).child(component: 'Database')
  end
  
  def log_query(sql, duration, result_count = nil)
    log_level = determine_log_level(duration)
    
    data = {
      sql: sql,
      duration_ms: duration.round(2),
      slow_query: duration > 1000
    }
    data[:result_count] = result_count if result_count
    
    @logger.send(log_level, 'Database query executed', data)
  end
  
  def log_transaction(operation, &block)
    start_time = Time.now
    @logger.debug('Transaction started', operation: operation)
    
    result = yield
    duration = Time.now - start_time
    
    @logger.info('Transaction completed', {
      operation: operation,
      duration_seconds: duration.round(3),
      status: 'committed'
    })
    
    result
  rescue StandardError => e
    duration = Time.now - start_time
    @logger.error('Transaction failed', {
      operation: operation,
      duration_seconds: duration.round(3),
      status: 'rolled_back',
      error: e.message
    })
    raise
  end
  
  private
  
  def determine_log_level(duration_ms)
    case duration_ms
    when 0..100
      :debug
    when 100..500
      :info
    when 500..1000
      :warn
    else
      :error
    end
  end
end

実用例

require 'ougai'

# Rails アプリケーションでの統合例
class ApplicationController < ActionController::Base
  before_action :setup_request_logging
  after_action :log_request_completion
  
  private
  
  def setup_request_logging
    @request_start = Time.now
    @request_id = SecureRandom.uuid
    
    # リクエストごとのロガーを作成
    @logger = Rails.logger.child(
      request_id: @request_id,
      controller: controller_name,
      action: action_name
    )
    
    @logger.info('Request started', {
      method: request.method,
      path: request.path,
      remote_ip: request.remote_ip,
      user_agent: request.user_agent,
      user_id: current_user&.id
    })
  end
  
  def log_request_completion
    duration = Time.now - @request_start
    
    @logger.info('Request completed', {
      status: response.status,
      duration_ms: (duration * 1000).round(2),
      allocations: GC.stat[:total_allocated_objects] - @initial_allocations
    })
  end
end

# バックグラウンドジョブのログ記録
class BaseJob < ApplicationJob
  def perform(*args)
    job_logger = Rails.logger.child(
      job_class: self.class.name,
      job_id: job_id,
      queue: queue_name
    )
    
    job_logger.info('Job started', {
      arguments: args,
      scheduled_at: scheduled_at,
      attempts: executions
    })
    
    start_time = Time.now
    
    begin
      result = super(*args)
      duration = Time.now - start_time
      
      job_logger.info('Job completed successfully', {
        duration_seconds: duration.round(3),
        result: result
      })
      
      result
    rescue StandardError => e
      duration = Time.now - start_time
      
      job_logger.error('Job failed', {
        duration_seconds: duration.round(3),
        error_class: e.class.name,
        error_message: e.message,
        backtrace: e.backtrace&.first(5)
      })
      
      raise
    end
  end
end

# API クライアントのログ記録
class ExternalApiClient
  def initialize(base_url)
    @base_url = base_url
    @logger = Rails.logger.child(component: 'ExternalApiClient')
    @http = Net::HTTP.new(URI(base_url).host, URI(base_url).port)
  end
  
  def get(path, params = {})
    make_request(:get, path, params)
  end
  
  def post(path, data = {})
    make_request(:post, path, data)
  end
  
  private
  
  def make_request(method, path, data = {})
    request_id = SecureRandom.uuid
    start_time = Time.now
    
    @logger.info('External API request started', {
      request_id: request_id,
      method: method.upcase,
      url: "#{@base_url}#{path}",
      data_size: data.to_json.bytesize
    })
    
    # HTTPリクエスト実行
    response = execute_http_request(method, path, data)
    duration = Time.now - start_time
    
    log_level = response.code.to_i >= 400 ? :error : :info
    
    @logger.send(log_level, 'External API request completed', {
      request_id: request_id,
      status_code: response.code.to_i,
      response_size: response.body.bytesize,
      duration_ms: (duration * 1000).round(2),
      success: response.code.to_i < 400
    })
    
    JSON.parse(response.body)
  rescue StandardError => e
    duration = Time.now - start_time
    
    @logger.error('External API request failed', {
      request_id: request_id,
      duration_ms: (duration * 1000).round(2),
      error_class: e.class.name,
      error_message: e.message
    })
    
    raise
  end
  
  def execute_http_request(method, path, data)
    # 実際のHTTPリクエスト実装
    case method
    when :get
      @http.get(path)
    when :post
      request = Net::HTTP::Post.new(path)
      request.body = data.to_json
      request['Content-Type'] = 'application/json'
      @http.request(request)
    end
  end
end

# メトリクス収集とログ記録
class MetricsLogger
  def initialize
    @logger = Rails.logger.child(component: 'Metrics')
  end
  
  def log_business_metric(metric_name, value, dimensions = {})
    @logger.info('Business metric recorded', {
      metric_name: metric_name,
      value: value,
      dimensions: dimensions,
      recorded_at: Time.now.iso8601
    })
  end
  
  def log_system_metric(metric_name, value)
    @logger.debug('System metric recorded', {
      metric_name: metric_name,
      value: value,
      recorded_at: Time.now.iso8601
    })
  end
end

# 使用例
metrics_logger = MetricsLogger.new

# ビジネスメトリクス
metrics_logger.log_business_metric('user_registration', 1, {
  source: 'web',
  plan: 'premium',
  country: 'JP'
})

# システムメトリクス
metrics_logger.log_system_metric('memory_usage_mb', 512)
metrics_logger.log_system_metric('response_time_ms', 150)