From 4fc427477acb2c9ab07af8b09643bbe4f2d5b4cb Mon Sep 17 00:00:00 2001 From: Dan Loman Date: Tue, 13 Oct 2026 09:02:48 -0700 Subject: [PATCH 1/3] Say in the subject when an emailed error was reopened With email_every_occurrence off, the occurrence that reopens a resolved error is emailed with the same subject as a brand-new error, so a fix that didn't hold is indistinguishable from a new problem until the email is opened. The occurrence knows it reopened its error, but the mailer job loads it afresh, so the reason is passed to the mailer as an optional argument. --- app/mailers/solid_errors/error_mailer.rb | 5 ++- app/models/solid_errors/occurrence.rb | 3 +- .../mailers/solid_errors/error_mailer_test.rb | 31 +++++++++++++++++-- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/app/mailers/solid_errors/error_mailer.rb b/app/mailers/solid_errors/error_mailer.rb index 2d1f106..bc22343 100644 --- a/app/mailers/solid_errors/error_mailer.rb +++ b/app/mailers/solid_errors/error_mailer.rb @@ -12,13 +12,16 @@ class ErrorMailer < (defined?(ActionMailer::Base) ? ActionMailer::Base : Object) helper_method :occurrence_url end - def error_occurred(occurrence) + # opens_error is :reopened when this occurrence brought a resolved error back, so a fix that + # didn't hold reads differently from a brand-new error + def error_occurred(occurrence, opens_error = 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}" + subject += " (reopened)" if opens_error&.to_sym == :reopened if SolidErrors.email_subject_prefix.present? subject = [SolidErrors.email_subject_prefix, subject].join(" ").squish! end diff --git a/app/models/solid_errors/occurrence.rb b/app/models/solid_errors/occurrence.rb index b598b87..d7945cd 100644 --- a/app/models/solid_errors/occurrence.rb +++ b/app/models/solid_errors/occurrence.rb @@ -35,7 +35,8 @@ def publish_events end def send_email - ErrorMailer.error_occurred(self).deliver_later + # Passed along rather than read off the occurrence, which the mailer job loads afresh without it + ErrorMailer.error_occurred(self, opens_error).deliver_later end def clear_resolved_errors diff --git a/test/mailers/solid_errors/error_mailer_test.rb b/test/mailers/solid_errors/error_mailer_test.rb index 13a5c51..7925f77 100644 --- a/test/mailers/solid_errors/error_mailer_test.rb +++ b/test/mailers/solid_errors/error_mailer_test.rb @@ -36,6 +36,26 @@ def deliver!(_mail) ActionMailer::Base.default_url_options = {} end + test "the subject says when an error was reopened" do + report_raised_error("oof") + occurrence = SolidErrors::Occurrence.last + + assert_equal "⚠️ StandardError", SolidErrors::ErrorMailer.error_occurred(occurrence).subject + assert_equal "⚠️ StandardError (reopened)", SolidErrors::ErrorMailer.error_occurred(occurrence, :reopened).subject + end + + test "a reopened error is emailed as reopened" do + with_emails do + report_raised_error("oof") + SolidErrors::Error.last.update!(resolved_at: Time.current) + + assert_enqueued_email_with SolidErrors::ErrorMailer, :error_occurred, + args: ->(args) { args.last == :reopened } do + report_raised_error("oof") + end + end + end + test "a failed delivery is recorded without sending another email" do with_failing_delivery do report_raised_error("oof") @@ -62,15 +82,20 @@ def report_raised_error(message) Rails.error.report(error) end - def with_failing_delivery - delivery_method = ActionMailer::Base.delivery_method + def with_emails SolidErrors.send_emails = true SolidErrors.email_to = "ops@example.com" - ActionMailer::Base.delivery_method = :failing yield ensure SolidErrors.send_emails = false SolidErrors.email_to = nil + end + + def with_failing_delivery + delivery_method = ActionMailer::Base.delivery_method + ActionMailer::Base.delivery_method = :failing + with_emails { yield } + ensure ActionMailer::Base.delivery_method = delivery_method end end From 8e56a5804a16a6f110a9520e6b062477d00566e0 Mon Sep 17 00:00:00 2001 From: Dan Loman Date: Tue, 13 Oct 2026 09:36:57 -0700 Subject: [PATCH 2/3] Let apps build the error email subject with email_subject The occurrence's reason for being emailed now reaches a proc, along with the error and the occurrence, so an app can word the subject itself: a different label for a reopened error, or marking the 100th occurrence when every occurrence is emailed. The default proc is the existing subject, with "(reopened)" for an error that occurred again after being resolved. --- CHANGELOG.md | 2 ++ README.md | 16 +++++++++++++++ app/mailers/solid_errors/error_mailer.rb | 9 ++++----- lib/solid_errors.rb | 6 ++++++ .../mailers/solid_errors/error_mailer_test.rb | 20 +++++++++++++++++++ 5 files changed, 48 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58377ce..6bf8d1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## [Unreleased] +- Say in the error email subject when a resolved error was reopened, and add `email_subject` to build the subject yourself ([@namolnad](https://github.com/fractaledmind/solid_errors/pull/109)) + ## [0.8.0] - 2026-10-13 - Add `enabled` to turn off error tracking in an environment, such as test ([@kulturbande](https://github.com/fractaledmind/solid_errors/pull/96)) diff --git a/README.md b/README.md index 8332469..21435f0 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,7 @@ You can configure Solid Errors via the Rails configuration object, under the `so * `email_to` - The email address(es) to send a notification to. See [Email notifications](#email-notifications) for more information. * `email_every_occurrence` - Whether to email every occurrence (the default), or only new errors and resolved errors that occur again. See [Email notifications](#email-notifications) for more information. * `email_subject_prefix` - Prefix added to the subject line for email notifications. See [Email notifications](#email-notifications) for more information. +* `email_subject` - A proc that builds the subject line for email notifications. See [Email notifications](#email-notifications) for more information. * `base_controller_class` - Specify a different controller as the base class for the Solid Errors controller. See [Authentication](#authentication) for more information. * `destroy_after` - If set, Solid Errors will periodically destroy resolved records that are older than the value specified. See [Automatically destroying old records](#automatically-destroying-old-records) for more information. * `ignored_errors` - Error classes, or their names, to never record, in addition to the built-in list in `SolidErrors::Subscriber::IGNORED_ERRORS`. For example, `config.solid_errors.ignored_errors = ["ActiveStorage::FileNotFoundError"]`. @@ -297,6 +298,21 @@ To be emailed only when an error is new, or when a resolved error occurs again, config.solid_errors.email_every_occurrence = false # defaults to true ``` +The subject names the error, and says when a resolved error has occurred again (`🔥 TimeoutError (reopened)`). To build it yourself, set `email_subject` to a proc. It is called with the error, the occurrence, and `reason`: `:created` or `:reopened` when the occurrence opened its error, `nil` for any other. `email_subject_prefix` is still added in front of what it returns. + +```ruby +config.solid_errors.email_subject = ->(error:, occurrence:, reason:) { + # Counting up to this occurrence, rather than all of them, gives the same answer however late the email is sent + count = error.occurrences.where(id: ..occurrence.id).count + subject = "#{error.severity_emoji} #{error.exception_class}" + + if reason == :reopened then "🔄 REOPENED: #{subject}" + elsif count.in?([10, 100, 1_000]) then "#{subject} (#{count.ordinalize} occurrence)" + else subject + end +} +``` + #### Lifecycle events Solid Errors publishes [Active Support notifications](https://guides.rubyonrails.org/active_support_instrumentation.html) once each change is committed, so you can send errors to Slack, a webhook or anywhere else: diff --git a/app/mailers/solid_errors/error_mailer.rb b/app/mailers/solid_errors/error_mailer.rb index bc22343..035d4a9 100644 --- a/app/mailers/solid_errors/error_mailer.rb +++ b/app/mailers/solid_errors/error_mailer.rb @@ -12,16 +12,15 @@ class ErrorMailer < (defined?(ActionMailer::Base) ? ActionMailer::Base : Object) helper_method :occurrence_url end - # opens_error is :reopened when this occurrence brought a resolved error back, so a fix that - # didn't hold reads differently from a brand-new error - def error_occurred(occurrence, opens_error = nil) + # reason is passed on to SolidErrors.email_subject: :created or :reopened when the occurrence + # opened its error + def error_occurred(occurrence, reason = 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}" - subject += " (reopened)" if opens_error&.to_sym == :reopened + subject = SolidErrors.email_subject.call(error: @error, occurrence: @occurrence, reason: reason&.to_sym) if SolidErrors.email_subject_prefix.present? subject = [SolidErrors.email_subject_prefix, subject].join(" ").squish! end diff --git a/lib/solid_errors.rb b/lib/solid_errors.rb index 7e9b4ff..14c44a6 100644 --- a/lib/solid_errors.rb +++ b/lib/solid_errors.rb @@ -16,6 +16,12 @@ module SolidErrors mattr_accessor :email_to mattr_accessor :email_subject_prefix mattr_accessor :email_every_occurrence, default: true + # Builds the error email's subject, before email_subject_prefix. reason is :created or :reopened when + # the occurrence opened its error, and nil for any other occurrence + mattr_accessor :email_subject, default: ->(error:, occurrence:, reason:) { + subject = "#{error.severity_emoji} #{error.exception_class}" + (reason == :reopened) ? "#{subject} (reopened)" : subject + } mattr_accessor :destroy_after mattr_accessor :ignored_errors, default: [] mattr_writer :url_helper_name, :url_options diff --git a/test/mailers/solid_errors/error_mailer_test.rb b/test/mailers/solid_errors/error_mailer_test.rb index 7925f77..f86f4d7 100644 --- a/test/mailers/solid_errors/error_mailer_test.rb +++ b/test/mailers/solid_errors/error_mailer_test.rb @@ -2,6 +2,7 @@ class SolidErrors::ErrorMailerTest < ActionMailer::TestCase DeliveryFailed = Class.new(StandardError) + DEFAULT_EMAIL_SUBJECT = SolidErrors.email_subject class FailingDelivery def initialize(_settings) @@ -44,6 +45,25 @@ def deliver!(_mail) assert_equal "⚠️ StandardError (reopened)", SolidErrors::ErrorMailer.error_occurred(occurrence, :reopened).subject end + test "the subject can be built by the app, before the prefix" do + report_raised_error("oof") + occurrence = SolidErrors::Occurrence.last + received = nil + SolidErrors.email_subject_prefix = "[App]" + SolidErrors.email_subject = ->(**args) { + received = args + "#{args[:error].exception_class} (#{args[:error].occurrences.where(id: ..args[:occurrence].id).count.ordinalize})" + } + + email = SolidErrors::ErrorMailer.error_occurred(occurrence, :created) + + assert_equal "[App] StandardError (1st)", email.subject + assert_equal({error: occurrence.error, occurrence: occurrence, reason: :created}, received) + ensure + SolidErrors.email_subject_prefix = nil + SolidErrors.email_subject = DEFAULT_EMAIL_SUBJECT + end + test "a reopened error is emailed as reopened" do with_emails do report_raised_error("oof") From 8aa473ea6733d810361d2a354bde581d95caf13f Mon Sep 17 00:00:00 2001 From: Dan Loman Date: Tue, 13 Oct 2026 09:41:13 -0700 Subject: [PATCH 3/3] Document sending the error email for an app's own events Whatever reason an app passes to error_occurred already reaches email_subject. Saying so lets an app build resolved or rate-spike emails on the lifecycle notifications and still word their subjects in one place. --- CHANGELOG.md | 2 +- README.md | 17 ++++++++++++++--- app/mailers/solid_errors/error_mailer.rb | 2 +- lib/solid_errors.rb | 2 +- test/mailers/solid_errors/error_mailer_test.rb | 11 +++++++++++ 5 files changed, 28 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bf8d1c..039f145 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ ## [Unreleased] -- Say in the error email subject when a resolved error was reopened, and add `email_subject` to build the subject yourself ([@namolnad](https://github.com/fractaledmind/solid_errors/pull/109)) +- Say in the error email subject when a resolved error was reopened, and add `email_subject` to build the subject yourself, including for error emails an app sends for its own events ([@namolnad](https://github.com/fractaledmind/solid_errors/pull/109)) ## [0.8.0] - 2026-10-13 diff --git a/README.md b/README.md index 21435f0..36a7c2a 100644 --- a/README.md +++ b/README.md @@ -306,13 +306,24 @@ config.solid_errors.email_subject = ->(error:, occurrence:, reason:) { count = error.occurrences.where(id: ..occurrence.id).count subject = "#{error.severity_emoji} #{error.exception_class}" - if reason == :reopened then "🔄 REOPENED: #{subject}" - elsif count.in?([10, 100, 1_000]) then "#{subject} (#{count.ordinalize} occurrence)" - else subject + case reason + when :reopened then "🔄 REOPENED: #{subject}" + when :resolved then "✅ RESOLVED: #{error.exception_class}" + else count.in?([10, 100, 1_000]) ? "#{subject} (#{count.ordinalize} occurrence)" : subject end } ``` +The error email can also be sent for events of your own, such as an error being resolved or occurring unusually often. Whatever reason you pass reaches `email_subject`, as `:resolved` does in the example above: + +```ruby +# config/initializers/solid_errors.rb +ActiveSupport::Notifications.subscribe("error_resolved.solid_errors") do |event| + occurrence = event.payload[:error].occurrences.last + SolidErrors::ErrorMailer.error_occurred(occurrence, :resolved).deliver_later if occurrence && SolidErrors.send_emails? +end +``` + #### Lifecycle events Solid Errors publishes [Active Support notifications](https://guides.rubyonrails.org/active_support_instrumentation.html) once each change is committed, so you can send errors to Slack, a webhook or anywhere else: diff --git a/app/mailers/solid_errors/error_mailer.rb b/app/mailers/solid_errors/error_mailer.rb index 035d4a9..964403e 100644 --- a/app/mailers/solid_errors/error_mailer.rb +++ b/app/mailers/solid_errors/error_mailer.rb @@ -13,7 +13,7 @@ class ErrorMailer < (defined?(ActionMailer::Base) ? ActionMailer::Base : Object) end # reason is passed on to SolidErrors.email_subject: :created or :reopened when the occurrence - # opened its error + # opened its error, or whatever an app sending this email for its own events passes def error_occurred(occurrence, reason = nil) unless defined?(ActionMailer::Base) raise "ActionMailer is not available. Make sure that you require \"action_mailer/railtie\" in application.rb" diff --git a/lib/solid_errors.rb b/lib/solid_errors.rb index 14c44a6..82f1f99 100644 --- a/lib/solid_errors.rb +++ b/lib/solid_errors.rb @@ -17,7 +17,7 @@ module SolidErrors mattr_accessor :email_subject_prefix mattr_accessor :email_every_occurrence, default: true # Builds the error email's subject, before email_subject_prefix. reason is :created or :reopened when - # the occurrence opened its error, and nil for any other occurrence + # the occurrence opened its error, nil for any other occurrence, or the app's own reason when it sends the email itself mattr_accessor :email_subject, default: ->(error:, occurrence:, reason:) { subject = "#{error.severity_emoji} #{error.exception_class}" (reason == :reopened) ? "#{subject} (reopened)" : subject diff --git a/test/mailers/solid_errors/error_mailer_test.rb b/test/mailers/solid_errors/error_mailer_test.rb index f86f4d7..34df7fb 100644 --- a/test/mailers/solid_errors/error_mailer_test.rb +++ b/test/mailers/solid_errors/error_mailer_test.rb @@ -64,6 +64,17 @@ def deliver!(_mail) SolidErrors.email_subject = DEFAULT_EMAIL_SUBJECT end + test "an app sending the email for its own event gets its reason in the subject proc" do + report_raised_error("oof") + SolidErrors.email_subject = ->(error:, occurrence:, reason:) { "#{error.exception_class} #{reason}" } + + email = SolidErrors::ErrorMailer.error_occurred(SolidErrors::Occurrence.last, :resolved) + + assert_equal "StandardError resolved", email.subject + ensure + SolidErrors.email_subject = DEFAULT_EMAIL_SUBJECT + end + test "a reopened error is emailed as reopened" do with_emails do report_raised_error("oof")