diff --git a/CHANGELOG.md b/CHANGELOG.md index 60d2e46..97abcad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## [Unreleased] +- 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)) - Leave the dashboard stylesheet out of the error email ([@namolnad](https://github.com/fractaledmind/solid_errors/pull/110)) ## [0.8.0] - 2026-10-13 diff --git a/README.md b/README.md index a84cf03..ad48caf 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,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"]`. @@ -304,6 +305,32 @@ 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}" + + 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 2d1f106..964403e 100644 --- a/app/mailers/solid_errors/error_mailer.rb +++ b/app/mailers/solid_errors/error_mailer.rb @@ -12,13 +12,15 @@ class ErrorMailer < (defined?(ActionMailer::Base) ? ActionMailer::Base : Object) helper_method :occurrence_url end - def error_occurred(occurrence) + # reason is passed on to SolidErrors.email_subject: :created or :reopened when the occurrence + # 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" end @occurrence = occurrence @error = occurrence.error - subject = "#{@error.severity_emoji} #{@error.exception_class}" + 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/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/lib/solid_errors.rb b/lib/solid_errors.rb index 6997dce..458ab4c 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, 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 + } 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 379765e..4fffe3e 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) @@ -46,6 +47,56 @@ 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 "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 "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") + 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 "the error email links with configured url options" do report_raised_error("oof") occurrence = SolidErrors::Occurrence.last @@ -85,15 +136,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