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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
## [Unreleased]

- Add `fingerprint` to choose what an error is grouped by, and record the message each occurrence was raised with in its context ([@namolnad](https://github.com/fractaledmind/solid_errors/pull/121))

## [0.9.1] - 2026-10-19

- Record request params and job arguments only when `record_params` or `record_arguments` is set. 0.9.0 recorded and emailed them for every occurrence, filtered only by `filter_parameters`, which misses positional arguments, mail delivery jobs and webhook payloads. If that matters for your app, review occurrences recorded under 0.9.0 ([@fractaledmind](https://github.com/fractaledmind/solid_errors/pull/120))
Expand Down
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ end

### What each occurrence records

Every occurrence stores the backtrace and the error's context. Rails adds the current request or job to that context, and Solid Errors records which one it was: the controller action (`UsersController#show`), or the job's class, id and queue.
Every occurrence stores the backtrace, the message it was raised with (as `message` in its context) and the error's context. Rails adds the current request or job to that context, and Solid Errors records which one it was: the controller action (`UsersController#show`), or the job's class, id and queue.

Request params and job arguments are not recorded unless you opt in, because they often carry user content that `config.filter_parameters` can't catch: it matches keys, so positional job arguments, mail delivery jobs (the mail's recipients and body) and webhook payloads would be stored and emailed as they are.

Expand Down Expand Up @@ -190,6 +190,22 @@ You can configure Solid Errors via the Rails configuration object, under the `so
* `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.
* `record_params` / `record_arguments` - Whether to record request params and job arguments with each occurrence. Both default to `false`. See [What each occurrence records](#what-each-occurrence-records).
* `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"]`.
* `fingerprint` - A proc that returns what an error is grouped by. See [Grouping occurrences into errors](#grouping-occurrences-into-errors) for more information.

#### Grouping occurrences into errors

Occurrences are grouped into one error when their exception class, message, severity and source are all the same. When a message carries something that changes every time, such as a request id, each occurrence becomes an error of its own, and one outage shows up as hundreds of errors with one occurrence each.

To group them, set `fingerprint` to a proc. It is called with the exception and the attributes stored for it (`exception_class`, `message`, `severity` and `source`), and the occurrences whose procs return the same string are one error:

```ruby
config.solid_errors.fingerprint = ->(error:, attributes:) {
message = attributes[:message].gsub(/\breq_[A-Za-z0-9]+/, "req_*")
[attributes[:exception_class], message, attributes[:severity], attributes[:source]].join
}
```

Only the grouping changes. The error keeps the message it was first reported with, and every occurrence records the message it was raised with in its context, so each id is still there to look up. Without the setting, errors are grouped exactly as before, so upgrading never regroups existing errors. Setting it does: an error recorded before then gets a new fingerprint, so its next occurrence starts a new error.

### Database Configuration

Expand Down
5 changes: 5 additions & 0 deletions lib/solid_errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ module SolidErrors
}
mattr_accessor :destroy_after
mattr_accessor :ignored_errors, default: []
# What an error is grouped by: occurrences whose text is the same are one error. Called with the exception and the
# attributes stored for it; the default is the class, message, severity and source, as every version before it
mattr_accessor :fingerprint, default: ->(error:, attributes:) {
attributes.values_at(:exception_class, :message, :severity, :source).join
}
# Request params and job arguments carry user content that filter_parameters' key matching misses
mattr_accessor :record_params, default: false
mattr_accessor :record_arguments, default: false
Expand Down
5 changes: 3 additions & 2 deletions lib/solid_errors/subscriber.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def report(error, handled:, severity:, context:, source: nil)
severity: severity,
source: source
}
fingerprint = Digest::SHA256.hexdigest(error_attributes.values.join)
fingerprint = Digest::SHA256.hexdigest(SolidErrors.fingerprint.call(error: error, attributes: error_attributes).to_s)
if (record = SolidErrors::Error.find_by(fingerprint: fingerprint))
opens_error = :reopened if record.resolved?
record.update!(resolved_at: nil, updated_at: Time.now)
Expand All @@ -42,7 +42,8 @@ def report(error, handled:, severity:, context:, source: nil)
SolidErrors::Occurrence.create(
error_id: record.id,
backtrace: error.backtrace&.join("\n"),
context: s(readable_context(context)),
# The message as raised, which the error keeps only for the occurrence that created it
context: s({message: error.message}.merge(readable_context(context))),
opens_error: opens_error
)
end
Expand Down
2 changes: 1 addition & 1 deletion test/models/solid_errors/occurrence_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def teardown

Rails.error.report(StandardError.new("oof"), context: {user_id: 1})

assert_equal({"user_id" => 1}, SolidErrors::Occurrence.last.reload.context)
assert_equal({"message" => "oof", "user_id" => 1}, SolidErrors::Occurrence.last.reload.context)
ensure
SolidErrors::Occurrence.reset_column_information
end
Expand Down
58 changes: 58 additions & 0 deletions test/solid_errors/subscriber_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@
class SolidErrors::SubscriberTest < ActiveSupport::TestCase
CustomError = Class.new(StandardError)

setup do
@default_fingerprint = SolidErrors.fingerprint
end

teardown do
SolidErrors.ignored_errors = []
SolidErrors.fingerprint = @default_fingerprint
end

test "errors named in ignored_errors are not recorded while others are" do
Expand All @@ -31,4 +36,57 @@ class SolidErrors::SubscriberTest < ActiveSupport::TestCase

assert_empty SolidErrors::Error.all
end

test "the default fingerprint is the one earlier versions computed, so upgrading regroups nothing" do
Rails.error.report(StandardError.new("boom"), severity: :error, source: "application.job")

expected = Digest::SHA256.hexdigest(["StandardError", "boom", "error", "application.job"].join)
assert_equal expected, SolidErrors::Error.sole.fingerprint
end

test "a fingerprint proc groups messages that differ only in an id, and keeps each message as reported" do
SolidErrors.fingerprint = ->(error:, attributes:) {
attributes.merge(message: attributes[:message].gsub(/\breq_\w+/, "req_*")).values.join
}

Rails.error.report(StandardError.new("upstream failed (req_abc123)"))
Rails.error.report(StandardError.new("upstream failed (req_def456)"))
Rails.error.report(StandardError.new("something else failed (req_abc123)"))

errors = SolidErrors::Error.order(:id).map { |error| [error.message, error.occurrences.count] }
assert_equal [["upstream failed (req_abc123)", 2], ["something else failed (req_abc123)", 1]], errors
end

test "each occurrence keeps the message it was raised with, which a grouped error does not" do
SolidErrors.fingerprint = ->(error:, attributes:) {
attributes.merge(message: attributes[:message].gsub(/\breq_\w+/, "req_*")).values.join
}

Rails.error.report(StandardError.new("upstream failed (req_abc123)"))
Rails.error.report(StandardError.new("upstream failed (req_def456)"))

messages = SolidErrors::Error.sole.occurrences.order(:id).map { |occurrence| occurrence.context["message"] }
assert_equal ["upstream failed (req_abc123)", "upstream failed (req_def456)"], messages
end

test "an occurrence records its message whether or not anything is grouped" do
Rails.error.report(StandardError.new("plain failure"))

assert_equal "plain failure", SolidErrors::Occurrence.sole.context["message"]
end

test "a message the app put in the context is the one kept" do
Rails.error.report(StandardError.new("raised"), context: {message: "the app said"})

assert_equal "the app said", SolidErrors::Occurrence.sole.context["message"]
end

test "the fingerprint proc is given the exception" do
SolidErrors.fingerprint = ->(error:, attributes:) { error.class.name }

Rails.error.report(CustomError.new("one"))
Rails.error.report(CustomError.new("two"))

assert_equal 2, SolidErrors::Error.sole.occurrences.count
end
end
Loading