diff --git a/app/mailers/solid_errors/error_mailer.rb b/app/mailers/solid_errors/error_mailer.rb index 59ae375..b3cf995 100644 --- a/app/mailers/solid_errors/error_mailer.rb +++ b/app/mailers/solid_errors/error_mailer.rb @@ -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 diff --git a/app/models/solid_errors/error.rb b/app/models/solid_errors/error.rb index 3c84203..dae30ca 100644 --- a/app/models/solid_errors/error.rb +++ b/app/models/solid_errors/error.rb @@ -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 @@ -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 diff --git a/app/models/solid_errors/occurrence.rb b/app/models/solid_errors/occurrence.rb index 4f034e0..85191aa 100644 --- a/app/models/solid_errors/occurrence.rb +++ b/app/models/solid_errors/occurrence.rb @@ -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 diff --git a/lib/solid_errors.rb b/lib/solid_errors.rb index c2a3788..ef42e18 100644 --- a/lib/solid_errors.rb +++ b/lib/solid_errors.rb @@ -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 diff --git a/test/models/solid_errors/lifecycle_events_test.rb b/test/models/solid_errors/lifecycle_events_test.rb new file mode 100644 index 0000000..ea706e2 --- /dev/null +++ b/test/models/solid_errors/lifecycle_events_test.rb @@ -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 diff --git a/test/models/solid_errors/occurrence_test.rb b/test/models/solid_errors/occurrence_test.rb index c994e7d..0b1d203 100644 --- a/test/models/solid_errors/occurrence_test.rb +++ b/test/models/solid_errors/occurrence_test.rb @@ -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