Skip to content
Open
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
18 changes: 16 additions & 2 deletions app/mailers/solid_errors/error_mailer.rb
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
module SolidErrors
# adapted from: https://github.com/codergeek121/email_error_reporter/blob/main/lib/email_error_reporter/error_mailer.rb
class ErrorMailer < (defined?(ActionMailer::Base) ? ActionMailer::Base : Object)
def error_occurred(occurrence)
def error_occurred(occurrence, trigger: nil)
unless defined?(ActionMailer::Base)
raise "ActionMailer is not available. Make sure that you require \"action_mailer/railtie\" in application.rb"
end
@occurrence = occurrence
@error = occurrence.error
subject = "#{@error.severity_emoji} #{@error.exception_class}"
@trigger = trigger

# Customize subject based on trigger (lifecycle event)
subject = case trigger
when :resolved
"βœ… RESOLVED: #{@error.exception_class}"
when :reopened
"πŸ”„ REOPENED: #{@error.exception_class}"
when :milestone
occurrence_count = @error.occurrences.count
"#{@error.severity_emoji} #{@error.exception_class} (#{occurrence_count.ordinalize} occurrence)"
else
"#{@error.severity_emoji} #{@error.exception_class}"
end

if SolidErrors.email_subject_prefix.present?
subject = [SolidErrors.email_subject_prefix, subject].join(" ").squish!
end
Expand Down
26 changes: 26 additions & 0 deletions app/models/solid_errors/error.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ class Error < Record
scope :resolved, -> { where.not(resolved_at: nil) }
scope :unresolved, -> { where(resolved_at: nil) }

after_update_commit :send_lifecycle_email, if: :saved_change_to_resolved_at?

def severity_emoji
SEVERITY_TO_EMOJI[severity.to_sym]
end
Expand All @@ -53,5 +55,29 @@ def status_badge_classes
def resolved?
resolved_at.present?
end

private

def send_lifecycle_email
return unless SolidErrors.send_emails?

if was_resolved? && !resolved?
# Error was reopened
send_lifecycle_occurrence_email(:reopened) if SolidErrors.email_on_reopened
elsif !was_resolved? && resolved?
# Error was resolved
send_lifecycle_occurrence_email(:resolved) if SolidErrors.email_on_resolved
end
end

def was_resolved?
resolved_at_before_last_save.present?
end

def send_lifecycle_occurrence_email(trigger)
# Reuse the existing error_occurred email but with a trigger parameter
occurrence = occurrences.last || occurrences.new
ErrorMailer.error_occurred(occurrence, trigger: trigger).deliver_later
end
end
end
43 changes: 42 additions & 1 deletion app/models/solid_errors/occurrence.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,48 @@ def parse_backtrace(backtrace)
end

def send_email
ErrorMailer.error_occurred(self).deliver_later
return unless should_send_email?

trigger = email_trigger
ErrorMailer.error_occurred(self, trigger: trigger).deliver_later
end

def should_send_email?
# Check if we've hit a milestone count (if configured)
return true if milestone_reached?

# Check if we've exceeded the rate threshold (if configured)
return true if rate_threshold_exceeded?

false
end

def email_trigger
return :milestone if milestone_reached?
nil
end

def milestone_reached?
# If email_milestone_counts is nil, email all occurrences (default behavior)
return true if SolidErrors.email_milestone_counts.nil?

# If email_milestone_counts is empty array, don't email based on counts
return false if SolidErrors.email_milestone_counts.empty?

# Otherwise, only email if we're at a milestone
occurrence_count = error.occurrences.count
SolidErrors.email_milestone_counts.include?(occurrence_count)
end

def rate_threshold_exceeded?
# Only check rate threshold if both count and window are configured
return false unless SolidErrors.email_rate_threshold_count && SolidErrors.email_rate_threshold_window

window_start = SolidErrors.email_rate_threshold_window.seconds.ago
recent_count = error.occurrences.where(created_at: window_start...).count

# Only send email if we've just crossed the threshold (not on every subsequent occurrence)
recent_count == SolidErrors.email_rate_threshold_count
end

def clear_resolved_errors
Expand Down
12 changes: 12 additions & 0 deletions lib/solid_errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,18 @@ module SolidErrors
mattr_accessor :email_to
mattr_accessor :email_subject_prefix
mattr_accessor :destroy_after
# Email notification configuration
# email_milestone_counts: nil (default, emails all occurrences), [] (no count-based emails), [1,10,100] (email at these counts)
# email_rate_threshold_count: number of occurrences within time window to trigger email
# email_rate_threshold_window: time window in seconds for rate threshold
mattr_accessor :email_milestone_counts, default: nil
mattr_accessor :email_rate_threshold_count, default: nil
mattr_accessor :email_rate_threshold_window, default: nil
# Lifecycle event email configuration
# email_on_resolved: send email when error is marked as resolved
# email_on_reopened: send email when resolved error is reopened
mattr_accessor :email_on_resolved, default: false
mattr_accessor :email_on_reopened, default: false

class << self
# use method instead of attr_accessor to ensure
Expand Down
74 changes: 74 additions & 0 deletions test/models/solid_errors/lifecycle_events_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
require 'test_helper'

class SolidErrors::LifecycleEventsTest < ActiveSupport::TestCase
include ActionMailer::TestHelper

def teardown
SolidErrors.send_emails = false
SolidErrors.email_to = nil
SolidErrors.email_on_resolved = false
SolidErrors.email_on_reopened = false
ActionMailer::Base.deliveries.clear
end

test 'sends email when error is resolved if email_on_resolved is true' do
SolidErrors.send_emails = true
SolidErrors.email_to = 'test@example.com'
SolidErrors.email_on_resolved = true

error = create_error
error.occurrences.create!

assert_enqueued_emails 1 do
error.update!(resolved_at: Time.current)
end
end

test 'does not send email when error is resolved if email_on_resolved is false' do
SolidErrors.send_emails = true
SolidErrors.email_to = 'test@example.com'
SolidErrors.email_on_resolved = false

error = create_error
error.occurrences.create!

assert_no_enqueued_emails do
error.update!(resolved_at: Time.current)
end
end

test 'sends email when error is reopened if email_on_reopened is true' do
SolidErrors.send_emails = true
SolidErrors.email_to = 'test@example.com'
SolidErrors.email_on_reopened = true

error = create_error
error.occurrences.create!
error.update!(resolved_at: Time.current)

assert_enqueued_emails 1 do
error.update!(resolved_at: nil)
end
end

test 'does not send email when error is reopened if email_on_reopened is false' do
SolidErrors.send_emails = true
SolidErrors.email_to = 'test@example.com'
SolidErrors.email_on_reopened = false

error = create_error
error.occurrences.create!
error.update!(resolved_at: Time.current)

assert_no_enqueued_emails do
error.update!(resolved_at: nil)
end
end

private

def create_error
Rails.error.report(StandardError.new('test error'))
SolidErrors::Error.last
end
end
126 changes: 118 additions & 8 deletions test/models/solid_errors/occurrence_test.rb
Original file line number Diff line number Diff line change
@@ -1,49 +1,159 @@
require "test_helper"
require 'test_helper'

class SolidErrors::OccurrenceTest < ActiveSupport::TestCase
def teardown
SolidErrors.destroy_after = nil
SolidErrors.email_milestone_counts = nil
SolidErrors.email_rate_threshold_count = nil
SolidErrors.email_rate_threshold_window = nil
SolidErrors.email_on_resolved = false
SolidErrors.email_on_reopened = false
end

test "do not destroy if destroy_after is not set" do
test 'do not destroy if destroy_after is not set' do
SolidErrors.destroy_after = nil
simulate_99_old_exceptions(:resolved)

assert_difference -> { SolidErrors::Error.count }, +1 do
assert_difference -> { SolidErrors::Occurrence.count }, +1 do
Rails.error.report(StandardError.new("oof"))
Rails.error.report(StandardError.new('oof'))
end
end
end

test "destroy old occurrences every 100 insertions if destroy_after is set" do
test 'destroy old occurrences every 100 insertions if destroy_after is set' do
SolidErrors.destroy_after = 1.day
simulate_99_old_exceptions(:resolved)

assert_difference -> { SolidErrors::Error.count }, 0 do
assert_difference -> { SolidErrors::Occurrence.count }, 0 do
Rails.error.report(StandardError.new("oof"))
Rails.error.report(StandardError.new('oof'))
end
end
end

test "not destroy if errors are unresolved" do
test 'not destroy if errors are unresolved' do
SolidErrors.destroy_after = 1.day
simulate_99_old_exceptions(:unresolved)

assert_difference -> { SolidErrors::Error.count }, +1 do
assert_difference -> { SolidErrors::Occurrence.count }, +1 do
assert_empty SolidErrors::Error.resolved
Rails.error.report(StandardError.new("oof"))
Rails.error.report(StandardError.new('oof'))
end
end
end

# Email notification configuration tests

test 'should send email for all occurrences by default (nil milestone_counts)' do
SolidErrors.email_milestone_counts = nil
error = create_error

assert error.occurrences.last.send(:should_send_email?)

# Create more occurrences and verify each should send email
2.times { create_occurrence(error) }
assert error.occurrences.last.send(:should_send_email?)
end

test 'should not send email when milestone_counts is empty array' do
SolidErrors.email_milestone_counts = []
error = create_error

assert_not error.occurrences.last.send(:should_send_email?)
end

test 'should send email only at milestone counts' do
SolidErrors.email_milestone_counts = [1, 10, 100]
error = create_error

# First occurrence should send
assert error.occurrences.last.send(:milestone_reached?)
assert error.occurrences.last.send(:should_send_email?)

# 2nd-9th occurrences should not send
8.times do
occurrence = create_occurrence(error)
assert_not occurrence.send(:milestone_reached?)
assert_not occurrence.send(:should_send_email?)
end

# 10th occurrence should send
tenth_occurrence = create_occurrence(error)
assert tenth_occurrence.send(:milestone_reached?)
assert tenth_occurrence.send(:should_send_email?)
end

test 'should send email when rate threshold is exceeded' do
SolidErrors.email_milestone_counts = [] # Disable milestone emails
SolidErrors.email_rate_threshold_count = 5
SolidErrors.email_rate_threshold_window = 300 # 5 minutes

error = create_error
# Create 4 more occurrences within the window (total 5)
4.times { create_occurrence(error) }

# The 5th occurrence should trigger rate threshold
fifth_occurrence = error.occurrences.last
assert_equal 5, error.occurrences.where(created_at: 300.seconds.ago...).count
assert fifth_occurrence.send(:rate_threshold_exceeded?)
assert fifth_occurrence.send(:should_send_email?)

# 6th occurrence should not trigger (we've already hit the threshold)
sixth_occurrence = create_occurrence(error)
assert_not sixth_occurrence.send(:rate_threshold_exceeded?)
end

test 'should not trigger rate threshold when occurrences are spread out' do
SolidErrors.email_milestone_counts = [] # Disable milestone emails
SolidErrors.email_rate_threshold_count = 3
SolidErrors.email_rate_threshold_window = 300 # 5 minutes

error = create_error
# Move the initial occurrence outside the window
error.occurrences.last.update!(created_at: 10.minutes.ago)

# Create 1 more occurrence outside the window
occurrence = create_occurrence(error)
occurrence.update!(created_at: 10.minutes.ago)

# New occurrence should not trigger rate threshold (only 1 in window)
new_occurrence = create_occurrence(error)
assert_equal 1, error.occurrences.where(created_at: 300.seconds.ago...).count
assert_not new_occurrence.send(:rate_threshold_exceeded?)
end

test 'should work with both milestone and rate threshold configured' do
SolidErrors.email_milestone_counts = [1, 10]
SolidErrors.email_rate_threshold_count = 5
SolidErrors.email_rate_threshold_window = 300

error = create_error

# First occurrence triggers milestone
assert error.occurrences.last.send(:should_send_email?)

# 5th occurrence triggers rate threshold even though not a milestone
4.times { create_occurrence(error) }
fifth_occurrence = error.occurrences.last
assert fifth_occurrence.send(:should_send_email?)
end

private

def simulate_99_old_exceptions(status)
Rails.error.report(StandardError.new("argh"))
Rails.error.report(StandardError.new('argh'))
SolidErrors::Error.update_all(resolved_at: Time.current) if status == :resolved
SolidErrors::Occurrence.last.update!(id: 99, created_at: 1.day.ago)
end

def create_error
Rails.error.report(StandardError.new('test error'))
SolidErrors::Error.last
end

def create_occurrence(error)
error.occurrences.create!
end
end