bugsnag gem のコードからクロージャを使った Rails における実践的な遅延実行の実装をマスターする
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_bugsnag
→ set_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
実装は以下のようになっています。
ざっくりとした流れは以下のとおりです。
-
10~12 行目:
_add_bugsnag_notify_callback
というプライベートメソッドに:before_callbacks
を追加して呼び出す -
15 行目:
before_bugsnag_notify
に定義したオブジェクトがHash
だった場合は、options
インスタンスに代入する -
17~32 行目: Rails の
before_action
に渡すlambda
ブロックの作成 -
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)
この controller
や method_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
内で定義された変数 count
は lambda
でキャプチャされ、後から呼び出しても変数の状態を保持し続けています。
bugsnag
のコードでも同様に、外側の lambda
内で定義した controller
や method_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
などの値を正常に参照できるわけです。
全体の処理の流れ
以上を踏まえて、冒頭のサンプルコードの処理順をまとめると以下のとおりになります。
-
before_bugsnag_notify :add_user_info_to_bugsnag
が呼び出される-
controller.send(:add_user_info_to_bugsnag)
をキャプチャしたlambda
がBugsnag
のコールバックにセットされる
-
-
before_action :set_user
が実行される-
set_user
が実行され、@user
が初期化される
-
- ユーザー更新時に
ActiveRecord::RecordInvalid
が発生する -
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 の仕組み + クロージャを活用した遅延実行」であるとわかります。
「エラー通知時に、あとから動的にデータを付け加える必要がある」ケースは実務でもよくあるため、こうしたテクニックを押さえておくと役立つシーンがあるかもしれません。ぜひ参考にしてみてください。
Discussion