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