🍙

Sentry Logsで実現するRailsアプリの構造化ロギング

に公開

はじめに

Railsアプリケーションの運用において、ログの管理は問題発生時のデバッグやサービス監視のために重要な構成要素です。しかし、従来のテキストベースのログでは、エラー発生時に複数のツールを行き来しながらログを検索し、原因を特定する必要がありました。

この記事では、Sentry Logs と ActiveSupport::Notifications を組み合わせて、構造化ロギングを実現する方法を解説します。実際のプロジェクトで導入した実装パターンを紹介し、ハマったポイントと解決策も共有します。

この記事で得られること

  • Sentry Logsの基本的な使い方
  • ActiveSupport::Notificationsを活用した構造化ロギングの実装方法
  • ServiceクラスにおけるLogSubscriberの実装パターン
  • 実運用で遭遇した問題と解決策

前提条件

  • Ruby on Rails 8.0
  • ActiveSupport::Notificationsの基本的な知識

Sentry Logsとは

Sentry Logsは、従来のログ管理ツールとは異なり、エラー、トレース、リプレイなど他のテレメトリーと統合されたログ管理機能です。

https://sentry.ichizoku.io/blog/logs-generally-available/

主要機能

ライブテーリング
ログをリアルタイムにストリーミングし、デプロイ後の動作確認や長時間実行されるジョブの監視ができます。

アラート機能
特定のパターン(支払い失敗、異常な処理時間など)に基づいてアラートを発火し、ユーザーからの報告前に問題を検知できます。

ダッシュボード
ログの傾向を時系列で可視化し、特定の条件下でのエラー率上昇や新機能に関連するスパイクを確認できます。

最大の特徴

Sentry Logsの最大の特徴は、ログが他のテレメトリーと統合されることです。エラー発生時に、そのエラーに関連するログを同じ画面で確認でき、複数ツール間のタブ切り替えが不要になります。

利用条件と料金

すべてのSentryユーザーは月5GBを無料で利用でき、追加分は1GBあたり$0.50です。30日間のログ保持期間が含まれます。

また、Businessプランでは追加で13ヶ月のサンプリング保持もあります。

参考:

AWS CloudWatch Logs(東京リージョン)との比較

参考として、AWS CloudWatch Logsの東京リージョンとの料金比較を示します:

サービス 無料枠 料金 備考
Sentry Logs 月5GB $0.50/GB 30日保持含む
CloudWatch Logs(標準) 月5GB 取込: $0.76/GB
保存: $0.033/GB
-
CloudWatch Logs(低頻度) 月5GB 取込: $0.38/GB
保存: $0.033/GB
-

参考: 料金 - Amazon CloudWatch | AWS

関連ドキュメント:

Railsでの基本的な使い方

セットアップ

Gemfileに追加して、初期化時にenable_logstrueに設定します。

# Gemfile
gem 'sentry-ruby'
gem 'sentry-rails'
# config/initializers/sentry.rb
Sentry.init do |config|
  config.dsn = ENV['SENTRY_DSN']
  # 構造化ロギングを有効化
  config.enable_logs = true
  config.rails.structured_logging.enabled = true

  # その他の設定...
end

基本的なAPI

明示的にログを送信する場合は、Sentry.logger APIを使用します。

# 基本的な使用例
Sentry.logger.info("ユーザーがログイン")
Sentry.logger.error("支払い処理失敗", order_id: "or_2342")

# 構造化データを含む
Sentry.logger.info(
  "インポート処理完了",
  {
    supply_area_id: 1,
    processed_count: 1000,
    duration_ms: 3500
  }
)

以下のように記録されます。

Logs

渡した属性でフィルタリングできます。

フィルタリング

自動キャプチャ

Railsフレームワークイベント(ActiveRecordクエリ、コントローラアクションなど)も自動的にキャプチャされます。ただし、デフォルトではActiveRecordとActionControllerのみが有効です。

config/initializers/sentry.rb
Sentry.init do |config|
  # 省略

  config.enable_logs = true
  config.rails.structured_logging.enabled = true
  config.rails.structured_logging.subscribers = {
    active_record: Sentry::Rails::LogSubscribers::ActiveRecordSubscriber,
    action_controller: Sentry::Rails::LogSubscribers::ActionControllerSubscriber,
    active_job: Sentry::Rails::LogSubscribers::ActiveJobSubscriber,       # 追加
    action_mailer: Sentry::Rails::LogSubscribers::ActionMailerSubscriber  # 追加
  }
end

ActionControllerのみ有効にするなら

config/initializers/sentry.rb
Sentry.init do |config|
  # 省略

  config.enable_logs = true
  config.rails.structured_logging.enabled = true
  config.rails.structured_logging.subscribers = {
    action_controller: Sentry::Rails::LogSubscribers::ActionControllerSubscriber,
  }
end

データ保護

Railsのfilter_parameters設定が自動的に適用され、パスワードやクレジットカード番号などの機密データがフィルタリングされます。

config/initializers/filter_parameter_logging.rb
Rails.application.config.filter_parameters += [
  :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc
]

実践: ApplicationServiceでの構造化ロギング実装

ここからは、実際のプロジェクトでServiceクラスに構造化ロギングを導入した実装パターンを紹介します。

設計方針

Serviceクラスの実行状況をSentry Logsで記録する際、以下の方針で設計しました:

  1. 記録すべき情報を明確にする

    • サービスの実行開始(perform)
    • サービスの成功(success)
    • サービスの失敗(failure)
    • サービスの進捗報告(report)
  2. ActiveSupport::Notificationsの活用

    Sentry Logsのサブスクライバ Sentry::Rails::LogSubscriber はRails標準のActiveSupport::LogSubscriberを継承して作られています。ActiveSupport::LogSubscriberActiveSupport::Notifications を利用してログ出力するための仕組みで、通知イベントが発行されると自動的に対応するメソッドが呼び出されます。

    この標準的な仕組みを使用することで、以下の利点があります:

    • Railsの標準パターンに従った実装
    • ロギングロジックとビジネスロジックの分離
    • Sentry::Rails::LogSubscriberとの自然な統合

    参考: ActiveSupport::LogSubscriber - Rails API

  3. 機密情報のフィルタリング

    • Sentry::Rails::LogSubscribers::ParameterFilterを使用
    • Railsのfilter_parameters設定を自動適用

ApplicationServiceの拡張

まず、基底クラスのApplicationServiceで、ActiveSupport::Notificationsでイベントを発行するようにします。

app/services/application_service.rb
class ApplicationService
  include ActiveModel::Model
  include ActiveModel::Attributes
  include ActiveModel::Validations

  Result = Data.define(:success, :data, :errors)

  class << self
    def perform(**attributes, &block)
      payload = {
        service_class: self,
        attributes: attributes
      }
      service = new(**attributes)
      ActiveSupport::Notifications.instrument("perform.application_service", payload) do |payload|
        if service.valid?
          service.perform(&block).tap do |result|
            payload[:result] = result
          end
        else
          payload[:result] = Result.new(success: false, data: nil, errors: service.errors)
        end
      end
    end

    protected :new
  end

  protected

  def perform(&block)
    raise NoMethodError, "The `call` method must be implemented in #{self.class.name}"
  end

  private

  def success!(**data)
    Result.new(success: true, data:, errors:)
  end

  def fail!(**data)
    Result.new(success: false, data:, errors:)
  end

  def info(**data)
    ActiveSupport::Notifications.instrument(
      "info.application_service",
      service_class: self.class,
      attributes: attributes,
      data: data
    )
  end
end

サービスクラスの実装例

基底クラスにロギング用のロジックを実装したので、いくつかのルールに則ってサブクラスを実装することで、ロギングについて意識する必要はなくなります。

  • ロジックを perform メソッドに置く
  • 途中経過を info メソッドで伝える
  • 成功または失敗時に success! または fail! で結果を返す
app/services/net_position/calculation_service.rb
class NetPosition::CalculationService < ApplicationService
  attribute :supply_area_id, :integer
  attribute :target_month, :date

  def perform
    ApplicationRecord.transaction do
      demand_amount = DemandCalculator.new(supply_area_id, target_month).calculate
      info(message: "需要量を取得", demand_amount:)
      trade_amount = TradeCalculator.new(supply_area_id, target_month).calculate
      info(message: "取引量を取得", trade_amount:)
      futures_amount = FuturesCalculator.nwe(supply_area_id, target_month).calculate
      info(message: "建玉量を取得", futures_amount:)

      net_amount = demand_amount - trade_amount - futures_amount

      save_calculation_history(
        demand_amount: demand_amount,
        trade_amount: trade_amount,
        futures_amount: futures_amount,
        net_amount: net_amount
      )

      success!(net_amount: net_amount)
    end
  rescue => e
    errors.add :base, e.message
    Sentry.capture_exception(e)
    fail!
  end

  # その他のプライベートメソッド...
end

LogSubscriberの実装

次に、ActiveSupport::Notificationsのイベントを購読して、Sentry Logsに送信するLogSubscriberを実装します。

lib/log_subscribers/application_service_subscriber.rb
require "sentry/rails/log_subscriber"
require "sentry/rails/log_subscribers/parameter_filter"

module LogSubscribers
  class ApplicationServiceSubscriber < Sentry::Rails::LogSubscriber
    include Sentry::Rails::LogSubscribers::ParameterFilter # filter_sensitive_params

    # Handle perform.application_service events
    def perform(event)
      service_class = event.payload[:service_class]
      return unless service_class
      return unless service_class < ApplicationService

      message = "Service performed: #{service_class.name}"
      duration = duration_ms(event)
      attributes = event.payload[:attributes]
      result = event.payload[:result]

      attributes = {
        duration_ms: duration,
        service_class: service_class.name,
        # IMPORTANT: 値として Array や Hash は直接渡すと Sentry Logs 上で表示できないので文字列にした
        arguments: filter_sensitive_params(attributes).to_json,
        success: result.success,
        result: filter_sensitive_params(result.data).to_json,
        errors: result.errors.full_messages.join("\n")
      }

      log_structured_event(
        message: message,
        level: :info,
        attributes: attributes
      )
    end

    # Handle report.application_service events
    def info(event)
      service_class = event.payload[:service_class]
      return unless service_class
      return unless service_class < ApplicationService

      attributes = event.payload[:attributes]
      data = event.payload[:data]

      message = "Service performed: #{service_class.name}"
      attributes = {
        service_class: service_class.name,
        arguments: filter_sensitive_params(attributes).to_json,
        data: filter_sensitive_params(data).to_json
      }

      log_structured_event(
        message: message,
        level: :info,
        attributes: attributes
      )
    end
  end
end

Subscriberの登録

LogSubscriberを登録します。

方法はいくつかありますが、今回は Sentry SDK の設定で登録します。

config/sentry.rb
require "log_subscribers/application_service_subscriber"

Sentry.init do |config|
  # 省略

  config.enable_logs = true
  config.rails.structured_logging.enabled = true
  config.rails.structured_logging.subscribers = {
    active_record: Sentry::Rails::LogSubscribers::ActiveRecordSubscriber,
    action_controller: Sentry::Rails::LogSubscribers::ActionControllerSubscriber,
    active_job: Sentry::Rails::LogSubscribers::ActiveJobSubscriber,
    action_mailer: Sentry::Rails::LogSubscribers::ActionMailerSubscriber,
    # カスタムサブスクライバ
    application_service: ::LogSubscribers::ApplicationServiceSubscriber
  }

subscribers ハッシュに追加することで、 Sentry::Rails::StructuredLogging によって適切なタイミングで attach_to されます。

実際に記録されたログ

実際に記録されたログ

フィルタリング

属性を使ってフィルタリングできます。

フィルタリング設定例

Issue画面でログを確認できる

エラーをSentryに送っていると、トランザクション内のログをあわせて確認できます。

Issue画面でログを確認できる

ログを条件にアラートを設定できる

フィルタリング条件にマッチするログの出現を報告するアラートを設定することができます。

ログの属性を使ってアラートを設定できる

実装時の注意点と解決策

実装中に遭遇した問題と解決策を紹介します。

問題: ArrayやHashのパラメータが記録されない

当初、以下のようにattributesを設定していました。

# 問題のあるコード: HashやArrayが無視される
attributes = {
  duration_ms: duration,
  service_class: service_class.name,
  args: filter_sensitive_arguments(args), # Array
  opts: filter_sensitive_params(opts)     # Hash
}

しかし、SentryのLogsでは、argsoptsが記録されませんでした。

原因

Sentryのattributesは String や Number などの型を想定しており、構造を持ったArrayやHashをそのまま渡すと無視されます。

解決策

to_json するなどして文字列にします。

# 改善後: JSON化することで解決
attributes = {
  duration_ms: duration,
  service: service.class.name,
  args: filter_sensitive_arguments(args).to_json,
  opts: filter_sensitive_params(opts).to_json
}

問題: ActiveRecordSubscriberによる容量の急増

Sentry::Rails::LogSubscribers::ActiveRecordSubscriber を有効にすると、すべてのSQLクエリが構造化ログとして記録されます。これにより、ログの容量が急激に増大し、Sentry Logsの月間容量制限やコストに大きな影響を与える可能性があります。

# 問題のある設定: すべてのクエリがログに記録される
config.rails.structured_logging.subscribers = {
  active_record: Sentry::Rails::LogSubscribers::ActiveRecordSubscriber,  # すべてのクエリを記録
  action_controller: Sentry::Rails::LogSubscribers::ActionControllerSubscriber,
  # ...
}

原因

ActiveRecordSubscriberは、すべてのデータベースクエリに対してログイベントを発行します。アプリケーションの規模によっては、大量のクエリが実行されることもあり、ログの容量が想定以上に増加します。

また、問題のあるクエリのログはSentry Tracingなどの他の機能ですでに確認できる場合が多く、重複してログを記録する必要性が低いケースもあります。

解決策

必要なサブスクライバのみを有効にします。クエリのログが不要な場合は、ActiveRecordSubscriberを無効にします。

# 改善後: 必要なサブスクライバのみ有効化
config.rails.structured_logging.subscribers = {
  # active_record: Sentry::Rails::LogSubscribers::ActiveRecordSubscriber,
  # action_controller: Sentry::Rails::LogSubscribers::ActionControllerSubscriber,
  # active_job: Sentry::Rails::LogSubscribers::ActiveJobSubscriber,
  # action_mailer: Sentry::Rails::LogSubscribers::ActionMailerSubscriber,
  application_service: ::LogSubscribers::ApplicationServiceSubscriber
}

用途とコストを考慮して、適切なサブスクライバを選択することが重要です。

あるいは本番環境では無効、ステージング、開発では有効という設定もよいと思います。

問題: call.component というイベントが拾えない

たとえば call というイベントを call.application_service という名前で購読しようとしても、 ApplicationServiceSubscriber#call というメソッドでは拾うことができませんでした。

原因

ActiveSupport::LogSubscriber#call はすでに存在するため、オーバーライドになってしまう。

解決策

call というイベント名は使用しない。

実際の運用で得られた効果

構造化ロギングを導入することで、以下の効果が得られました。

デバッグ時間の短縮

エラーとログが同じ画面で確認できるため、複数ツール間のタブ切り替えが不要になりました。エラー発生時に、そのエラーに関連する実行ログを即座に確認できます。

また、プレーン文字列やJSON形式のログに比べて、ログの内容を読み取りやすく、追いやすくなりました。

問題の早期発見

アラート機能を活用することで、特定のServiceクラスの失敗率が上昇した際に通知を受け取れます。ユーザーからの報告前に問題を検知できるようになりました。

パフォーマンス分析の容易さ

Sentry Tracing とあわせて、ログにduration_msを記録することで、どのServiceがどの程度時間がかかっているのか一目でわかるので、ボトルネックの特定に役立ちます。

ロギング方法の共通化

ロギングの「普通のやり方」を決めておくことで、ログ設計と実装のルールを作りやすくなります。

まとめ

Sentry LogsとActiveSupport::Notificationsを組み合わせることで、Railsアプリケーションに構造化ロギングを導入できました。

主な利点

  • エラーとログの統合により、デバッグ効率が向上
  • ActiveSupport::Notificationsによる疎結合な設計
  • Railsのfilter_parameters設定による機密情報の自動フィルタリング
  • リアルタイムでのログ監視とアラート

今後の展開

導入後は、以下の展開を検討できます:

  1. アラート設定の最適化: 重要な処理に対して適切なアラートを設定
  2. ダッシュボードの作成: よく使用するクエリをダッシュボード化
  3. パフォーマンス監視: duration_msを活用した継続的なパフォーマンス監視

構造化ロギングは、アプリケーションの可観測性を大きく向上させます。Sentry Logsを活用することで、開発チーム全体で効率的に問題を発見・解決できる環境を構築できました。

タケユー・ウェブ株式会社

Discussion