🍣

bugsnag gem のコードからクロージャを使った Rails における実践的な遅延実行の実装をマスターする

2024/12/27に公開

masaki です。
弊社では Ruby on Rails を使ったアプリケーションを開発し、バグ検知サービスに BugSnag を利用しています。今日は、Ruby のライブラリである bugsnag gem の before_bugsnag_notify がどのように動作しているか、実装を読み解きながらご紹介します。
before_bugsnag_notify の内部には「クロージャを使った遅延実行」が取り入れられており、実践的な実装テクニックを学ぶことができます。ぜひ最後までお読みください。

このブログの対象者

  • Rails アプリケーションを開発している方
  • 遅延実行の実践的な実装テクニックを知りたい方

検証時のバージョン

  • Rails 7.1 系
  • Ruby 3.2 系
  • bugsnag gem 6.27.1

before_bugsnag_notify とは

before_bugsnag_notify は、Rails アプリケーションのコントローラー内に定義することで、エラー発生時の BugSnag 通知にカスタムデータを追加できるクラスメソッドです。
公式ドキュメント: Updating events using callbacks

サンプルコード

以下は「指定したユーザーの名前を更新する」処理に before_bugsnag_notify を組み込んだ例です。

class UsersController < ApplicationController
  before_bugsnag_notify :add_user_info_to_bugsnag
  before_action :set_user

  def update
    @user.update!(name: params[:name])
    render status: :created
  end

  private

  def add_user_info_to_bugsnag(report)
    report.user = { id: @user.id }
  end

  def set_user
    @user = User.find_by!(identifier: params[:identifier])
  end
end
class ApplicationController < ActionController::API
  include Bugsnag::Rails

  rescue_from ActiveRecord::RecordInvalid do |exception|
    Bugsnag.notify(exception)
    render status: :bad_request
  end
end

上記では、名前更新が不正だった場合に ActiveRecord::RecordInvalid が raise され、Bugsnag.notify(exception) によって BugSnag へ通知されます。このとき before_bugsnag_notify :add_user_info_to_bugsnag の効果で、「どのユーザーでエラーが発生したか」という情報が BugSnag 通知に追加されます。

どこで遅延実行が行われているのか

実装上の疑問点としては、以下の部分が挙げられます。

before_bugsnag_notify :add_user_info_to_bugsnag
before_action :set_user

def add_user_info_to_bugsnag(report)
  report.user = { id: @user.id }
end

def set_user
  @user = User.find_by!(identifier: params[:identifier])
end

Rails では定義順に実行されるため「add_user_info_to_bugsnagset_user」となります。一見すると、

add_user_info_to_bugsnag@user.id にアクセスするのに、その前に @user が初期化されてないのでは?」

と思われるかもしれません。しかし実際にはエラーなく動作し、期待どおり @user の情報が BugSnag に通知されます。
これは内部ではクロージャを利用した「遅延実行」が行われており、add_user_info_to_bugsnag 内の @user.id が通知タイミングの直前に呼び出されるためです。ここからは bugsnag gem の実装を紐解き、どのように遅延実行を実現しているかを見ていきましょう。

bugsnag gem の実装を追う

bugsnag gem の before_bugsnag_notify 実装は以下のようになっています。

https://github.com/bugsnag/bugsnag-ruby/blob/54acb0d1a5ff1bab326facd61ff96f9590bc16ba/lib/bugsnag/integrations/rails/controller_methods.rb#L10-L38

ざっくりとした流れは以下のとおりです。

  1. 10~12 行目: _add_bugsnag_notify_callback というプライベートメソッドに :before_callbacks を追加して呼び出す
  2. 15 行目: before_bugsnag_notify に定義したオブジェクトが Hash だった場合は、options インスタンスに代入する
  3. 17~32 行目: Rails の before_action に渡す lambda ブロックの作成
  4. 33~37 行目: 3 で作成した lambda ブロックを Rails の before_action にセットする

重要なのは 17~32 行目33~37 行目 です。次のセクションで詳しく見ていきます。

17~32 行目のポイント

該当部分の抜粋です(コメントは便宜上追加)。

action = lambda do |controller| # 外側の lambda
  request_data = Bugsnag.configuration.request_data
  request_data[callback_key] ||= []

  methods.each do |method_symbol|
    request_data[callback_key] << lambda { |notification| # 内側の lambda
      controller.send(method_symbol, notification)
    }
  end

  if block_given?
    request_data[callback_key] << lambda { |notification| # 内側の lambda
      controller.instance_exec(notification, &block)
    }
  end
end

以下の処理内容となっています。

  • Rails の before_action に渡す lambda ブロックを作成
  • 上記lambda ブロックの内側で更に lambda ブロックを作成し、Bugsnag クラスのコールバック(:before_callbacks)にセットする

内側の lambda の処理は以下です。

      controller.send(method_symbol, notification)

これは、冒頭のサンプルコードに置き換えると以下を処理していることになります。

      {UsersController のインスタンス}.send(:add_user_info_to_bugsnag, notification)

この controllermethod_symbol は、それぞれ外側の lambda の引数や _add_bugsnag_notify_callback メソッドの引数として渡された変数です。

クロージャを使った遅延実行

ここで重要なのが、クロージャ の仕組みです。Ruby の lambda やブロックは、定義時点のスコープをキャプチャ(保持)します。たとえば以下のようなシンプルな例があります。

def generate_counter
  count = 0
  lambda do
    count += 1
  end
end

counter = generate_counter
puts counter.call # => 1
puts counter.call # => 2
puts counter.call # => 3

この場合、generate_counter 内で定義された変数 countlambda でキャプチャされ、後から呼び出しても変数の状態を保持し続けています。

bugsnag のコードでも同様に、外側の lambda 内で定義した controllermethod_symbol がキャプチャされ、後で実行するときにこれらの変数を利用できます。これが結果として「BugSnag の通知タイミングで controller が持つインスタンス変数(@user など)を参照できる」ことにつながっています。

33~37 行目のポイント

最後に、先ほどの 17~32 行目で作成した action(外側の lambda)を Rails の before_action に渡す処理が以下で行われています。

if respond_to?(:before_action)
  before_action(options, &action)
else
  before_filter(options, &action)
end

これにより、Rails の仕組みとして controller が利用可能となり、結果的に @user などの値を正常に参照できるわけです。

全体の処理の流れ

以上を踏まえて、冒頭のサンプルコードの処理順をまとめると以下のとおりになります。

  1. before_bugsnag_notify :add_user_info_to_bugsnag が呼び出される
    • controller.send(:add_user_info_to_bugsnag) をキャプチャした lambdaBugsnag のコールバックにセットされる
  2. before_action :set_user が実行される
    • set_user が実行され、@user が初期化される
  3. ユーザー更新時に ActiveRecord::RecordInvalid が発生する
  4. Bugsnag.notify(exception) が呼び出される
    • Bugsnag のコールバックにセットされた controller.send(:add_user_info_to_bugsnag) が実行される
    • 「2」で初期化された @user が BugSnag 通知に追加される

「実際の実行タイミングがどうなっているか」 がわかれば、before_bugsnag_notify で定義したメソッド内で @user が使える仕組みに納得できるはずです。

簡易コードで確認してみる

「説明を聞いてもピンとこない」「実際に手を動かしてみたい」という方もいるでしょう。そこで、BugSnag や Rails の機能を完全には再現していませんが、同様の仕組みを再現した簡易コードを用意しました。

class TestNotify
  class << self
    attr_reader :callbacks

    def notify(exception = nil)
      report = {}
      callbacks.each do |callback|
        callback.call(report)
      end
      notification = {
        exception: exception,
        report: report
      }
      puts "notification: #{notification}"
    end

    def set_callback(callback)
      @callbacks ||= []
      @callbacks << callback
    end
  end
end

module TestNotifyModule
  def self.included(base)
    base.extend ClassMethods
  end

  module ClassMethods
    private

    def before_notify(*methods)
      action = lambda do |controller|
        methods.each do |method_symbol|
          callback = lambda { |notification|
            controller.send(method_symbol, notification)
          }
          TestNotify.set_callback(callback)
        end
      end
      before_action(&action)
    end
  end
end

class BaseController
  class << self
    def before_action(method_name = nil, &block)
      @before_actions ||= []
      @before_actions << (block || method_name)
    end

    attr_reader :before_actions
  end

  def run_action(action_name, params = {})
    @params = params
    self.class.before_actions.each do |callback|
      if callback.is_a?(Proc)
        callback.call(self)
      else
        send(callback)
      end
    end

    public_send(action_name)
  end

  private

  attr_reader :params
end

class SimpleController < BaseController
  include TestNotifyModule

  before_notify :notify_user
  before_action :set_user

  def show
    puts "action: :show, user: #{@user}"
    raise if @user == 'dhh'
  rescue StandardError => e
    TestNotify.notify(e)
  end

  private

  def notify_user(report)
    report[:user] = @user
  end

  def set_user
    @user = params[:user]
  end
end

params = { user: ARGV[0] }
SimpleController.new.run_action(:show, params)

実行例:

$ ruby main.rb dhh
action: :show, user: dhh
notification: {:exception=>RuntimeError, :report=>{:user=>"dhh"}}

これは BugSnag や Rails のコードを簡略化したものですが、「before_action 相当の処理で @user が設定され、例外発生時に遅延的に @user 情報を参照する」仕組みを確認できます。

まとめ

  • before_bugsnag_notify で定義したメソッドは、あくまでも Bugsnag.notify が呼ばれる直前に実行される
  • 実装内部では クロージャ を活用して、コントローラーインスタンスやメソッドシンボルを「あとから呼び出す」ための処理が登録されている
  • その結果、before_action などで設定したインスタンス変数を利用できる

このように一見複雑に見える処理も、内部を覗くと「Rails の仕組み + クロージャを活用した遅延実行」であるとわかります。
「エラー通知時に、あとから動的にデータを付け加える必要がある」ケースは実務でもよくあるため、こうしたテクニックを押さえておくと役立つシーンがあるかもしれません。ぜひ参考にしてみてください。

SocialPLUS Tech Blog

Discussion