Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- Add the `form_actions` router configuration (#278).
- [Deferred] Add native periodic task scheduling with multi-process leader election via `File#flock` by [@Abishekcs](https://github.com/Abishekcs) (#233).
- [OpenAPI] Support optional attributes and `Array<>` syntax by [@ayushman1210](https://github.com/ayushman1210) (#228).
- [Errors] Add centralized error reporting interface via `Rage.errors` and `config.error_handlers` by [@Digvijay-x1](https://github.com/Digvijay-x1) (#275).

### Fixed

Expand Down
7 changes: 7 additions & 0 deletions lib/rage-rb.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ def self.events
Rage::Events
end

# Shorthand to access {Rage::Errors Rage::Errors}.
# @return [Rage::Errors]
def self.errors
Rage::Errors
end

# Shorthand to access {Rage::SSE Rage::SSE}.
# @return [Rage::SSE]
def self.sse
Expand Down Expand Up @@ -191,6 +197,7 @@ module ActiveRecord
autoload :OpenAPI, "rage/openapi/openapi"
autoload :Deferred, "rage/deferred/deferred"
autoload :Events, "rage/events/events"
autoload :Errors, "rage/errors"
autoload :PubSub, "rage/pubsub/pubsub"
end

Expand Down
1 change: 1 addition & 0 deletions lib/rage/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ def call(env)
response = @exception_app.call(400, e)

rescue Exception => e
Rage::Errors.report(e)
response = @exception_app.call(500, e)

ensure
Expand Down
3 changes: 2 additions & 1 deletion lib/rage/cable/cable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,8 @@ def schedule_fiber(connection)
end

def log_error(e)
Rage.logger.error("Unhandled exception has occured - #{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}")
Rage.logger.error("Unhandled exception has occurred - #{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}")
Rage::Errors.report(e)
end
end

Expand Down
3 changes: 2 additions & 1 deletion lib/rage/cable/channel.rb
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,8 @@ def set_up_periodic_timers
Fiber.schedule do
slice.each { |channel| callback.call(channel) }
rescue => e
Rage.logger.error("Unhandled exception has occured - #{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}")
Rage.logger.error("Unhandled exception has occurred - #{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}")
Rage::Errors.report(e)
end
end
end
Expand Down
62 changes: 62 additions & 0 deletions lib/rage/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,14 @@ def cable
end
# @!endgroup

# @!group Error Handler Configuration
# Allows configuring error handlers.
# @return [Rage::Configuration::ErrorHandlers]
def error_handlers
@error_handlers ||= ErrorHandlers.new
end
# @!endgroup

# @!group OpenAPI Configuration
# Allows configuring OpenAPI settings.
# @return [Rage::Configuration::OpenAPI]
Expand Down Expand Up @@ -386,6 +394,60 @@ def validate_input!(obj)
end
end

class ErrorHandlers
# @private
def initialize
@objects = []
end

# @private
def objects
@objects.dup
end

# Add a new error handler.
# Error handlers should respond to `#call` and accept one of:
# - `call(exception)`
# - `call(exception, context: {})`
#
# @param handler [#call]
# @return [self]
# @example
# Rage.configure do
# config.error_handlers << SentryReporter.new
# end
def <<(handler)
validate_input!(handler)
return self if @objects.include?(handler)

@objects << handler
Rage::Errors.__send__(:__register_reporter, handler)

self
end

alias_method :push, :<<

# Remove an error handler.
# @param handler [#call] the handler to remove
# @example
# handler = SentryReporter.new
# Rage.configure do
# config.error_handlers.delete(handler)
# end
def delete(handler)
deleted = @objects.delete(handler)
Rage::Errors.__send__(:__unregister_reporter, handler) if deleted
deleted
end

private

def validate_input!(handler)
raise ArgumentError, "error handler must respond to #call" unless handler.respond_to?(:call)
end
end

class Server
# @!attribute port
# Specify the port the server will listen on.
Expand Down
2 changes: 2 additions & 0 deletions lib/rage/deferred/task.rb
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ def __perform(context)

true
rescue Exception => e
Rage::Errors.report(e)

unless respond_to?(:__deferred_suppress_exception_logging?, true) && __deferred_suppress_exception_logging?
Rage.logger.with_context(task_log_context) do
Rage.logger.error("Deferred task failed with exception: #{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}")
Expand Down
83 changes: 83 additions & 0 deletions lib/rage/errors.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,87 @@
# frozen_string_literal: true

module Rage::Errors
ReporterEntry = Struct.new(:reporter, :method_name)
private_constant :ReporterEntry

@reporters = []
@next_reporter_id = 0

class << self
# Forward an exception to all registered reporters.
#
# @param exception [Exception]
# @param context [Hash]
# @return [nil]
def report(exception, context: {})
return if @reporters.empty?
return if exception.instance_variable_defined?(:@_rage_error_reported)

ensure_backtrace(exception)

@reporters.each do |entry|
__send__(entry.method_name, entry.reporter, exception, context)
rescue => e
Rage.logger.error("Error reporter #{entry.reporter.class} failed while reporting #{exception.class}: #{e.class} (#{e.message})")
end

exception.instance_variable_set(:@_rage_error_reported, true) unless exception.frozen?

nil
end

# @private
def __register_reporter(reporter)
raise ArgumentError, "error handler must respond to #call" unless reporter.respond_to?(:call)

reporter_id = @next_reporter_id
@next_reporter_id += 1
method_name = :"__report_#{reporter_id}"

arguments = Rage::Internal.build_arguments(
reporter.method(:call),
{ context: "context" }
)
call_arguments = arguments.empty? ? "" : ", #{arguments}"

singleton_class.class_eval <<~RUBY, __FILE__, __LINE__ + 1
def #{method_name}(reporter, exception, context)
reporter.call(exception#{call_arguments})
end
RUBY

@reporters << ReporterEntry.new(reporter, method_name)

self
end

# @private
def __unregister_reporter(reporter)
@reporters.delete_if do |entry|
next false unless entry.reporter == reporter

singleton_class.remove_method(entry.method_name) if singleton_class.method_defined?(entry.method_name)
true
end

self
end

private

def ensure_backtrace(exception)
return if exception.frozen?
return unless exception.backtrace.nil?

begin
raise exception
rescue exception.class
end
end

private :__register_reporter, :__unregister_reporter
end

class BadRequest < StandardError
end

Expand Down
7 changes: 6 additions & 1 deletion lib/rage/events/subscriber.rb
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,12 @@ def __call(event, context: nil)
Rage.logger.with_context(self.class.__log_context) do
Rage.logger.error("Subscriber failed with exception: #{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}")
end
raise e if self.class.__is_deferred

if self.class.__is_deferred
raise e
else
Rage::Errors.report(e)
end
end

private
Expand Down
1 change: 1 addition & 0 deletions lib/rage/middleware/fiber_wrapper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ def call(env)
rescue Exception => e
exception_str = "#{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}"
Rage.logger << exception_str
Rage::Errors.report(e)
if Rage.env.development?
[500, {}, [exception_str]]
else
Expand Down
1 change: 1 addition & 0 deletions lib/rage/pubsub/adapters/redis.rb
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ def poll

rescue RedisClient::Error => e
Rage.logger.error("Subscriber error: #{e.message} (#{e.class})")
Rage::Errors.report(e)
sleep error_backoff_intervals.next
rescue SystemCallError => e
@stopping ? break : raise(e)
Expand Down
1 change: 1 addition & 0 deletions lib/rage/sse/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ def start_stream(connection)
end
rescue => e
Rage.logger.error("SSE stream failed with exception: #{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}")
Rage::Errors.report(e)
ensure
Iodine.task_dec!
end
Expand Down
1 change: 1 addition & 0 deletions lib/rage/telemetry/tracer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ def #{tracer_name(span.id)}(#{parameters})
#{calls_chain}
rescue Exception => e
Rage.logger.error("Telemetry handler failed with error \#{e}:\\n\#{e.backtrace.join("\\n")}")
Rage::Errors.report(e)
end

unless yield_called
Expand Down
6 changes: 6 additions & 0 deletions spec/deferred/task_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -355,13 +355,19 @@ def perform(arg, kwarg:)
allow(Rage::Deferred::Context).to receive(:get_log_context).with(context).and_return({})
allow(task).to receive(:perform).and_raise(error)
allow(error).to receive(:backtrace).and_return(["line 1", "line 2"])
allow(Rage::Errors).to receive(:report)
end

it "logs the error" do
task.__perform(context)
expect(logger).to have_received(:error).with("Deferred task failed with exception: StandardError (Something went wrong):\nline 1\nline 2")
end

it "reports the error" do
task.__perform(context)
expect(Rage::Errors).to have_received(:report).with(error)
end

it "returns the exception" do
expect(task.__perform(context)).to be(error)
end
Expand Down
Loading