From 60c70f17128dccdd336ffe52102cfae1377f2358 Mon Sep 17 00:00:00 2001 From: Stephen Margheim Date: Fri, 16 Oct 2026 12:43:02 +0200 Subject: [PATCH] Record which controller action or job an error came from Rails puts the current controller or job in the error context, but it was stored as "#". It's now the action and its filtered params, or the job's class, id, queue and filtered arguments, and records anywhere in the context are stored as GlobalIDs. Fixes #37. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 11 +++++ lib/solid_errors/sanitizer.rb | 13 ++++++ lib/solid_errors/subscriber.rb | 22 +++++++++- .../app/controllers/reports_controller.rb | 6 +++ test/dummy/app/jobs/report_job.rb | 5 +++ test/dummy/config/application.rb | 2 + test/dummy/config/routes.rb | 2 + test/solid_errors/context_test.rb | 43 +++++++++++++++++++ 8 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 test/dummy/app/controllers/reports_controller.rb create mode 100644 test/dummy/app/jobs/report_job.rb create mode 100644 test/solid_errors/context_test.rb diff --git a/README.md b/README.md index ad48caf..4d72eb9 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,17 @@ Rails.error.handle(context: { user_id: user.id }, severity: :info) do 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 it as: + +* **Requests:** the controller action (`UsersController#show`) and its params, with your `config.filter_parameters` applied. +* **Background jobs:** the job class, its id and queue, and its arguments, also filtered. + +Any Active Record object in the context is stored as its [GlobalID](https://github.com/rails/globalid) (`gid://my-app/User/1`), so you can tell which record was involved. Add your own context with `Rails.error.set_context(...)` or the `context:` option shown above. + +Errors raised in background jobs reach Solid Errors when your job backend runs jobs inside the Rails executor, as Solid Queue, Sidekiq and GoodJob do. You don't need any extra setup for this. ### Configuration You can configure Solid Errors via the Rails configuration object, under the `solid_errors` key. Currently, the following configuration options are available: diff --git a/lib/solid_errors/sanitizer.rb b/lib/solid_errors/sanitizer.rb index 79c3ceb..32a739b 100644 --- a/lib/solid_errors/sanitizer.rb +++ b/lib/solid_errors/sanitizer.rb @@ -49,6 +49,10 @@ def sanitize(data, depth = 0, stack = nil) when String sanitize_string(data) else # all other objects + if (global_id = global_id(data)) + return global_id + end + klass = data.class begin @@ -67,6 +71,15 @@ def sanitize(data, depth = 0, stack = nil) attr_reader :max_depth + # A saved record is identified by its GlobalID (gid://app/User/1) instead of just its class + def global_id(object) + return unless object.respond_to?(:to_global_id) + + object.to_global_id.to_s + rescue + nil + end + def basic_object?(object) object.respond_to?(:to_s) false diff --git a/lib/solid_errors/subscriber.rb b/lib/solid_errors/subscriber.rb index 2b8433d..21b2b6b 100644 --- a/lib/solid_errors/subscriber.rb +++ b/lib/solid_errors/subscriber.rb @@ -42,11 +42,31 @@ def report(error, handled:, severity:, context:, source: nil) SolidErrors::Occurrence.create( error_id: record.id, backtrace: error.backtrace&.join("\n"), - context: s(context), + context: s(readable_context(context)), opens_error: opens_error ) end + # Rails puts the current controller or job in the context; keep what identifies the work, not the object + def readable_context(context) + context = context.dup + if defined?(ActionController::Metal) && (controller = context[:controller]).is_a?(ActionController::Metal) + context[:controller] = "#{controller.class.name}##{controller.action_name}" + context[:params] ||= controller.request.filtered_parameters.except("controller", "action") + end + if defined?(ActiveJob::Base) && (job = context[:job]).is_a?(ActiveJob::Base) + context[:job] = job.class.name + context[:job_id] ||= job.job_id + context[:queue] ||= job.queue_name + context[:arguments] ||= parameter_filter.filter(arguments: s(job.arguments))[:arguments] + end + context + end + + def parameter_filter + ActiveSupport::ParameterFilter.new(Rails.application.config.filter_parameters) + end + def s(data) Sanitizer.sanitize(data) end diff --git a/test/dummy/app/controllers/reports_controller.rb b/test/dummy/app/controllers/reports_controller.rb new file mode 100644 index 0000000..9047824 --- /dev/null +++ b/test/dummy/app/controllers/reports_controller.rb @@ -0,0 +1,6 @@ +class ReportsController < ApplicationController + def create + Rails.error.handle { raise "reported from a request" } + head :ok + end +end diff --git a/test/dummy/app/jobs/report_job.rb b/test/dummy/app/jobs/report_job.rb new file mode 100644 index 0000000..88134be --- /dev/null +++ b/test/dummy/app/jobs/report_job.rb @@ -0,0 +1,5 @@ +class ReportJob < ActiveJob::Base + def perform(record, options) + Rails.error.handle { raise "reported from a job" } + end +end diff --git a/test/dummy/config/application.rb b/test/dummy/config/application.rb index 14009e4..ad5e219 100644 --- a/test/dummy/config/application.rb +++ b/test/dummy/config/application.rb @@ -39,6 +39,8 @@ class Application < Rails::Application config.content_security_policy_nonce_generator = ->(_request) { SecureRandom.base64(16) } config.content_security_policy_nonce_directives = %w[script-src style-src] + config.filter_parameters += [:password] + # Configuration for the application, engines, and railties goes here. # # These settings can be overridden in specific environments using the files diff --git a/test/dummy/config/routes.rb b/test/dummy/config/routes.rb index 85301aa..16ce98b 100644 --- a/test/dummy/config/routes.rb +++ b/test/dummy/config/routes.rb @@ -7,6 +7,8 @@ mount SolidErrors::Engine, at: "/solid_errors" + post "reports" => "reports#create" + # Defines the root path route ("/") root to: "rails/health#show" end diff --git a/test/solid_errors/context_test.rb b/test/solid_errors/context_test.rb new file mode 100644 index 0000000..be589b6 --- /dev/null +++ b/test/solid_errors/context_test.rb @@ -0,0 +1,43 @@ +require "test_helper" + +class SolidErrors::ContextTest < ActionDispatch::IntegrationTest + test "an error in a request records the controller action and its filtered params" do + post "/reports", params: {name: "Ada", password: "secret"} + + context = SolidErrors::Occurrence.last.context + assert_equal "ReportsController#create", context["controller"] + assert_equal({"name" => "Ada", "password" => "[FILTERED]"}, context["params"]) + end + + test "an error in a job records the job and its filtered arguments, with records as global ids" do + record = record_an_error + + job = ReportJob.new(record, {"password" => "secret", "retries" => 3}) + job.perform_now + + context = SolidErrors::Occurrence.last.context + assert_equal ["ReportJob", job.job_id, "default"], context.values_at("job", "job_id", "queue") + assert_equal [record.to_global_id.to_s, {"password" => "[FILTERED]", "retries" => 3}], context["arguments"] + end + + test "a record in the context is recorded as its global id" do + record = record_an_error + + Rails.error.report(StandardError.new("with a record"), context: {error_record: record}) + + assert_equal record.to_global_id.to_s, SolidErrors::Occurrence.last.context["error_record"] + end + + test "an unsaved record in the context is recorded by its class" do + Rails.error.report(StandardError.new("with a new record"), context: {error_record: SolidErrors::Error.new}) + + assert_equal "#", SolidErrors::Occurrence.last.context["error_record"] + end + + private + + def record_an_error + Rails.error.report(StandardError.new("first")) + SolidErrors::Error.last + end +end