"Sentry"、使っていますか?
去る 2021-11-11 にリリースされた sentry-ruby 4.8.0 gem に "exception frame's local variable capturing" という機能が導入されました。良い感じの和訳が思いついてないのですが本記事ではローカル変数キャプチャ機能と呼称します。
これは例外発生箇所のローカル変数を Sentry Web UI で確認できる便利デバグ機能であるだけでなく TracePoint
の cool な活用事例でもあります。せっかくなので機能の説明だけでなく、どのようにこの機能が実現されているのか、コードリーディングしつつ解説を試みます。
"exception frame's local variable capturing" とは?
前置き / 用語整理
機能を説明する前にざっくりと用語を整理します。
exception
: 例外local variable
: ローカル変数capturing
: 捕まえる、捕捉する、記録する
frame
だけ若干聞き慣れないかと思うので詳説します。
まず、例外のスタックトレース (Exception#backtrace
) は例外発生箇所にいたるまでの呼び出し元 (caller
の String
表現) の配列です。Ruby プログラムを書いている方なら以下のように整形された表現を見ることも多いでしょう。
irb(main):001:0> def foo; raise 'bye'; end => :foo irb(main):002:0> foo (irb):1:in `foo': bye (RuntimeError) from (irb):2:in `<main>' from /Users/m-ohba/.rbenv/versions/3.0.1/lib/ruby/gems/3.0.0/gems/irb-1.3.5/exe/irb:11:in `<top (required)>' from /Users/m-ohba/.rbenv/versions/3.0.1/bin/irb:23:in `load' from /Users/m-ohba/.rbenv/versions/3.0.1/bin/irb:23:in `<main>'
例外から直接 backtrace
を参照するとよりプリミティブな姿が見えます。
irb(main):003:0> begin; foo; rescue => e; e.backtrace; end => ["(irb):1:in `foo'", "(irb):3:in `<main>'", # 以下省略 ]
この配列の要素 1 つ 1 つのことを frame
と呼んでいます。
sentry-ruby
gem には Sentry::StackTraceInterface::Frame
class が存在し、例外の backtrace
を parse して frame
を表現しています。このframe
に詰められた情報が Sentry Web UI では以下のように見えるわけです。
frame
が積層されている様子
ローカル変数キャプチャ機能とは?
この機能の概要を https://github.com/getsentry/sentry-ruby/pull/1580 よりかいつまんで述べます。
同機能は例外が発生した箇所のローカル変数をキャプチャして Sentry に送信してくれる便利機能です。たとえば以下のようなスクリプトで例外が発生したとします。
def foo a = 1 b = 0 request = self.request a / b #=> This would cause ZeroDivisionError end
例外発生箇所におけるローカル変数は a
, b
, request
であり、これらが Sentry の Web UI で確認できるようになります。
これはシンプルな例ですが、実際のアプリケーションではデバグが捗る有用な手がかりになりえます。
📝 ちなみに Sentry に送信される各ローカル変数の値は Object#inspect
の戻り値です。1024 bytes 以上の長さの場合は truncate されます。
使う、いますぐ
capture_exception_frame_locals
に true
をセットすることで有効化できます。
Sentry.init do |config| config.capture_exception_frame_locals = true # default is false end
後述しますがこの機能の実現には TracePoint
を使っており、パフォーマンスに影響を与える可能性があるため 4.8.0 ではデフォルトでオフになっています。
sentry-ruby
Deep Dive
さて、便利なことがわかったところでイチ利用者としては満足なのですが、この機能はどのように実現されているのでしょうか。Rubyist なのでわたし、気になります。
その謎を解明するため、私は https://github.com/getsentry/sentry-ruby の奥地へと向かいました。
TracePoint
そこで出会ったのは Ruby 2.0 から導入されている TracePoint
でした。
TracePoint
について本記事では詳説しませんが、https://techlife.cookpad.com/entry/2018/12/27/093914#TracePoint-%E5%9F%BA%E7%A4%8E より引用します。
TracePoint は、プログラム中のイベントで起動する Proc を登録するための仕組みです。イベントには、毎行ごとに実行する line イベントや、メソッドの呼び出しと、そのリターンごとに実行する call、return などがあります。
Ruby プログラムで発生する様々なイベントにフックした処理/ハンドラが書けるという強い機能です。
TracePoint
でハンドラを登録できるイベントの 1 つに raise
があり、例外発生時にフックして任意の処理を呼び出すことができます。
"trick-タネ-"が見えてきたと思います。
TracePoint.new(:raise)
- 例外の発生にフック
実際の使い方を見てみます。
Sentry.exception_locals_tp
は以下のように実装されています。
def exception_locals_tp @exception_locals_tp ||= TracePoint.new(:raise) do |tp| exception = tp.raised_exception # don't collect locals again if the exception is re-raised next if exception.instance_variable_get(:@sentry_locals) next unless tp.binding locals = tp.binding.local_variables.each_with_object({}) do |local, result| result[local] = tp.binding.local_variable_get(local) end exception.instance_variable_set(:@sentry_locals, locals) end end
発生した例外の binding
が持つローカル変数の一覧 local_variables
を参照し、一つずつ Hash にセットしていきます。
それを例外インスタンス exception
のインスタンス変数 @sentry_locals
にセットしています。同じ例外が別の箇所で再度 raise
されたときにはすでにローカル変数がセット済なのでスキップするようになっています。
TracePoint#enable
- 有効化
Sentry.exception_locals_tp
では TracePoint
インスタンスを生成しているだけで、まだ有効化されていません。有効化しているのは Sentry.init
内です。
config.capture_exception_frame_locals
が truthy なときだけ TracePoint#enable
されることがわかります。
def init(&block) config = Configuration.new yield(config) if block_given? config.detect_release apply_patches(config) client = Client.new(config) scope = Scope.new(max_breadcrumbs: config.max_breadcrumbs) hub = Hub.new(client, scope) Thread.current.thread_variable_set(THREAD_LOCAL, hub) @main_hub = hub @background_worker = Sentry::BackgroundWorker.new(config) if config.capture_exception_frame_locals exception_locals_tp.enable end end
余談ですが、最初の実装では TracePoint
の有効化を Rack middleware でやっており、バックグラウンドジョブ等の Rack middleware を使わないアプリケーションではこのキャプチャ機能が使えませんでした。有効化箇所を Sentry.init
に持ってくることで恩恵を受けられる対象が大幅に広がりました。
Sentry に送信
コードリーディングの最後に、TracePoint
のフックによってインスタンス変数 @sentry_locals
にセットされた値がどのように整形されているかを見てみます。
実装箇所は Sentry::SingleExceptionInterface.build_with_stacktrace
になります。ここでは例外 exception
を Sentry のイベントに変換する処理を行っています。
def self.build_with_stacktrace(exception:, stacktrace_builder:) stacktrace = stacktrace_builder.build(backtrace: exception.backtrace) if locals = exception.instance_variable_get(:@sentry_locals) locals.each do |k, v| locals[k] = begin v = v.inspect unless v.is_a?(String) if v.length >= MAX_LOCAL_BYTES v = v.byteslice(0..MAX_LOCAL_BYTES - 1) + OMISSION_MARK end v rescue StandardError PROBLEMATIC_LOCAL_VALUE_REPLACEMENT end end stacktrace.frames.last.vars = locals end new(exception: exception, stacktrace: stacktrace) end
ループの中で @sentry_locals
の値を 1 つずつ inspect
し、MAX_LOCAL_BYTES
(= 1024
bytes) を超える文字列であれば "<超長い文字列>..."
のように truncate しています。もし処理を行えず StandardError
が起きるような値があった場合には PROBLEMATIC_LOCAL_VALUE_REPLACEMENT
(= "[ignored due to error]"
) に置き換えられます。
ループを抜けたところで最後のフレーム stacktrace.frames.last
の vars
にセットされた値が、Sentry Web UI で以下のように表示されることになります。
📝 スタックトレースのインタフェースについてはドキュメントで詳説されています。Sentry に送信する値をカスタマイズする日には大変参考になることでしょう。
ローカル変数キャプチャ機能の制約
仕組みを理解したので現時点で存在する以下の制約も理解できました。
キャプチャできるのは最後のフレームだけ
例外が発生した箇所、すなわち最後のフレームのローカル変数しか同機能はキャプチャしません。
Sentry に送信できる payload の最大長が 200KB なので全てのフレームでローカル変数を送るわけにはいかない、という事情のためと推察します。
ライブラリ内で例外が発生した場合は「ライブラリに自分のアプリケーションコードがどんな値を渡したのか?」=アプリケーション内の最後のフレームでのローカル変数を知りたいのが人情ですが、現状ではそれはできません。ライブラリ内の例外発生箇所のローカル変数のみがキャプチャされます。
アプリケーション内での最後の caller までスタックトレースを遡ってローカル変数を取得することも将来的にはできるようになるかもしれません。
パフォーマンス影響
一般に TracePoint
を有効化するとパフォーマンスが落ちます。その事実を示すベンチマークもそこかしこに見つかります。
デフォルトでオフになっている主な理由は experimental だからというだけでなく、パフォーマンス懸念が存在するためです。
しかしながら当機能ではフックするイベントが raise
1 つのみであり、"実世界-リアルワールド-"のアプリケーションではさほど影響がないという見解が https://github.com/getsentry/sentry-ruby/pull/1580 にベンチマーク付で載せてあります。
本当にパフォーマンス影響が無視できるレベルかどうかは、ぜひ君の目で確かめてほしい...といったところです。
センシティブデータの対処
当記事でリーディングしたコードを読む限り、ローカル変数に格納されたセンシティブなデータが Sentry に送信されうることに気付いた方もいるかもしれません。
Rails 6.0 以上なら Rails.application.config.filter_parameters
を設定することで、それ以前なら Sentry の before_send
フックを使うことでセンシティブデータのフィルタリングが可能です。公式ドキュメントを参考にご対応ください。
SmartBank での活用
最後に、弊社 SmartBank での Sentry 活用についてさらっと記述します。
SmartBank では Sentry を用いたエラー監視を行っています。ローカル変数キャプチャ機能については、上述の通り有用かつ面白いと感じたので一部の Rails アプリケーションで早速有効化してみました。
しかしながら実のところ記事公開時点ではまだ同機能の恩恵に預かれるような例外は発生していません。SmartBank では以下のように Sentry のエラー対処・運用をガッツリ行っているので、例外発生数自体もさほど多くないためです。
1. 日常に溶け込んだ Sentry と Slack の連携
例外発生時に Sentry からの通知を受け取る Slack channel があります。
日々エンジニアが目を光らせている…というと若干の誇張になりますが、平日に見慣れないエラーが通知されればおおよそ同日中には誰かしらが初動調査をします。
すぐに解決できないもの、影響が軽微なものはコメントを残していったん見送ります。エラー対応以外の業務に集中するためです。
もちろん、暫定対応と恒久対応が即座に行われる issue もあります。
2. 隔週で棚卸し
隔週でエンジニア全員が集まって Sentry issues の棚卸しを行っています。
即日対応が難しかったものや見送ったものをこの場で議論してトリアージし、必要なら解決に向けて担当者をアサインします。通知がそもそも不要という結論に至れば、同じエラーの通知は行われないようにします。
エラーゼロは目指していませんが、極力 Sentry issues を空っぽにするようにチーム全員で努めています。
突発的なエラー対応は確実かつそれなりに開発者のマインドシェアや時間を奪っていくもので、可能な限りエラー通知が少ないヘルシーな状態を保つことが日常の開発者体験の向上に直結すると考えているためです。
執筆にあたり参考にしたドキュメント等
- https://github.com/getsentry/sentry-ruby/pull/1580
- https://github.com/getsentry/sentry-ruby/pull/1589
- https://github.com/getsentry/sentry-ruby/releases/tag/4.8.0
- https://docs.sentry.io/platforms/ruby/guides/rails/configuration/filtering/
- https://develop.sentry.dev/sdk/event-payloads/stacktrace/
- https://develop.sentry.dev/sdk/event-payloads/
注1: アイキャッチ画像に使用したロゴは https://sentry.io/branding/ より入手したものです。Sentry は branding page から入手した画像の使用を認めています。
Sentry 本体は多数の言語に対応していますが、言語によってインテグレーションのしやすさや収集できる情報量が多少異なります。TracePoint
のような機能を用いてローカル変数キャプチャが実現できる Ruby は面白い言語ですね。他言語ではこのあたりがどうなっているか探ってみるのも面白そうです。
本記事は最近 Go 言語を書き始めた @ohbarye が執筆しました。