RailsのApplicationController::RoutingErrorがたまらなくうざい

いやまぁ書いた通りなんだけど、ApplicationController::RoutingError の backtrace って全然役に立たないし、邪魔じゃないですか。できればこのエラーだけ backtrace をオフにできたらステキだなと思ったのでその辺の対処方法をまとめてみた。

この「RoutingErrorのログが邪魔問題」の解決方法は主に以下のような3つくらいのパターンがあるっぽい。

  1. routing で丸ごとキャッチしてしまってそもそも RoutingError が起きないようにして手動で 404 を返す
  2. Logger を差し替えてなかったことにする、DebugExceptions そのものを使わないようにする
  3. DebugExceptions の中でゴニョゴニョする

RoutingErrorが起きないようにroutes.rbで全部拾う

1 の方法については Stack Overflow や Qiita なんかにたくさん記事や回答がある。あるんだけど、ちょっとイマイチっぽい、みたいなニュアンス。

個人的にも想定外のリクエストがあってエラーが起きたという事実について情報量をコントロールしたいとは思うものの、すべて正常な処理で 404 が返っただけになってしまうのはちょっと意図と違うなという気がする。

Loggerやrack middlewareを差し替えてもみ消す

2 の方法はなかなか見つからないんだけど、例えば

stve/silencer: Easily suppress the Rails logger

みたいなやつ。

もっと大胆に DebugExceptions middleware を無効にしちゃうとか、 3 と組み合わせて自分で動的に Logger を差し替えちゃうという方法も Stack Overflow で見かけたが、middleware や Logger を全部差し替えたりするのは差し替え処理と差し替えた Logger ( middleware ) そのものが正しく動作するか検証しなくちゃいけないので重たいなーという印象。

そもそもエラーは起きているがそれが記録されないのは 1 よりもさらにイマイチな気がする。

DebugExceptions middleware + interceptorで好きなようにする

3 の方法については 2 の方法が見つかってあれこれコードを読んでいて自分で気がついたんだけど、実は DebugExceptions には interceptor という仕組みがあるのでそれが使える。

この方法を採用している事例は探した限り見つからなかったんだけど、結構使えるテクだと思うので紹介しておく。対象バージョンは

  • Rails 6.0.0

で、DebugExceptions は ActionDispatch の中の middleware で、rake middleware すると真ん中辺りに出てくるんじゃないかと思う。

この middleware のコードを追うと以下のように ActionDispatch::DebugExceptions.call の中で、事前に register された interceptor が次々に呼ばれることが分かる。interceptor には request と exception が渡ってくる。

module ActionDispatch
  # This middleware is responsible for logging exceptions and
  # showing a debugging page in case the request is local.
  class DebugExceptions
    cattr_reader :interceptors, instance_accessor: false, default: []

    def self.register_interceptor(object = nil, &block)
      interceptor = object || block
      interceptors << interceptor
    end

….

    def call(env)
      ...
    rescue Exception => exception
      invoke_interceptors(request, exception)
      raise exception unless request.show_exceptions?
      render_exception(request, exception)
    end

….

     def invoke_interceptors(request, exception)
       backtrace_cleaner = request.get_header("action_dispatch.backtrace_cleaner")
       wrapper = ExceptionWrapper.new(backtrace_cleaner, exception)

       @interceptors.each do |interceptor|
         interceptor.call(request, exception)
       rescue Exception
         log_error(request, wrapper)
       end
     end

ということで独自の interceptor を作ってその中で ActionController::RoutingError かどうか判別して何らかの加工を加えるなり処理を行うなどするとよさそう。

具体的には request オブジェクトを書き換えたり exception オブジェクトを書き換えると backtrace は無視するといったことが可能で、そのうえで必要な情報は独自にログに落とすとかどこかエラー収集の仕組みに投げるようなコードを書けばよいと思う。

具体的なIntercepterの例

上のコードにマッチすればよいので、以下のような形になる。

class FooInterceptor
  def call(request, exception)
    ..
    if exception.is_a? ActionController::RoutingError
      ..
      request.instance_eval {
        def show_exceptions? # もみ消す
          false
        end
      }
      ..
    }
  end
end

ActionDispatch::DebugExceptions.register_interceptor(FooIntercepter)

単に消してしまうだけだと上に書いたようにエラーが起きた事実も消えてしまってよくないので、なんらかの措置を行なったうえでもみ消してください。

More