diff --git a/README.md b/README.md index ca5fd80..fedfbe6 100644 --- a/README.md +++ b/README.md @@ -227,22 +227,6 @@ config.action_dispatch.rescue_responses.merge!( Now when an you raise an `AuthenticationError` in one of your actions, the status code of the response will be 401. -### Error Reporting -If you use an external error tracking software like Sentry or Honeybadger you'll -want to report all errors to that service. - -You can do so by passing in a `Proc` to the `error_reporter` option. The first -argument provided to the Proc will be the error. The second argument will be the -`error_id` if you have one. See the section below for more details on error IDs. - -```ruby -handle_api_errors( - error_reporter: Proc.new do |error, error_id| - Raven.capture_exception(error, error_id: error_id) - end -) -``` - ### Error IDs Sometimes it's helpful to include IDs with your error responses so that you can correlate a specific error with a record in your logs or bug tracking software. @@ -269,6 +253,40 @@ These will result in: } ``` +### Error Reporting +If you use an external error tracking software like Sentry or Honeybadger, you'll +want to report all errors to that service. + +#### Out of the Box Error Reporting +There are a few supported error reporter options that you can select. + +##### Raven/Sentry +```ruby +handle_api_errors(error_reporter: :raven) +# Or +handle_api_errors(error_reporter: :sentry) +``` + +##### Honeybadger +```ruby +handle_api_errors(error_reporter: :honeybadger) +``` + +__NOTE:__ If you use the `:error_id` option, the error error reporter will tag +the exception with the error ID when reporting the error. + +#### Custom Reporting +If none of the out of the box options work for you, you can pass in a proc which +will receive the error and the error_id as arguments. + +```ruby +handle_api_errors( + error_reporter: Proc.new do |error, error_id| + # Do something with the `error` here. + end +) +``` + ### Setting Content Type The api_error_handler will set the content type of your error based on the `format` option you pick. However, you can override this by setting the diff --git a/lib/api_error_handler.rb b/lib/api_error_handler.rb index bffe117..af608c7 100644 --- a/lib/api_error_handler.rb +++ b/lib/api_error_handler.rb @@ -1,6 +1,7 @@ require_relative "./api_error_handler/version" require_relative "./api_error_handler/action_controller" require_relative "./api_error_handler/error_id_generator" +require_relative "./api_error_handler/error_reporter" Dir[File.join(__dir__, 'api_error_handler', 'serializers', "*.rb")].each { |file| require file } module ApiErrorHandler @@ -20,7 +21,7 @@ module ApiErrorHandler def handle_api_errors(options = {}) format = options.fetch(:format, :json) - error_reporter = options[:error_reporter] + error_reporter = ErrorReporter.new(options[:error_reporter]) serializer_options = SERIALIZER_OPTIONS.merge( options.slice(*SERIALIZER_OPTIONS.keys) ) @@ -32,7 +33,7 @@ def handle_api_errors(options = {}) status = ActionDispatch::ExceptionWrapper.rescue_responses[error.class.to_s] error_id = ErrorIdGenerator.run(options[:error_id]) - error_reporter.call(error, error_id) if error_reporter + error_reporter.report(error, error_id: error_id) serializer = serializer_class.new(error, status) response_body = serializer.serialize( diff --git a/lib/api_error_handler/error_reporter.rb b/lib/api_error_handler/error_reporter.rb new file mode 100644 index 0000000..45a8357 --- /dev/null +++ b/lib/api_error_handler/error_reporter.rb @@ -0,0 +1,36 @@ +require "logger" +require_relative "./errors" + +module ApiErrorHandler + class ErrorReporter + def initialize(strategy) + @strategy = strategy + end + + def report(error, error_id: nil) + if @strategy.nil? + true + elsif @strategy.instance_of?(Proc) + @strategy.call(error, error_id) + elsif @strategy == :honeybadger + raise_dependency_error(missing_constant: "Honeybadger") unless defined?(Honeybadger) + + context = error_id ? { error_id: error_id } : {} + Honeybadger.notify(error, context: context) + elsif @strategy == :raven || @strategy == :sentry + raise_dependency_error(missing_constant: "Raven") unless defined?(Raven) + + extra = error_id ? { error_id: error_id } : {} + Raven.capture_exception(error, extra: extra) + else + raise(InvalidOptionError, "`#{@strategy.inspect}` is an invalid argument for the `:error_id` option.") + end + end + + private + + def raise_dependency_error(missing_constant:) + raise MissingDependencyError, "You selected the #{@strategy.inspect} error reporter option but the #{missing_constant} constant is not defined. If you wish to use this error reporting option you must have the #{@strategy} client gem installed." + end + end +end diff --git a/lib/api_error_handler/errors.rb b/lib/api_error_handler/errors.rb index 0ac25c3..3d20458 100644 --- a/lib/api_error_handler/errors.rb +++ b/lib/api_error_handler/errors.rb @@ -2,4 +2,5 @@ module ApiErrorHandler class Error < StandardError; end class InvalidOptionError < Error; end + class MissingDependencyError < Error; end end diff --git a/spec/api_error_handler/error_reporter_spec.rb b/spec/api_error_handler/error_reporter_spec.rb new file mode 100644 index 0000000..f28c057 --- /dev/null +++ b/spec/api_error_handler/error_reporter_spec.rb @@ -0,0 +1,78 @@ +require_relative "../../lib/api_error_handler/error_reporter" + +RSpec.describe ApiErrorHandler::ErrorReporter do + let(:error) { RuntimeError.new("Error message") } + + it "Raises an InvalidOptionError if you provide an bad option" do + reporter = described_class.new(:asdf) + + expect do + reporter.report(error) + end.to raise_error(ApiErrorHandler::InvalidOptionError) + end + + context "using the `nil` strategy" do + let(:reporter) { described_class.new(nil) } + + it "Does nothing if the strategy is `nil`" do + reporter.report(error) + end + end + + context "using a Proc strategy" do + it "Calls the Proc with the error and error_id" do + strategy = proc do |e, error_id| + expect(e).to eq(error) + expect(error_id).to eq("123") + end + + reporter = described_class.new(strategy) + + reporter.report(error, error_id: "123") + end + end + + context "using the :honeybadger strategy" do + let(:reporter) { described_class.new(:honeybadger) } + + it "Raises an error if the Honeybadger constant is not defined" do + expect { reporter.report(error) }.to raise_error(ApiErrorHandler::MissingDependencyError) + end + + it "Reports to Honeybadger with an error id" do + stub_const("Honeybadger", double) + expect(Honeybadger).to receive(:notify).with(error, context: { error_id: "456" }) + + reporter.report(error, error_id: "456") + end + + it "Reports to Honeybadger without an error id" do + stub_const("Honeybadger", double) + expect(Honeybadger).to receive(:notify).with(error, context: {}) + + reporter.report(error) + end + end + + context "using the :raven/:sentry strategy" do + let(:reporter) { described_class.new(:sentry) } + + it "Raises an error if the Raven constant is not defined" do + expect { reporter.report(error) }.to raise_error(ApiErrorHandler::MissingDependencyError) + end + + it "Reports to Honeybadger with an error id" do + stub_const("Raven", double) + expect(Raven).to receive(:capture_exception).with(error, extra: { error_id: "456" }) + + reporter.report(error, error_id: "456") + end + + it "Reports to Honeybadger without an error id" do + stub_const("Raven", double) + expect(Raven).to receive(:capture_exception).with(error, extra: { }) + + reporter.report(error) + end + end +end