From 359f9451acc22ac59dd41fcdc90a32daca86c9de Mon Sep 17 00:00:00 2001 From: Dan Loman Date: Mon, 19 Oct 2026 08:18:07 -0700 Subject: [PATCH 1/2] Add a fingerprint proc to choose what an error is grouped by Each occurrence also records the message it was raised with in its context, since a grouped error keeps only the first one. --- README.md | 18 ++++++- lib/solid_errors.rb | 5 ++ lib/solid_errors/subscriber.rb | 5 +- test/models/solid_errors/occurrence_test.rb | 2 +- test/solid_errors/subscriber_test.rb | 58 +++++++++++++++++++++ 5 files changed, 84 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5a71eda..3703dd4 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 diff --git a/lib/solid_errors.rb b/lib/solid_errors.rb index 81355fb..fcd4341 100644 --- a/lib/solid_errors.rb +++ b/lib/solid_errors.rb @@ -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 diff --git a/lib/solid_errors/subscriber.rb b/lib/solid_errors/subscriber.rb index 72e7248..11cbfab 100644 --- a/lib/solid_errors/subscriber.rb +++ b/lib/solid_errors/subscriber.rb @@ -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) @@ -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 diff --git a/test/models/solid_errors/occurrence_test.rb b/test/models/solid_errors/occurrence_test.rb index bf1a483..9c8ce67 100644 --- a/test/models/solid_errors/occurrence_test.rb +++ b/test/models/solid_errors/occurrence_test.rb @@ -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 diff --git a/test/solid_errors/subscriber_test.rb b/test/solid_errors/subscriber_test.rb index 68e73e0..27de1b4 100644 --- a/test/solid_errors/subscriber_test.rb +++ b/test/solid_errors/subscriber_test.rb @@ -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 @@ -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 From bdffe40672d4ad60b9ec42c611bbaf64033d642b Mon Sep 17 00:00:00 2001 From: Dan Loman Date: Mon, 19 Oct 2026 09:07:42 -0700 Subject: [PATCH 2/2] Add the fingerprint change to the changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7de7848..be7594c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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))