diff --git a/sentry-rails/lib/sentry/rails/error_reporter_context.rb b/sentry-rails/lib/sentry/rails/error_reporter_context.rb index 4ecfc72e4..928c9cb30 100644 --- a/sentry-rails/lib/sentry/rails/error_reporter_context.rb +++ b/sentry-rails/lib/sentry/rails/error_reporter_context.rb @@ -1,18 +1,35 @@ # frozen_string_literal: true require "sentry/rails/serializer" +require "sentry/rails/parameter_filter" module Sentry 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. + # + # Every code path that emits that entry must go through here, 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 self.rails_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? - { "rails.error" => Sentry::Rails::Serializer.serialize(context) } + { "rails.error" => ErrorReporterContext.rails_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..66f1e7172 100644 --- a/sentry-rails/lib/sentry/rails/error_subscriber.rb +++ b/sentry-rails/lib/sentry/rails/error_subscriber.rb @@ -1,5 +1,7 @@ # 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. @@ -29,7 +31,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" => ErrorReporterContext.rails_error_context(context) }, tags: tags, hint: hint) end end end 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..856e72b3d 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,15 @@ # 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 + # @deprecated Use {Sentry::Rails::ParameterFilter} instead. This alias keeps custom log + # subscribers that include the old constant working, and will be removed in the next + # major version. + ParameterFilter = Sentry::Rails::ParameterFilter + deprecate_constant :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..0cf5d1ea8 100644 --- a/sentry-rails/spec/active_job/shared_examples/error_context.rb +++ b/sentry-rails/spec/active_job/shared_examples/error_context.rb @@ -42,6 +42,8 @@ def perform def perform Rails.error.set_context( debug_key: "important_value", + api_key: "secret-api-key", + nested: { password: "hunter2" }, 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) @@ -60,6 +62,8 @@ def perform expect(event.contexts).to include( "rails.error" => hash_including( debug_key: "important_value", + api_key: "[FILTERED]", + nested: { password: "[FILTERED]" }, 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) diff --git a/sentry-rails/spec/dummy/test_rails_app/app/controllers/hello_controller.rb b/sentry-rails/spec/dummy/test_rails_app/app/controllers/hello_controller.rb index 43e8b1838..3d246eb73 100644 --- a/sentry-rails/spec/dummy/test_rails_app/app/controllers/hello_controller.rb +++ b/sentry-rails/spec/dummy/test_rails_app/app/controllers/hello_controller.rb @@ -10,6 +10,8 @@ def exception def exception_with_error_context Rails.error.set_context( debug_key: "important_value", + api_key: "secret-api-key", + nested: { password: "hunter2", 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) diff --git a/sentry-rails/spec/sentry/rails/error_reporter_context_spec.rb b/sentry-rails/spec/sentry/rails/error_reporter_context_spec.rb new file mode 100644 index 000000000..0af731c81 --- /dev/null +++ b/sentry-rails/spec/sentry/rails/error_reporter_context_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require "spec_helper" +require "sentry/rails/active_job" + +RSpec.describe Sentry::Rails::ErrorReporterContext do + describe "the objects that mix it in" do + it "does not expose parameter filtering on the CaptureExceptions middleware" do + middleware = Sentry::Rails::CaptureExceptions.new(->(_env) { [200, {}, []] }) + + expect(middleware).not_to respond_to(:filter_sensitive_params) + end + + it "does not expose parameter filtering on the ActiveJob reporter" do + expect(Sentry::Rails::ActiveJobExtensions::SentryReporter).not_to respond_to(:filter_sensitive_params) + end + end + + describe "#execution_context", skip: Rails.version.to_f < 7.0 do + subject(:context) do + Class.new { include Sentry::Rails::ErrorReporterContext }.new.execution_context["rails.error"] + end + + before { make_basic_app } + + it "filters sensitive values the serializer expands out of an Enumerable" do + Rails.error.set_context(records: Set[{ password: "hunter2" }]) + + expect(context).to eq(records: [{ password: "[FILTERED]" }]) + end + + it "filters sensitive values nested under an Enumerable" do + Rails.error.set_context(audit: { entries: Set[{ api_key: "secret-api-key" }] }) + + expect(context).to eq(audit: { entries: [{ api_key: "[FILTERED]" }] }) + end + + it "still passes non-enumerable values through the serializer untouched" do + Rails.error.set_context( + debug_key: "important_value", + timestamp: Time.utc(2026, 7, 21, 12, 34, 56), + date: Date.new(2026, 7, 21) + ) + + expect(context).to include( + debug_key: "important_value", + timestamp: Time.utc(2026, 7, 21, 12, 34, 56), + date: Date.new(2026, 7, 21) + ) + end + end +end diff --git a/sentry-rails/spec/sentry/rails/log_subscriber_spec.rb b/sentry-rails/spec/sentry/rails/log_subscriber_spec.rb index 7b0844a09..99537d750 100644 --- a/sentry-rails/spec/sentry/rails/log_subscriber_spec.rb +++ b/sentry-rails/spec/sentry/rails/log_subscriber_spec.rb @@ -3,7 +3,7 @@ require "spec_helper" require "sentry/rails/log_subscriber" -require "sentry/rails/log_subscribers/parameter_filter" +require "sentry/rails/parameter_filter" RSpec.describe Sentry::Rails::LogSubscriber, type: :request do let!(:test_subscriber) { test_subscriber_class.new } @@ -218,7 +218,7 @@ def temp_event(event) context "parameter filtering integration" do let(:test_subscriber_class) do Class.new(described_class) do - include Sentry::Rails::LogSubscribers::ParameterFilter + include Sentry::Rails::ParameterFilter attach_to :filtering_test diff --git a/sentry-rails/spec/sentry/rails/parameter_filter_spec.rb b/sentry-rails/spec/sentry/rails/parameter_filter_spec.rb new file mode 100644 index 000000000..37d0f1568 --- /dev/null +++ b/sentry-rails/spec/sentry/rails/parameter_filter_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Sentry::Rails::ParameterFilter do + context "when mixed into a class" do + before { make_basic_app } + + include_examples "parameter filtering", Class.new { include Sentry::Rails::ParameterFilter } + end + + it "is also callable directly on the module" do + make_basic_app + + expect(described_class.filter_sensitive_params(password: "hunter2", safe: "kept")) + .to eq(password: "[FILTERED]", safe: "kept") + end + + describe "#filter_sensitive_params" do + let(:includer) { Class.new { include Sentry::Rails::ParameterFilter }.new } + + context "when the Rails application is not available" do + before { allow(::Rails).to receive(:application).and_return(nil) } + + it "returns the params instead of raising" do + params = { password: "hunter2" } + + expect { includer.filter_sensitive_params(params) }.not_to raise_error + expect(includer.filter_sensitive_params(params)).to eq(params) + end + end + end + + describe "Sentry::Rails::LogSubscribers::ParameterFilter" do + it "still resolves to the module for backwards compatibility" do + expect(Sentry::Rails::LogSubscribers::ParameterFilter).to be(described_class) + end + end +end diff --git a/sentry-rails/spec/sentry/rails_spec.rb b/sentry-rails/spec/sentry/rails_spec.rb index 8a002ca8a..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 @@ -383,6 +404,8 @@ def capture_in_separate_process(exit_code:) expect(event.contexts).to include( "rails.error" => 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)