diff --git a/.changeset/wise-jobs-raise.md b/.changeset/wise-jobs-raise.md new file mode 100644 index 0000000..ea68032 --- /dev/null +++ b/.changeset/wise-jobs-raise.md @@ -0,0 +1,5 @@ +--- +'posthog-rails': patch +--- + +Avoid double-capturing ActiveJob exceptions through the Rails error subscriber. diff --git a/posthog-rails/lib/posthog/rails.rb b/posthog-rails/lib/posthog/rails.rb index 7daac29..76537d4 100644 --- a/posthog-rails/lib/posthog/rails.rb +++ b/posthog-rails/lib/posthog/rails.rb @@ -20,6 +20,9 @@ module PostHog module Rails # Thread-local key for tracking web request context IN_WEB_REQUEST_KEY = :posthog_in_web_request + ACTIVE_JOB_CAPTURED_EXCEPTION_IVAR = :@posthog_active_job_exception_captured + + private_constant :ACTIVE_JOB_CAPTURED_EXCEPTION_IVAR class << self # @return [PostHog::Rails::Configuration] Rails integration configuration. @@ -60,6 +63,28 @@ def exit_web_request def in_web_request? Thread.current[IN_WEB_REQUEST_KEY] == true end + + # Mark an exception as already captured by the ActiveJob integration. + # Used by ErrorSubscriber to avoid duplicate captures when Rails reports + # the same re-raised job exception via Rails.error. + # @api private + # @param exception [Exception] + # @return [void] + def mark_active_job_exception_captured(exception) + exception.instance_variable_set(ACTIVE_JOB_CAPTURED_EXCEPTION_IVAR, true) + rescue StandardError + nil + end + + # Check whether an exception was already captured by ActiveJobExtensions. + # @api private + # @param exception [Exception] + # @return [Boolean] + def active_job_exception_captured?(exception) + exception.instance_variable_get(ACTIVE_JOB_CAPTURED_EXCEPTION_IVAR) == true + rescue StandardError + false + end end end end diff --git a/posthog-rails/lib/posthog/rails/active_job.rb b/posthog-rails/lib/posthog/rails/active_job.rb index a7842f8..b621ae8 100644 --- a/posthog-rails/lib/posthog/rails/active_job.rb +++ b/posthog-rails/lib/posthog/rails/active_job.rb @@ -70,6 +70,7 @@ def capture_job_exception(exception) properties, mechanism: { 'type' => 'active_job', 'handled' => false } ) + PostHog::Rails.mark_active_job_exception_captured(exception) rescue StandardError => e # Don't let PostHog errors break job processing PostHog::Logging.logger.error("Failed to capture job exception: #{e.message}") diff --git a/posthog-rails/lib/posthog/rails/error_subscriber.rb b/posthog-rails/lib/posthog/rails/error_subscriber.rb index e402bcb..48d8a0e 100644 --- a/posthog-rails/lib/posthog/rails/error_subscriber.rb +++ b/posthog-rails/lib/posthog/rails/error_subscriber.rb @@ -23,6 +23,8 @@ def report(error, handled:, severity:, context:, source: nil) # Skip if in a web request - CaptureExceptions middleware will handle it # with richer context (URL, params, controller, etc.) return if PostHog::Rails.in_web_request? + return if PostHog::Rails.config&.auto_instrument_active_job && + PostHog::Rails.active_job_exception_captured?(error) distinct_id = context[:user_id] || context[:distinct_id] diff --git a/spec/posthog/rails/exception_mechanism_spec.rb b/spec/posthog/rails/exception_mechanism_spec.rb index c38a915..2c92f00 100644 --- a/spec/posthog/rails/exception_mechanism_spec.rb +++ b/spec/posthog/rails/exception_mechanism_spec.rb @@ -62,6 +62,41 @@ ) end end + + it 'skips exceptions already captured by ActiveJobExtensions' do + PostHog::Rails.config.auto_instrument_active_job = true + error = StandardError.new('boom') + PostHog::Rails.mark_active_job_exception_captured(error) + + described_class.new.report( + error, + handled: false, + severity: :error, + context: {}, + source: 'application.active_support' + ) + + expect(PostHog).not_to have_received(:capture_exception) + end + + it 'still captures unmarked ActiveSupport reports when ActiveJob instrumentation is enabled' do + PostHog::Rails.config.auto_instrument_active_job = true + + described_class.new.report( + StandardError.new('boom'), + handled: false, + severity: :error, + context: {}, + source: 'application.active_support' + ) + + expect(PostHog).to have_received(:capture_exception).with( + an_instance_of(StandardError), + anything, + hash_including('$exception_source' => 'application.active_support'), + mechanism: { 'type' => 'rails_error_reporter', 'handled' => false } + ) + end end describe PostHog::Rails::ActiveJobExtensions do @@ -112,5 +147,32 @@ def perform_now mechanism: { 'type' => 'active_job', 'handled' => false } ) end + + it 'prevents Rails error subscriber from capturing the same job exception again' do + PostHog::Rails.config.auto_instrument_active_job = true + error = nil + + begin + job_class.new.perform_now + rescue StandardError => e + error = e + end + + PostHog::Rails::ErrorSubscriber.new.report( + error, + handled: false, + severity: :error, + context: {}, + source: 'application.active_support' + ) + + expect(PostHog).to have_received(:capture_exception).once + expect(PostHog).to have_received(:capture_exception).with( + an_instance_of(StandardError), + anything, + hash_including('$exception_source' => 'active_job'), + mechanism: { 'type' => 'active_job', 'handled' => false } + ) + end end end