sharagoz.com

Rolling your own exception handler in Rails 3

Published March 15th 2011

In Rails 2 you could override ActionController's "rescue_action_in_public" to do your own exception handling.
For Rails 3 the request handling was re-written to take full advantage of Rack, so now you need to use Rack middleware to catch and handle the exceptions manually.
For an introduction to Rack middleware you can checkout Railscasts ep. 151.

I will show you how to create a middleware app that serves as a backend for tracking exceptions in your rails apps. Catch the exception, store the relevant data in the database, and display a custom error message to the user. That's what I will cover. The front end is not the scope of this post.

The middleware skeleton

Create a file in the lib folder and call it "exception_handler.rb". Here's the minimal code that will get us started:


class ExceptionHandler
def initialize(app)
@app = app
end

def call(env)
@app.call(env)
rescue
["200", {"content-type" => "text/html"}, "An error has occured"]
end
end


To hook the error handler into the application, add these lines to config/application.rb



require 'exception_handler'
config.middleware.use ExceptionHandler

For production usage you'd move these into production.rb, as you want regular error messages in development. If you trigger an error now you should receive a response that says nothing more than "An error has occured".
Note: What we have so far will not catch routing errors, I'll get to that in a minute.

Let's start by gathering the information we want to save.
Create a new class called "Parser" and define it within the ExceptionHandler class.



class Parser
def initialize(exception, request)
@exception = exception
@request = request
end

def relevant_info
info = {}
info[:class_name] = @exception.class.to_s # The exception class, "ActiveRecord::RecordNotFound"
info[:message] = @exception.to_s # The actual error message, "Couldn't find Post with id=42"
info[:trace] = @exception.backtrace.join("\n") # I bet you have seen a backtrace before
info[:target_url] = @request.url # Which URL did the client try to access
info[:referer_url] = @request.referer # Which URL did he come from
info[:params] = @request.params.inspect # The request parameters
info[:user_agent] = @request.user_agent # The user agent string
return info
end
end


To store this in the database we need a database table and a model:



rails g model ErrorMessage class_name:text message:text trace:text target_url:text referer_url:text params:text user_agent:text
rake db:migrate

To use the class we just made, change the code of the rescue block in ExceptionHandler:



rescue Exception => e
request = ActionDispatch::Request.new(env)
parsed_error = Parser.new(e, request)
ErrorMessage.create!(parsed_error.relevant_info)
["200", {"content-type" => "text/html"}, "An error has occured"]
end

I am creating a new request object here. It is a bit easier to extract information from it than the env object itself.

Give this a test. What we have made so far is the gist of it. We catch the exception and store the info we want in the database. There are some important details to take care of though, before it can be put into production.

Filtering

Crawlers and bots are going to generate a bunch of errors, regardless of what you put in robot.txt. Let's add some basic filtering. Change the create statement to this:


ErrorMessage.create(parsed_error.relevant_info) unless(parsed_error.ignore?)

Then define the ignore? function on the Parser class like so:



def ignore?
# Ignore routing errors in requests without a referer as they are going to be bots in 99.99235% of the cases
routing_errors = [ActionController::RoutingError, AbstractController::ActionNotFound, ActiveRecord::RecordNotFound]
if(routing_errors.include?(@exception.class) && @request.referer.blank?)
return true
end
# Ignore requests with user agent string matching this regxp as they are surely made by bots
if(@request.user_agent =~ /\b(Baidu|Gigabot|Googlebot|libwww-perl|lwp-trivial|msnbot|SiteUptime|Slurp|WordPress|ZIBB|ZyBorg|Yandex|Jyxobot|Huaweisymantecspider|ApptusBot)\b/i)
return true
end
return false
end

That's all the filtering I've needed. Some chose to ignore all routing errors, I prefer not to. Mortals like myself sometimes put broken links into production. If you start to get a large amount of error messages, it might be better to group them on the front end than apply a lot of filtering on the back end.

Catching routing errors

As I said before, we are not catching routing errors yet. To catch those you need to override a method in ActionDispatch:
Put this at the end of error_handler.rb


module ActionDispatch
class ShowExceptions
private
def render_exception_with_template(env, exception)
# Handle error and return response
end
alias_method_chain :render_exception, :template
end
end

Credit: I got that snipped from a blog post by AccUser.

A bit of refactoring

Instead of copy pasting the code from the ExceptionHandler's rescue block into the ActionDispatch method, I'm gonna be DRY and extract the code into its own class, so that maintaining and expanding the code is easier. Define a new class within the ExceptionHandler class called "Handler":


class Handler
def initialize(env, exception)
@exception = exception
@env = env
end

def handle_exception
request = ActionDispatch::Request.new(@env)
parsed_error = Parser.new(@exception, request)
ErrorMessage.create(parsed_error.relevant_info) unless(parsed_error.ignore?)
return response
end

def response
["200", {"content-type" => "text/html"}, "An error has occured"]
end
end


Replace the rescue block with this line:


Handler.new(env, e).handle_exception

Replace the comment in the ActionDispatch function with this line:


ExceptionHandler::Handler.new(env, exception).handle_exception

With that you should be catching the routing errors as well.

Displaying a nicer error message

I prefer to display the error messages within the regular layout of the page. It looks nicer and feels less like "MAYDAY! MAYDAY!" for the user.
Trying to send responses with layouts without using a controller in Rails takes a bit of work, so I'm gonna define an error response controller in the exception catcher to take care of this. Put this at the bottom of exception_catcher.rb:


class ErrorResponseController < ActionController::Base
def index
render(:text => "<h1>An error has occured</h1><p>The error message has been logged</p>", :layout => 'layouts/application')
end
end

Redefine the response function in the Handler class so that it calls upon this controller to generate the response:


ErrorResponseController.action(:index).call(@env)

The error message should now be rendered within the default layout of the site.

Regular logging

Even with your own error handler you may want to continue having the errors logged to the default log file.
If you want this, add this function to the Handler class:


def log_error(info)
message = "#{info[:class_name]} (#{info[:message]}):\n "
message += Rails.backtrace_cleaner.clean(info[:trace].split("\n")).join("\n")
Rails.logger.fatal(message)
end

Add this line to the handle_exception function:


log_error(parsed_error.relevant_info)

That will log an error to the log file that looks identical to the one Rails creates by default, without any filtering applied.

Keeping track of who triggered the request

In applications that has user accounts it is usefull to keep track of who experienced the error. If a critical error occurs it can enable you to contact the user directly. I once had an incident where users got an error page displayed after pressing "confirm order", which is about as bad as it gets, and knowing the login name of the ones who got this error enabled me to contact each one and tell them that the order went through OK.

Add a new function to the Parser class:



def user_info
if(@controller.respond_to?(:current_user))
current_user = @controller.current_user
[:login, :username, :email].each do |field|
return current_user.send(field) if(current_user.respond_to?(field))
end
end
return nil
end

I made the function probe for fields on the user object, as I dont universally use the same login field in all my apps. If you dont always use "current_user" you can easily expand the function to probe for the helper method as well.

Add the line below to the relevant_info function and add the new field to the database table.


info[:user_info] = user_info

The initializer on the Parser class must be expanded to take the controller instance:



def initialize(exception, request, controller)
@exception = exception
@request = request
@controller = controller
end

and finally, the handle_exception function must fetch the controller instance and pass it to the handler



controller = @env['action_controller.instance']
parsed_error = Parser.new(@exception, request, controller)

That's it. You can get the entire code of exception_handler.rb from here. I defined all the code in one file here to keep the handler easily contained. It's a lot cleaner to split this up into several files and keep it contained as a gem/plugin instead.

You can use this code as a basis for making your own error tracking system. It wouldnt take a lot of work to extract this into a gem and have all your applications store error messages in the same database. With a simple web front on top of that you have a pretty decent system in place already that you can modify and expand as needed.

Happy bug hunting!