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
@@ -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
Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"]`.
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 4 additions & 2 deletions app/mailers/solid_errors/error_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion app/models/solid_errors/occurrence.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions lib/solid_errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 59 additions & 3 deletions test/mailers/solid_errors/error_mailer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

class SolidErrors::ErrorMailerTest < ActionMailer::TestCase
DeliveryFailed = Class.new(StandardError)
DEFAULT_EMAIL_SUBJECT = SolidErrors.email_subject

class FailingDelivery
def initialize(_settings)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Loading