From 813a16c54da1093c10e219c4dbcd566844885390 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Tue, 28 Jul 2026 12:00:41 +0000 Subject: [PATCH 1/3] fix(rails): filter the Rails error context --- .../sentry/rails/error_reporter_context.rb | 6 +- .../action_controller_subscriber.rb | 4 +- .../action_mailer_subscriber.rb | 4 +- .../log_subscribers/active_job_subscriber.rb | 4 +- .../active_record_subscriber.rb | 4 +- .../rails/log_subscribers/parameter_filter.rb | 47 +----------- .../lib/sentry/rails/parameter_filter.rb | 69 ++++++++++++++++++ .../shared_examples/error_context.rb | 73 ++++++++++++++----- .../app/controllers/hello_controller.rb | 2 + .../rails/error_reporter_context_spec.rb | 39 ++++++++++ .../spec/sentry/rails/log_subscriber_spec.rb | 4 +- sentry-rails/spec/sentry/rails_spec.rb | 2 + 12 files changed, 185 insertions(+), 73 deletions(-) create mode 100644 sentry-rails/lib/sentry/rails/parameter_filter.rb create mode 100644 sentry-rails/spec/sentry/rails/error_reporter_context_spec.rb diff --git a/sentry-rails/lib/sentry/rails/error_reporter_context.rb b/sentry-rails/lib/sentry/rails/error_reporter_context.rb index 4ecfc72e4..2558c91e6 100644 --- a/sentry-rails/lib/sentry/rails/error_reporter_context.rb +++ b/sentry-rails/lib/sentry/rails/error_reporter_context.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "sentry/rails/serializer" +require "sentry/rails/parameter_filter" module Sentry module Rails @@ -12,7 +13,10 @@ def execution_context context = ::ActiveSupport::ExecutionContext.to_h return {} if context.empty? - { "rails.error" => Sentry::Rails::Serializer.serialize(context) } + # Serialize first: the serializer expands Enumerables and Ranges into arrays the + # filter can descend into, and it preserves hash keys, so filtering its output is + # strictly more thorough than filtering the raw context. + { "rails.error" => Sentry::Rails::ParameterFilter.filter_sensitive_params(Sentry::Rails::Serializer.serialize(context)) } end else def execution_context diff --git a/sentry-rails/lib/sentry/rails/log_subscribers/action_controller_subscriber.rb b/sentry-rails/lib/sentry/rails/log_subscribers/action_controller_subscriber.rb index 4b317e564..f54a86932 100644 --- a/sentry-rails/lib/sentry/rails/log_subscribers/action_controller_subscriber.rb +++ b/sentry-rails/lib/sentry/rails/log_subscribers/action_controller_subscriber.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require "sentry/rails/log_subscriber" -require "sentry/rails/log_subscribers/parameter_filter" +require "sentry/rails/parameter_filter" module Sentry module Rails @@ -21,7 +21,7 @@ module LogSubscribers # config.rails.structured_logging.subscribers = { action_controller: Sentry::Rails::LogSubscribers::ActionControllerSubscriber } # end class ActionControllerSubscriber < Sentry::Rails::LogSubscriber - include ParameterFilter + include Sentry::Rails::ParameterFilter # Handle process_action.action_controller events # diff --git a/sentry-rails/lib/sentry/rails/log_subscribers/action_mailer_subscriber.rb b/sentry-rails/lib/sentry/rails/log_subscribers/action_mailer_subscriber.rb index 2d8237fb7..63db9d139 100644 --- a/sentry-rails/lib/sentry/rails/log_subscribers/action_mailer_subscriber.rb +++ b/sentry-rails/lib/sentry/rails/log_subscribers/action_mailer_subscriber.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require "sentry/rails/log_subscriber" -require "sentry/rails/log_subscribers/parameter_filter" +require "sentry/rails/parameter_filter" module Sentry module Rails @@ -20,7 +20,7 @@ module LogSubscribers # config.rails.structured_logging.subscribers = { action_mailer: Sentry::Rails::LogSubscribers::ActionMailerSubscriber } # end class ActionMailerSubscriber < Sentry::Rails::LogSubscriber - include ParameterFilter + include Sentry::Rails::ParameterFilter # Handle deliver.action_mailer events # diff --git a/sentry-rails/lib/sentry/rails/log_subscribers/active_job_subscriber.rb b/sentry-rails/lib/sentry/rails/log_subscribers/active_job_subscriber.rb index da3353894..fc931576d 100644 --- a/sentry-rails/lib/sentry/rails/log_subscribers/active_job_subscriber.rb +++ b/sentry-rails/lib/sentry/rails/log_subscribers/active_job_subscriber.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require "sentry/rails/log_subscriber" -require "sentry/rails/log_subscribers/parameter_filter" +require "sentry/rails/parameter_filter" module Sentry module Rails @@ -20,7 +20,7 @@ module LogSubscribers # config.rails.structured_logging.subscribers = { active_job: Sentry::Rails::LogSubscribers::ActiveJobSubscriber } # end class ActiveJobSubscriber < Sentry::Rails::LogSubscriber - include ParameterFilter + include Sentry::Rails::ParameterFilter # Handle perform.active_job events # diff --git a/sentry-rails/lib/sentry/rails/log_subscribers/active_record_subscriber.rb b/sentry-rails/lib/sentry/rails/log_subscribers/active_record_subscriber.rb index ad0f7d642..d33b333cf 100644 --- a/sentry-rails/lib/sentry/rails/log_subscribers/active_record_subscriber.rb +++ b/sentry-rails/lib/sentry/rails/log_subscribers/active_record_subscriber.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require "sentry/rails/log_subscriber" -require "sentry/rails/log_subscribers/parameter_filter" +require "sentry/rails/parameter_filter" module Sentry module Rails @@ -21,7 +21,7 @@ module LogSubscribers # config.rails.structured_logging.subscribers = { active_record: Sentry::Rails::LogSubscribers::ActiveRecordSubscriber } # end class ActiveRecordSubscriber < Sentry::Rails::LogSubscriber - include ParameterFilter + include Sentry::Rails::ParameterFilter EXCLUDED_NAMES = ["SCHEMA", "TRANSACTION"].freeze EMPTY_ARRAY = [].freeze diff --git a/sentry-rails/lib/sentry/rails/log_subscribers/parameter_filter.rb b/sentry-rails/lib/sentry/rails/log_subscribers/parameter_filter.rb index 8f20cf3ed..d2f5177ee 100644 --- a/sentry-rails/lib/sentry/rails/log_subscribers/parameter_filter.rb +++ b/sentry-rails/lib/sentry/rails/log_subscribers/parameter_filter.rb @@ -1,52 +1,11 @@ # frozen_string_literal: true +require "sentry/rails/parameter_filter" + module Sentry module Rails module LogSubscribers - # Shared utility module for filtering sensitive parameters in log subscribers. - # - # This module provides consistent parameter filtering across all Sentry Rails - # log subscribers, leveraging Rails' built-in parameter filtering when available. - # It automatically detects the correct Rails parameter filtering API based on - # the Rails version and includes the appropriate implementation module. - # - # @example Usage in a log subscriber - # class MySubscriber < Sentry::Rails::LogSubscriber - # include Sentry::Rails::LogSubscribers::ParameterFilter - # - # def my_event(event) - # if Sentry.configuration.send_default_pii && event.payload[:params] - # filtered_params = filter_sensitive_params(event.payload[:params]) - # attributes[:params] = filtered_params unless filtered_params.empty? - # end - # end - # end - module ParameterFilter - EMPTY_HASH = {}.freeze - - if ::Rails.version.to_f >= 6.0 - def self.backend - ActiveSupport::ParameterFilter - end - else - def self.backend - ActionDispatch::Http::ParameterFilter - end - end - - # Filter sensitive parameters from a hash, respecting Rails configuration. - # - # @param params [Hash] The parameters to filter - # @return [Hash] Filtered parameters with sensitive data removed - def filter_sensitive_params(params) - return EMPTY_HASH unless params.is_a?(Hash) - - filter_parameters = ::Rails.application.config.filter_parameters - parameter_filter = ParameterFilter.backend.new(filter_parameters) - - parameter_filter.filter(params) - end - end + ParameterFilter = Sentry::Rails::ParameterFilter end end end diff --git a/sentry-rails/lib/sentry/rails/parameter_filter.rb b/sentry-rails/lib/sentry/rails/parameter_filter.rb new file mode 100644 index 000000000..2fff6bc0a --- /dev/null +++ b/sentry-rails/lib/sentry/rails/parameter_filter.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Sentry + module Rails + # Shared utility for filtering sensitive parameters, leveraging Rails' built-in parameter + # filtering when available. It automatically detects the correct Rails parameter filtering + # API based on the Rails version. + # + # This is public API: it is a supported extension point for custom log subscribers, and it + # is also what the SDK uses to redact the context users attach via `Rails.error.set_context` + # before it is sent as the "rails.error" context. + # + # Mix it in to get `filter_sensitive_params` as an instance method, or call it directly on + # the module. + # + # @example Usage in a log subscriber + # class MySubscriber < Sentry::Rails::LogSubscriber + # include Sentry::Rails::ParameterFilter + # + # def my_event(event) + # if Sentry.configuration.send_default_pii && event.payload[:params] + # filtered_params = filter_sensitive_params(event.payload[:params]) + # attributes[:params] = filtered_params unless filtered_params.empty? + # end + # end + # end + # + # @example Usage as a module function + # Sentry::Rails::ParameterFilter.filter_sensitive_params(password: "hunter2") + # # => { password: "[FILTERED]" } + module ParameterFilter + extend self + + EMPTY_HASH = {}.freeze + + if ::Rails.version.to_f >= 6.0 + def self.backend + ActiveSupport::ParameterFilter + end + else + def self.backend + ActionDispatch::Http::ParameterFilter + end + end + + # Filter sensitive parameters from a hash, respecting Rails configuration. + # + # @param params [Hash] The parameters to filter + # @return [Hash] Filtered parameters with sensitive data removed, or an empty hash if + # +params+ is not a Hash + def filter_sensitive_params(params) + return EMPTY_HASH unless params.is_a?(Hash) + + filter_parameters = ::Rails.application&.config&.filter_parameters + + # Without a booted application there are no filters configured, so there is + # nothing to redact. This runs on the exception-capture path, where raising + # would replace the user's exception with a NoMethodError from the SDK. + return params if filter_parameters.nil? + + parameter_filter = ParameterFilter.backend.new(filter_parameters) + + parameter_filter.filter(params) + end + end + end +end + +require "sentry/rails/log_subscribers/parameter_filter" diff --git a/sentry-rails/spec/active_job/shared_examples/error_context.rb b/sentry-rails/spec/active_job/shared_examples/error_context.rb index 2632858e5..9280af598 100644 --- a/sentry-rails/spec/active_job/shared_examples/error_context.rb +++ b/sentry-rails/spec/active_job/shared_examples/error_context.rb @@ -37,33 +37,70 @@ def perform expect(last_frame.vars).to include(a: "1", b: "0") end - it "includes Rails.error.set_context data attached before the job raises", skip: RAILS_VERSION < 7.0 do - job_with_context = job_fixture do - def perform - Rails.error.set_context( + context "with Rails.error.set_context data attached before the job raises", skip: RAILS_VERSION < 7.0 do + def capture_job_error_with_context(context) + job_with_context = job_fixture do + define_method(:perform) do + Rails.error.set_context(**context) + raise "boom with rails error context" + end + end + + expect do + job_with_context.perform_later + drain + end.to raise_error(RuntimeError, /boom with rails error context/) + + last_sentry_event + end + + it "attaches the context to the captured event" do + event = capture_job_error_with_context( + debug_key: "important_value", + timestamp: Time.utc(2026, 7, 21, 12, 34, 56), + zoned_timestamp: ActiveSupport::TimeZone["Eastern Time (US & Canada)"].parse("2026-07-21 12:34:56"), + date: Date.new(2026, 7, 21) + ) + + expect(event.contexts).to include( + "rails.error" => hash_including( debug_key: "important_value", timestamp: Time.utc(2026, 7, 21, 12, 34, 56), zoned_timestamp: ActiveSupport::TimeZone["Eastern Time (US & Canada)"].parse("2026-07-21 12:34:56"), date: Date.new(2026, 7, 21) ) - raise "boom with rails error context" - end + ) end - expect do - job_with_context.perform_later - drain - end.to raise_error(RuntimeError, /boom with rails error context/) + it "redacts values matching config.filter_parameters" do + event = capture_job_error_with_context( + api_key: "secret-api-key", + nested: { password: "hunter2", safe: "kept" } + ) - event = last_sentry_event + expect(event.contexts).to include( + "rails.error" => hash_including( + api_key: "[FILTERED]", + nested: { password: "[FILTERED]", safe: "kept" } + ) + ) + end - expect(event.contexts).to include( - "rails.error" => hash_including( - debug_key: "important_value", - timestamp: Time.utc(2026, 7, 21, 12, 34, 56), - zoned_timestamp: ActiveSupport::TimeZone["Eastern Time (US & Canada)"].parse("2026-07-21 12:34:56"), - date: Date.new(2026, 7, 21) + it "redacts sensitive values inside non-Hash Enumerables" do + event = capture_job_error_with_context(records: Set[{ password: "hunter2" }]) + + expect(event.contexts).to include( + "rails.error" => hash_including(records: [{ password: "[FILTERED]" }]) ) - ) + end + + it "does not expand the job instance into the context" do + event = capture_job_error_with_context(debug_key: "important_value") + + job = event.to_json_compatible.dig("contexts", "rails.error", "job") + + expect(job).to be_a(String) + expect(job).to match(/# hash_including( debug_key: "important_value", + api_key: "[FILTERED]", + nested: { password: "[FILTERED]", safe: "kept" }, timestamp: Time.utc(2026, 7, 21, 12, 34, 56), zoned_timestamp: ActiveSupport::TimeZone["Eastern Time (US & Canada)"].parse("2026-07-21 12:34:56"), date: Date.new(2026, 7, 21) From 426908c9ab0fb25b26df3cbd643bd265c86a9f46 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Tue, 28 Jul 2026 12:02:15 +0000 Subject: [PATCH 2/3] fix(rails): filter the context emitted by ErrorSubscriber --- .../sentry/rails/error_reporter_context.rb | 19 +++++++++++++---- .../lib/sentry/rails/error_subscriber.rb | 6 +++++- sentry-rails/spec/sentry/rails_spec.rb | 21 +++++++++++++++++++ 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/sentry-rails/lib/sentry/rails/error_reporter_context.rb b/sentry-rails/lib/sentry/rails/error_reporter_context.rb index 2558c91e6..39cee094c 100644 --- a/sentry-rails/lib/sentry/rails/error_reporter_context.rb +++ b/sentry-rails/lib/sentry/rails/error_reporter_context.rb @@ -8,15 +8,26 @@ module Rails module ErrorReporterContext SUPPORTS_EXECUTION_CONTEXT = Gem::Version.new(::Rails.version) >= Gem::Version.new("7.0.0") + # Prepare a context hash for the "rails.error" context entry, so that user data attached + # via `Rails.error.set_context` gets the same treatment regardless of whether the + # exception was handled. + # + # Serialization runs first: the serializer expands Enumerables and Ranges into arrays the + # filter can descend into, and it preserves hash keys, so filtering its output is strictly + # more thorough than filtering the raw context. + # + # @param context [Hash] the raw context + # @return [Hash] the serialized context with sensitive values redacted + def error_context(context) + Sentry::Rails::ParameterFilter.filter_sensitive_params(Sentry::Rails::Serializer.serialize(context)) + end + if SUPPORTS_EXECUTION_CONTEXT def execution_context context = ::ActiveSupport::ExecutionContext.to_h return {} if context.empty? - # Serialize first: the serializer expands Enumerables and Ranges into arrays the - # filter can descend into, and it preserves hash keys, so filtering its output is - # strictly more thorough than filtering the raw context. - { "rails.error" => Sentry::Rails::ParameterFilter.filter_sensitive_params(Sentry::Rails::Serializer.serialize(context)) } + { "rails.error" => error_context(context) } end else def execution_context diff --git a/sentry-rails/lib/sentry/rails/error_subscriber.rb b/sentry-rails/lib/sentry/rails/error_subscriber.rb index 39ba5be42..b89c6a07b 100644 --- a/sentry-rails/lib/sentry/rails/error_subscriber.rb +++ b/sentry-rails/lib/sentry/rails/error_subscriber.rb @@ -1,11 +1,15 @@ # frozen_string_literal: true +require "sentry/rails/error_reporter_context" + module Sentry module Rails # This is not a user-facing class. You should use it with Rails 7.0's error reporter feature and its interfaces. # See https://github.com/rails/rails/blob/main/activesupport/lib/active_support/error_reporter.rb to learn more about reporting APIs. # If you want Sentry to subscribe to the error reporter, please set `config.rails.register_error_subscriber` to `true`. class ErrorSubscriber + include ErrorReporterContext + SKIP_SOURCES = Regexp.union([/.*_cache_store.active_support/]) def report(error, handled:, severity:, context:, source: nil) @@ -29,7 +33,7 @@ def report(error, handled:, severity:, context:, source: nil) hint[:mechanism] ||= Sentry::Mechanism.new(type: Sentry::Rails.integration_name, handled: handled) - Sentry::Rails.capture_exception(error, level: severity, contexts: { "rails.error" => context }, tags: tags, hint: hint) + Sentry::Rails.capture_exception(error, level: severity, contexts: { "rails.error" => error_context(context) }, tags: tags, hint: hint) end end end diff --git a/sentry-rails/spec/sentry/rails_spec.rb b/sentry-rails/spec/sentry/rails_spec.rb index 9061da5e9..a33921ae4 100644 --- a/sentry-rails/spec/sentry/rails_spec.rb +++ b/sentry-rails/spec/sentry/rails_spec.rb @@ -366,6 +366,27 @@ def capture_in_separate_process(exit_code:) expect(event.contexts).to include({ "rails.error" => { foo: "bar" } }) end + it "filters Rails.error.set_context data attached before a handled exception" do + Rails.error.set_context( + debug_key: "important_value", + api_key: "secret-api-key", + nested: { password: "hunter2" } + ) + + Rails.error.handle(severity: :info) { 1/0 } + + expect(transport.events.count).to eq(1) + + event = transport.events.first + expect(event.contexts).to include( + "rails.error" => hash_including( + debug_key: "important_value", + api_key: "[FILTERED]", + nested: { password: "[FILTERED]" } + ) + ) + end + it "skips cache storage sources", skip: Rails.version.to_f < 7.1 do Rails.error.handle(severity: :info, source: "mem_cache_store.active_support") do 1/0 From aeaa508685afff2331be423a6c42b9499eb6e1ef Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Wed, 29 Jul 2026 12:07:48 +0000 Subject: [PATCH 3/3] refactor(rails): rename error_context to sanitize_context --- .../lib/sentry/rails/error_reporter_context.rb | 11 ++++++----- sentry-rails/lib/sentry/rails/error_subscriber.rb | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/sentry-rails/lib/sentry/rails/error_reporter_context.rb b/sentry-rails/lib/sentry/rails/error_reporter_context.rb index 39cee094c..cc417347b 100644 --- a/sentry-rails/lib/sentry/rails/error_reporter_context.rb +++ b/sentry-rails/lib/sentry/rails/error_reporter_context.rb @@ -8,9 +8,10 @@ module Rails module ErrorReporterContext SUPPORTS_EXECUTION_CONTEXT = Gem::Version.new(::Rails.version) >= Gem::Version.new("7.0.0") - # Prepare a context hash for the "rails.error" context entry, so that user data attached - # via `Rails.error.set_context` gets the same treatment regardless of whether the - # exception was handled. + # Serialize a context hash and redact anything matching the application's + # `config.filter_parameters`, so that user data attached via `Rails.error.set_context` + # gets the same treatment whether it reaches us through + # `ActiveSupport::ExecutionContext` or through the error reporter. # # Serialization runs first: the serializer expands Enumerables and Ranges into arrays the # filter can descend into, and it preserves hash keys, so filtering its output is strictly @@ -18,7 +19,7 @@ module ErrorReporterContext # # @param context [Hash] the raw context # @return [Hash] the serialized context with sensitive values redacted - def error_context(context) + def sanitize_context(context) Sentry::Rails::ParameterFilter.filter_sensitive_params(Sentry::Rails::Serializer.serialize(context)) end @@ -27,7 +28,7 @@ def execution_context context = ::ActiveSupport::ExecutionContext.to_h return {} if context.empty? - { "rails.error" => error_context(context) } + { "rails.error" => sanitize_context(context) } end else def execution_context diff --git a/sentry-rails/lib/sentry/rails/error_subscriber.rb b/sentry-rails/lib/sentry/rails/error_subscriber.rb index b89c6a07b..f4964f71a 100644 --- a/sentry-rails/lib/sentry/rails/error_subscriber.rb +++ b/sentry-rails/lib/sentry/rails/error_subscriber.rb @@ -33,7 +33,7 @@ def report(error, handled:, severity:, context:, source: nil) hint[:mechanism] ||= Sentry::Mechanism.new(type: Sentry::Rails.integration_name, handled: handled) - Sentry::Rails.capture_exception(error, level: severity, contexts: { "rails.error" => error_context(context) }, tags: tags, hint: hint) + Sentry::Rails.capture_exception(error, level: severity, contexts: { "rails.error" => sanitize_context(context) }, tags: tags, hint: hint) end end end