From 521565cf0d7ece00cba0a625c4558ed5d8782800 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 7 May 2026 12:31:48 +0000 Subject: [PATCH 01/18] feat(rails): add messaging span data to ActiveJob consumer transaction Sets messaging.message.id, messaging.destination.name, messaging.message.retry.count, and messaging.message.receive.latency on the consumer transaction, mirroring sentry-sidekiq's middleware. Adds an opt-in shared example that adapters can include to verify the data fields are populated correctly. Co-Authored-By: Claude Opus 4.7 (1M context) --- sentry-rails/lib/sentry/rails/active_job.rb | 25 ++++++++- .../tracing/messaging_span_data.rb | 56 +++++++++++++++++++ .../spec/active_job/support/harness.rb | 8 +++ .../spec/active_job/test_adapter_spec.rb | 1 + 4 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 sentry-rails/spec/active_job/shared_examples/tracing/messaging_span_data.rb diff --git a/sentry-rails/lib/sentry/rails/active_job.rb b/sentry-rails/lib/sentry/rails/active_job.rb index df7f27ecd..93e2ae840 100644 --- a/sentry-rails/lib/sentry/rails/active_job.rb +++ b/sentry-rails/lib/sentry/rails/active_job.rb @@ -40,7 +40,10 @@ def record(job, &block) origin: SPAN_ORIGIN ) - scope.set_span(transaction) if transaction + if transaction + set_messaging_data(transaction, job) + scope.set_span(transaction) + end yield.tap do finish_sentry_transaction(transaction, 200) @@ -55,6 +58,26 @@ def record(job, &block) end end + def set_messaging_data(transaction, job) + transaction.set_data(Sentry::Span::DataConventions::MESSAGING_MESSAGE_ID, job.job_id) + transaction.set_data(Sentry::Span::DataConventions::MESSAGING_DESTINATION_NAME, job.queue_name) + + if job.executions && job.executions > 1 + transaction.set_data(Sentry::Span::DataConventions::MESSAGING_MESSAGE_RETRY_COUNT, job.executions - 1) + end + + if (latency = compute_latency(job)) + transaction.set_data(Sentry::Span::DataConventions::MESSAGING_MESSAGE_RECEIVE_LATENCY, latency) + end + end + + def compute_latency(job) + return unless job.respond_to?(:enqueued_at) && job.enqueued_at + + enqueued_time = job.enqueued_at.is_a?(String) ? Time.parse(job.enqueued_at) : job.enqueued_at + ((Time.now.to_f - enqueued_time.to_f) * 1000).round + end + def capture_exception(job, e) Sentry::Rails.capture_exception( e, diff --git a/sentry-rails/spec/active_job/shared_examples/tracing/messaging_span_data.rb b/sentry-rails/spec/active_job/shared_examples/tracing/messaging_span_data.rb new file mode 100644 index 000000000..e68f6e184 --- /dev/null +++ b/sentry-rails/spec/active_job/shared_examples/tracing/messaging_span_data.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +RSpec.shared_examples "an ActiveJob backend that records messaging span data on the consumer transaction" do + include ActiveSupport::Testing::TimeHelpers + + let(:successful_job) do + job_fixture do + def perform; end + end + end + + let(:configure_sentry) { proc { |config| config.traces_sample_rate = 1.0 } } + + it "records messaging.message.id and messaging.destination.name on the consumer transaction" do + successful_job.set(queue: "critical").perform_later + drain + + data = consumer_transaction.contexts.dig(:trace, :data) + expect(data["messaging.message.id"]).to be_a(String).and(satisfy { |v| !v.empty? }) + expect(data["messaging.destination.name"]).to eq("critical") + end + + it "omits messaging.message.retry.count on the first execution" do + successful_job.perform_later + drain + + data = consumer_transaction.contexts.dig(:trace, :data) + expect(data).not_to have_key("messaging.message.retry.count") + end + + it "records messaging.message.retry.count = executions - 1 on retried executions" do + klass = job_fixture do + def perform; end + end + + allow_any_instance_of(klass).to receive(:executions).and_return(3) + + klass.perform_later + drain + + data = consumer_transaction.contexts.dig(:trace, :data) + expect(data["messaging.message.retry.count"]).to eq(2) + end + + it "records messaging.message.receive.latency in milliseconds", skip: RAILS_VERSION < 6.1 do + successful_job.perform_later + + travel(5.seconds, with_usec: true) do + drain + end + + latency = consumer_transaction.contexts.dig(:trace, :data, "messaging.message.receive.latency") + expect(latency).to be_a(Integer) + expect(latency).to be_within(50).of(5_000) + end +end diff --git a/sentry-rails/spec/active_job/support/harness.rb b/sentry-rails/spec/active_job/support/harness.rb index 4e489fb20..27dd00793 100644 --- a/sentry-rails/spec/active_job/support/harness.rb +++ b/sentry-rails/spec/active_job/support/harness.rb @@ -53,4 +53,12 @@ def job_fixture(name = nil, &block) stub_const(name, klass) klass end + + def transactions + sentry_events.select { |e| e.is_a?(Sentry::TransactionEvent) } + end + + def consumer_transaction + transactions.find { |t| t.contexts.dig(:trace, :op) == "queue.active_job" } + end end diff --git a/sentry-rails/spec/active_job/test_adapter_spec.rb b/sentry-rails/spec/active_job/test_adapter_spec.rb index 4d5e704de..cdfd338a9 100644 --- a/sentry-rails/spec/active_job/test_adapter_spec.rb +++ b/sentry-rails/spec/active_job/test_adapter_spec.rb @@ -6,4 +6,5 @@ include_context "active_job backend harness", adapter: :test it_behaves_like "a Sentry-instrumented ActiveJob backend" + it_behaves_like "an ActiveJob backend that records messaging span data on the consumer transaction" end From 4201e41bd8ae7fa13c6f5ec42fc32324f9dca8cd Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 7 May 2026 12:34:23 +0000 Subject: [PATCH 02/18] feat(rails): emit producer span when enqueueing ActiveJob Wraps ActiveJob enqueue with a `queue.publish` child span when an active parent transaction exists, mirroring sentry-sidekiq's client middleware. Uses the public `around_enqueue` callback so no new ActiveJob monkey-patching is introduced. Co-Authored-By: Claude Opus 4.7 (1M context) --- sentry-rails/lib/sentry/rails/active_job.rb | 13 +++++ sentry-rails/lib/sentry/rails/railtie.rb | 4 ++ .../shared_examples/tracing/producer_span.rb | 50 +++++++++++++++++++ .../spec/active_job/support/harness.rb | 8 +++ .../spec/active_job/test_adapter_spec.rb | 1 + 5 files changed, 76 insertions(+) create mode 100644 sentry-rails/spec/active_job/shared_examples/tracing/producer_span.rb diff --git a/sentry-rails/lib/sentry/rails/active_job.rb b/sentry-rails/lib/sentry/rails/active_job.rb index 93e2ae840..c9480346c 100644 --- a/sentry-rails/lib/sentry/rails/active_job.rb +++ b/sentry-rails/lib/sentry/rails/active_job.rb @@ -28,6 +28,19 @@ class SentryReporter } class << self + def record_producer_span(job) + return yield if !Sentry.initialized? || job.already_supported_by_sentry_integration? + + Sentry.with_child_span(op: "queue.publish", description: job.class.name) do |span| + if span + span.set_origin(SPAN_ORIGIN) + span.set_data(Sentry::Span::DataConventions::MESSAGING_MESSAGE_ID, job.job_id) + span.set_data(Sentry::Span::DataConventions::MESSAGING_DESTINATION_NAME, job.queue_name) + end + yield + end + end + def record(job, &block) Sentry.with_scope do |scope| begin diff --git a/sentry-rails/lib/sentry/rails/railtie.rb b/sentry-rails/lib/sentry/rails/railtie.rb index a86093768..cb3f5c48d 100644 --- a/sentry-rails/lib/sentry/rails/railtie.rb +++ b/sentry-rails/lib/sentry/rails/railtie.rb @@ -21,6 +21,10 @@ class Railtie < ::Rails::Railtie ActiveSupport.on_load(:active_job) do require "sentry/rails/active_job" prepend Sentry::Rails::ActiveJobExtensions + + around_enqueue do |job, block| + Sentry::Rails::ActiveJobExtensions::SentryReporter.record_producer_span(job, &block) + end end end diff --git a/sentry-rails/spec/active_job/shared_examples/tracing/producer_span.rb b/sentry-rails/spec/active_job/shared_examples/tracing/producer_span.rb new file mode 100644 index 000000000..9a52460f5 --- /dev/null +++ b/sentry-rails/spec/active_job/shared_examples/tracing/producer_span.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +RSpec.shared_examples "an ActiveJob backend that emits a producer span on enqueue" do + let(:successful_job) do + job_fixture do + def perform; end + end + end + + context "with traces_sample_rate = 1.0" do + let(:configure_sentry) { proc { |config| config.traces_sample_rate = 1.0 } } + + it "adds a queue.publish child span to the active parent transaction" do + within_parent_transaction do + successful_job.set(queue: "events").perform_later + end + + parent = transactions.find { |t| t.contexts.dig(:trace, :op) == "test" } + expect(parent).not_to be_nil + + publish_span = parent.spans.find { |s| s[:op] == "queue.publish" } + expect(publish_span).not_to be_nil + expect(publish_span[:description]).to eq(successful_job.name) + expect(publish_span[:origin]).to eq("auto.queue.active_job") + expect(publish_span[:data]["messaging.message.id"]).to be_a(String).and(satisfy { |v| !v.empty? }) + expect(publish_span[:data]["messaging.destination.name"]).to eq("events") + expect(publish_span[:timestamp]).not_to be_nil + end + + it "does not raise or capture an orphan span when no parent transaction is active" do + expect { successful_job.perform_later }.not_to raise_error + + orphan_publish = transactions.flat_map(&:spans).find { |s| s[:op] == "queue.publish" } + expect(orphan_publish).to be_nil + end + end + + context "with traces_sample_rate = 0" do + let(:configure_sentry) { proc { |config| config.traces_sample_rate = 0 } } + + it "does not capture a queue.publish span" do + within_parent_transaction do + successful_job.perform_later + end + + publish_spans = transactions.flat_map(&:spans).select { |s| s[:op] == "queue.publish" } + expect(publish_spans).to be_empty + end + end +end diff --git a/sentry-rails/spec/active_job/support/harness.rb b/sentry-rails/spec/active_job/support/harness.rb index 27dd00793..1ad6ce678 100644 --- a/sentry-rails/spec/active_job/support/harness.rb +++ b/sentry-rails/spec/active_job/support/harness.rb @@ -61,4 +61,12 @@ def transactions def consumer_transaction transactions.find { |t| t.contexts.dig(:trace, :op) == "queue.active_job" } end + + def within_parent_transaction(name: "parent.test", op: "test") + txn = Sentry.start_transaction(name: name, op: op) + Sentry.get_current_scope.set_span(txn) if txn + yield(txn) + ensure + txn&.finish + end end diff --git a/sentry-rails/spec/active_job/test_adapter_spec.rb b/sentry-rails/spec/active_job/test_adapter_spec.rb index cdfd338a9..146e7c5f7 100644 --- a/sentry-rails/spec/active_job/test_adapter_spec.rb +++ b/sentry-rails/spec/active_job/test_adapter_spec.rb @@ -7,4 +7,5 @@ it_behaves_like "a Sentry-instrumented ActiveJob backend" it_behaves_like "an ActiveJob backend that records messaging span data on the consumer transaction" + it_behaves_like "an ActiveJob backend that emits a producer span on enqueue" end From 6baadf31cba05c2d5a614d9f53c5c2b72604ca4f Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 7 May 2026 12:45:52 +0000 Subject: [PATCH 03/18] feat(rails): propagate trace context through ActiveJob payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the OpenTelemetry pattern (the only documented way to add metadata to an ActiveJob payload — Rails has no public extension hook for serialize/deserialize): prepends the existing ActiveJobExtensions module with serialize/deserialize overrides that inject and recover sentry-trace and baggage headers under a namespaced "_sentry" key, wrapped in rescue blocks so a Sentry bug never breaks job execution. Threads the deserialized headers into SentryReporter.record, which now uses Sentry.continue_trace when present so the consumer transaction shares the producer's trace_id and chains under the producer queue.publish span. Guards the around_enqueue producer-span registration against duplicate registration (each Test::Application.define re-runs the railtie and without idempotency this stacks dozens of nested queue.publish spans). Co-Authored-By: Claude Opus 4.7 (1M context) --- sentry-rails/lib/sentry/rails/active_job.rb | 56 +++++++++++++++++-- sentry-rails/lib/sentry/rails/railtie.rb | 7 ++- .../tracing/trace_propagation.rb | 50 +++++++++++++++++ .../spec/active_job/test_adapter_spec.rb | 1 + 4 files changed, 108 insertions(+), 6 deletions(-) create mode 100644 sentry-rails/spec/active_job/shared_examples/tracing/trace_propagation.rb diff --git a/sentry-rails/lib/sentry/rails/active_job.rb b/sentry-rails/lib/sentry/rails/active_job.rb index c9480346c..9b4e26fc5 100644 --- a/sentry-rails/lib/sentry/rails/active_job.rb +++ b/sentry-rails/lib/sentry/rails/active_job.rb @@ -5,16 +5,49 @@ module Sentry module Rails module ActiveJobExtensions + SENTRY_PAYLOAD_KEY = "_sentry" + def perform_now if !Sentry.initialized? || already_supported_by_sentry_integration? super else - SentryReporter.record(self) do + SentryReporter.record(self, trace_headers: @_sentry_trace_headers) do super end end end + def serialize + payload = super + return payload if !Sentry.initialized? || already_supported_by_sentry_integration? + + begin + sentry_data = {} + headers = Sentry.get_trace_propagation_headers + sentry_data["trace_propagation_headers"] = headers if headers && !headers.empty? + + payload[SENTRY_PAYLOAD_KEY] = sentry_data unless sentry_data.empty? + rescue StandardError => e + Sentry.sdk_logger&.error("sentry-rails: failed to inject _sentry payload: #{e}") + end + + payload + end + + def deserialize(job_data) + super + return if !Sentry.initialized? || already_supported_by_sentry_integration? + + begin + sentry_data = job_data[SENTRY_PAYLOAD_KEY] + return unless sentry_data + + @_sentry_trace_headers = sentry_data["trace_propagation_headers"] + rescue StandardError => e + Sentry.sdk_logger&.error("sentry-rails: failed to extract _sentry payload: #{e}") + end + end + def already_supported_by_sentry_integration? Sentry.configuration.rails.skippable_job_adapters.include?(self.class.queue_adapter.class.to_s) end @@ -28,6 +61,14 @@ class SentryReporter } class << self + def producer_callback_registered? + @producer_callback_registered ||= false + end + + def producer_callback_registered! + @producer_callback_registered = true + end + def record_producer_span(job) return yield if !Sentry.initialized? || job.already_supported_by_sentry_integration? @@ -41,17 +82,24 @@ def record_producer_span(job) end end - def record(job, &block) + def record(job, trace_headers: nil, &block) Sentry.with_scope do |scope| begin scope.set_transaction_name(job.class.name, source: :task) - transaction = Sentry.start_transaction( + transaction_options = { name: scope.transaction_name, source: scope.transaction_source, op: OP_NAME, origin: SPAN_ORIGIN - ) + } + + transaction = if trace_headers && !trace_headers.empty? + continued = Sentry.continue_trace(trace_headers, **transaction_options) + Sentry.start_transaction(transaction: continued, **transaction_options) + else + Sentry.start_transaction(**transaction_options) + end if transaction set_messaging_data(transaction, job) diff --git a/sentry-rails/lib/sentry/rails/railtie.rb b/sentry-rails/lib/sentry/rails/railtie.rb index cb3f5c48d..a234e95a9 100644 --- a/sentry-rails/lib/sentry/rails/railtie.rb +++ b/sentry-rails/lib/sentry/rails/railtie.rb @@ -22,8 +22,11 @@ class Railtie < ::Rails::Railtie require "sentry/rails/active_job" prepend Sentry::Rails::ActiveJobExtensions - around_enqueue do |job, block| - Sentry::Rails::ActiveJobExtensions::SentryReporter.record_producer_span(job, &block) + unless Sentry::Rails::ActiveJobExtensions::SentryReporter.producer_callback_registered? + around_enqueue do |job, block| + Sentry::Rails::ActiveJobExtensions::SentryReporter.record_producer_span(job, &block) + end + Sentry::Rails::ActiveJobExtensions::SentryReporter.producer_callback_registered! end end end diff --git a/sentry-rails/spec/active_job/shared_examples/tracing/trace_propagation.rb b/sentry-rails/spec/active_job/shared_examples/tracing/trace_propagation.rb new file mode 100644 index 000000000..d6c975547 --- /dev/null +++ b/sentry-rails/spec/active_job/shared_examples/tracing/trace_propagation.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +RSpec.shared_examples "an ActiveJob backend that propagates trace context through the job payload" do + let(:successful_job) do + job_fixture do + def perform; end + end + end + + let(:configure_sentry) { proc { |config| config.traces_sample_rate = 1.0 } } + + it "produces a consumer transaction whose trace_id matches the parent transaction" do + parent_trace_id = nil + publish_span_id = nil + + within_parent_transaction do |parent| + parent_trace_id = parent.trace_id + successful_job.perform_later + publish_span_id = parent.span_recorder.spans.find { |s| s.op == "queue.publish" }&.span_id + end + + drain + + expect(consumer_transaction).not_to be_nil + expect(consumer_transaction.contexts.dig(:trace, :trace_id)).to eq(parent_trace_id) + expect(consumer_transaction.contexts.dig(:trace, :parent_span_id)).to eq(publish_span_id) + end + + it "captures a consumer transaction without raising when no parent transaction was active at enqueue" do + expect { successful_job.perform_later }.not_to raise_error + expect { drain }.not_to raise_error + + expect(consumer_transaction).not_to be_nil + expect(consumer_transaction.contexts.dig(:trace, :trace_id)).to be_a(String) + end + + it "survives a JSON round-trip of the serialized payload" do + parent_trace_id = nil + + within_parent_transaction do |parent| + parent_trace_id = parent.trace_id + payload = successful_job.new.serialize + round_tripped = JSON.parse(JSON.generate(payload)) + ::ActiveJob::Base.execute(round_tripped) + end + + expect(consumer_transaction).not_to be_nil + expect(consumer_transaction.contexts.dig(:trace, :trace_id)).to eq(parent_trace_id) + end +end diff --git a/sentry-rails/spec/active_job/test_adapter_spec.rb b/sentry-rails/spec/active_job/test_adapter_spec.rb index 146e7c5f7..88e60f687 100644 --- a/sentry-rails/spec/active_job/test_adapter_spec.rb +++ b/sentry-rails/spec/active_job/test_adapter_spec.rb @@ -8,4 +8,5 @@ it_behaves_like "a Sentry-instrumented ActiveJob backend" it_behaves_like "an ActiveJob backend that records messaging span data on the consumer transaction" it_behaves_like "an ActiveJob backend that emits a producer span on enqueue" + it_behaves_like "an ActiveJob backend that propagates trace context through the job payload" end From c4d80850432ec55250915692f34d1fee799e97e3 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 7 May 2026 12:47:05 +0000 Subject: [PATCH 04/18] fixup(rails): account for AJ producer span in active_storage subscriber spec The producer-span change makes ActiveStorage's internally-enqueued AnalyzeJob emit an extra queue.publish span on the request transaction, which the previous index-based span lookups did not anticipate. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tracing/active_storage_subscriber_spec.rb | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/sentry-rails/spec/sentry/rails/tracing/active_storage_subscriber_spec.rb b/sentry-rails/spec/sentry/rails/tracing/active_storage_subscriber_spec.rb index 54b4eda9c..b1a3618e9 100644 --- a/sentry-rails/spec/sentry/rails/tracing/active_storage_subscriber_spec.rb +++ b/sentry-rails/spec/sentry/rails/tracing/active_storage_subscriber_spec.rb @@ -42,14 +42,13 @@ request_transaction = transport.events.last.to_h expect(request_transaction[:type]).to eq("transaction") - expect(request_transaction[:spans].count).to eq(2) - - span = request_transaction[:spans][1] - expect(span[:op]).to eq("file.service_upload.active_storage") - expect(span[:origin]).to eq("auto.file.rails") - expect(span[:description]).to eq("Disk") - expect(span.dig(:data, :key)).to be_nil - expect(span[:trace_id]).to eq(request_transaction.dig(:contexts, :trace, :trace_id)) + + upload_span = request_transaction[:spans].find { |s| s[:op] == "file.service_upload.active_storage" } + expect(upload_span).not_to be_nil + expect(upload_span[:origin]).to eq("auto.file.rails") + expect(upload_span[:description]).to eq("Disk") + expect(upload_span.dig(:data, :key)).to be_nil + expect(upload_span[:trace_id]).to eq(request_transaction.dig(:contexts, :trace, :trace_id)) end end @@ -71,10 +70,10 @@ request_transaction = transport.events.last.to_h expect(request_transaction[:type]).to eq("transaction") - expect(request_transaction[:spans].count).to eq(2) - span = request_transaction[:spans][1] - expect(span.dig(:data, :key)).to eq(p.cover.key) + upload_span = request_transaction[:spans].find { |s| s[:op] == "file.service_upload.active_storage" } + expect(upload_span).not_to be_nil + expect(upload_span.dig(:data, :key)).to eq(p.cover.key) end end From 79a3418054e550440fab281d36f964de3be4040d Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 7 May 2026 12:49:15 +0000 Subject: [PATCH 05/18] feat(rails): propagate whitelisted user context through ActiveJob payload When config.send_default_pii is true, the producer-side serialize override now copies a whitelisted set of user fields (id, email, username) into the _sentry payload block. The consumer-side deserialize stashes them and SentryReporter.record applies them to the new scope so that the consumer transaction (and any error event captured during perform) carries the originating user without leaking ip_address, segment, or other fields back into the queue. Co-Authored-By: Claude Opus 4.7 (1M context) --- sentry-rails/lib/sentry/rails/active_job.rb | 22 ++++- .../tracing/user_propagation.rb | 95 +++++++++++++++++++ .../spec/active_job/test_adapter_spec.rb | 1 + 3 files changed, 114 insertions(+), 4 deletions(-) create mode 100644 sentry-rails/spec/active_job/shared_examples/tracing/user_propagation.rb diff --git a/sentry-rails/lib/sentry/rails/active_job.rb b/sentry-rails/lib/sentry/rails/active_job.rb index 9b4e26fc5..56fb3b035 100644 --- a/sentry-rails/lib/sentry/rails/active_job.rb +++ b/sentry-rails/lib/sentry/rails/active_job.rb @@ -7,13 +7,17 @@ module Rails module ActiveJobExtensions SENTRY_PAYLOAD_KEY = "_sentry" + USER_FIELDS_WHITELIST = %w[id email username].freeze + def perform_now if !Sentry.initialized? || already_supported_by_sentry_integration? super else - SentryReporter.record(self, trace_headers: @_sentry_trace_headers) do - super - end + SentryReporter.record( + self, + trace_headers: @_sentry_trace_headers, + user: @_sentry_user + ) { super } end end @@ -26,6 +30,14 @@ def serialize headers = Sentry.get_trace_propagation_headers sentry_data["trace_propagation_headers"] = headers if headers && !headers.empty? + if Sentry.configuration.send_default_pii + user = Sentry.get_current_scope.user || {} + whitelisted = user.each_with_object({}) do |(k, v), acc| + acc[k.to_s] = v if USER_FIELDS_WHITELIST.include?(k.to_s) + end + sentry_data["user"] = whitelisted unless whitelisted.empty? + end + payload[SENTRY_PAYLOAD_KEY] = sentry_data unless sentry_data.empty? rescue StandardError => e Sentry.sdk_logger&.error("sentry-rails: failed to inject _sentry payload: #{e}") @@ -43,6 +55,7 @@ def deserialize(job_data) return unless sentry_data @_sentry_trace_headers = sentry_data["trace_propagation_headers"] + @_sentry_user = sentry_data["user"] rescue StandardError => e Sentry.sdk_logger&.error("sentry-rails: failed to extract _sentry payload: #{e}") end @@ -82,9 +95,10 @@ def record_producer_span(job) end end - def record(job, trace_headers: nil, &block) + def record(job, trace_headers: nil, user: nil, &block) Sentry.with_scope do |scope| begin + scope.set_user(user) if user && !user.empty? scope.set_transaction_name(job.class.name, source: :task) transaction_options = { diff --git a/sentry-rails/spec/active_job/shared_examples/tracing/user_propagation.rb b/sentry-rails/spec/active_job/shared_examples/tracing/user_propagation.rb new file mode 100644 index 000000000..8619f743e --- /dev/null +++ b/sentry-rails/spec/active_job/shared_examples/tracing/user_propagation.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +RSpec.shared_examples "an ActiveJob backend that propagates Sentry user context through job payloads" do + let(:successful_job) do + job_fixture do + def perform; end + end + end + + let(:failing_job) do + job_fixture do + def perform + raise "boom from user_propagation spec" + end + end + end + + let(:full_user) do + { + id: "u1", + email: "alice@example.com", + username: "alice", + ip_address: "1.2.3.4", + segment: "vip" + } + end + + context "when send_default_pii is true" do + let(:configure_sentry) do + proc do |config| + config.traces_sample_rate = 1.0 + config.send_default_pii = true + end + end + + it "propagates only id, email, and username to the consumer transaction" do + Sentry.set_user(full_user) + + successful_job.perform_later + + # Simulate the cross-process boundary by clearing the producer scope + # before the consumer runs. Without this the consumer's with_scope + # inherits the user from the test thread and the test cannot tell + # whether propagation actually happened. + Sentry.set_user({}) + + drain + + expect(consumer_transaction).not_to be_nil + expect(consumer_transaction.user).to eq( + "id" => "u1", + "email" => "alice@example.com", + "username" => "alice" + ) + end + + it "propagates the whitelisted user to a captured error event" do + Sentry.set_user(full_user) + + failing_job.perform_later + Sentry.set_user({}) + + expect { drain }.to raise_error(RuntimeError, /boom from user_propagation spec/) + + error_event = sentry_events.find { |e| e.is_a?(Sentry::ErrorEvent) } + expect(error_event).not_to be_nil + expect(error_event.user).to eq( + "id" => "u1", + "email" => "alice@example.com", + "username" => "alice" + ) + end + end + + context "when send_default_pii is false" do + let(:configure_sentry) do + proc do |config| + config.traces_sample_rate = 1.0 + config.send_default_pii = false + end + end + + it "does not propagate user context to the consumer transaction" do + Sentry.set_user(full_user) + + successful_job.perform_later + Sentry.set_user({}) + + drain + + expect(consumer_transaction).not_to be_nil + expect(consumer_transaction.user).to eq({}) + end + end +end diff --git a/sentry-rails/spec/active_job/test_adapter_spec.rb b/sentry-rails/spec/active_job/test_adapter_spec.rb index 88e60f687..b4e29c34a 100644 --- a/sentry-rails/spec/active_job/test_adapter_spec.rb +++ b/sentry-rails/spec/active_job/test_adapter_spec.rb @@ -9,4 +9,5 @@ it_behaves_like "an ActiveJob backend that records messaging span data on the consumer transaction" it_behaves_like "an ActiveJob backend that emits a producer span on enqueue" it_behaves_like "an ActiveJob backend that propagates trace context through the job payload" + it_behaves_like "an ActiveJob backend that propagates Sentry user context through job payloads" end From 6c1634f03384f5eba3594472b7a8bfe45101c86e Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 7 May 2026 12:54:06 +0000 Subject: [PATCH 06/18] feat(rails): isolate Sentry hub per worker thread for ActiveJob Calls Sentry.clone_hub_to_current_thread before opening the consumer scope when perform_now runs on a non-main thread (mirrors the sentry-sidekiq server middleware). This ensures that worker-side state captured during job execution lives on a thread-local hub clone and cannot leak back into the main process hub. Adds a behaviour-driven shared example: two concurrent jobs in separate worker threads do not cross-pollute each other's tags, and the calling thread's scope is unchanged after both complete. Co-Authored-By: Claude Opus 4.7 (1M context) --- sentry-rails/lib/sentry/rails/active_job.rb | 2 + .../tracing/worker_hub_isolation.rb | 39 +++++++++++++++++++ .../spec/active_job/test_adapter_spec.rb | 1 + 3 files changed, 42 insertions(+) create mode 100644 sentry-rails/spec/active_job/shared_examples/tracing/worker_hub_isolation.rb diff --git a/sentry-rails/lib/sentry/rails/active_job.rb b/sentry-rails/lib/sentry/rails/active_job.rb index 56fb3b035..d95034bfc 100644 --- a/sentry-rails/lib/sentry/rails/active_job.rb +++ b/sentry-rails/lib/sentry/rails/active_job.rb @@ -96,6 +96,8 @@ def record_producer_span(job) end def record(job, trace_headers: nil, user: nil, &block) + Sentry.clone_hub_to_current_thread if Thread.current != Thread.main + Sentry.with_scope do |scope| begin scope.set_user(user) if user && !user.empty? diff --git a/sentry-rails/spec/active_job/shared_examples/tracing/worker_hub_isolation.rb b/sentry-rails/spec/active_job/shared_examples/tracing/worker_hub_isolation.rb new file mode 100644 index 000000000..53be4d15d --- /dev/null +++ b/sentry-rails/spec/active_job/shared_examples/tracing/worker_hub_isolation.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +RSpec.shared_examples "an ActiveJob backend that isolates Sentry context per worker thread" do + let(:configure_sentry) { proc { |config| config.traces_sample_rate = 1.0 } } + + it "creates an isolated hub per worker thread when run concurrently" do + job_a = job_fixture do + def perform + Sentry.get_current_scope.set_tags(job: "A") + sleep 0.05 + end + end + + job_b = job_fixture do + def perform + Sentry.get_current_scope.set_tags(job: "B") + sleep 0.05 + end + end + + Sentry.get_current_scope.set_tags(test_thread: true) + + thread_a = Thread.new { job_a.perform_later; drain } + thread_b = Thread.new { job_b.perform_later; drain } + [thread_a, thread_b].each(&:join) + + txn_a = transactions.find { |t| t.tags[:job] == "A" } + txn_b = transactions.find { |t| t.tags[:job] == "B" } + + expect(txn_a).not_to be_nil + expect(txn_b).not_to be_nil + expect(txn_a.tags[:job]).to eq("A") + expect(txn_b.tags[:job]).to eq("B") + + # The test thread's own scope is unchanged. + expect(Sentry.get_current_scope.tags[:test_thread]).to be_truthy + expect(Sentry.get_current_scope.tags).not_to have_key(:job) + end +end diff --git a/sentry-rails/spec/active_job/test_adapter_spec.rb b/sentry-rails/spec/active_job/test_adapter_spec.rb index b4e29c34a..7fcbbf6b8 100644 --- a/sentry-rails/spec/active_job/test_adapter_spec.rb +++ b/sentry-rails/spec/active_job/test_adapter_spec.rb @@ -10,4 +10,5 @@ it_behaves_like "an ActiveJob backend that emits a producer span on enqueue" it_behaves_like "an ActiveJob backend that propagates trace context through the job payload" it_behaves_like "an ActiveJob backend that propagates Sentry user context through job payloads" + it_behaves_like "an ActiveJob backend that isolates Sentry context per worker thread" end From 8994ac657d7ad9c2e10b4fc3e5b14c8d37fc4071 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 7 May 2026 12:58:14 +0000 Subject: [PATCH 07/18] refactor(rails): bundle ActiveJob tracing examples into a distributed_tracing meta Replaces the five individual it_behaves_like opt-ins in test_adapter_spec.rb with one composite shared example so future AJ adapter specs can opt in with a single line. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../shared_examples/tracing/distributed_tracing.rb | 9 +++++++++ sentry-rails/spec/active_job/test_adapter_spec.rb | 6 +----- 2 files changed, 10 insertions(+), 5 deletions(-) create mode 100644 sentry-rails/spec/active_job/shared_examples/tracing/distributed_tracing.rb diff --git a/sentry-rails/spec/active_job/shared_examples/tracing/distributed_tracing.rb b/sentry-rails/spec/active_job/shared_examples/tracing/distributed_tracing.rb new file mode 100644 index 000000000..8dac36d55 --- /dev/null +++ b/sentry-rails/spec/active_job/shared_examples/tracing/distributed_tracing.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +RSpec.shared_examples "an ActiveJob backend that supports distributed tracing" do + it_behaves_like "an ActiveJob backend that emits a producer span on enqueue" + it_behaves_like "an ActiveJob backend that propagates trace context through the job payload" + it_behaves_like "an ActiveJob backend that records messaging span data on the consumer transaction" + it_behaves_like "an ActiveJob backend that propagates Sentry user context through job payloads" + it_behaves_like "an ActiveJob backend that isolates Sentry context per worker thread" +end diff --git a/sentry-rails/spec/active_job/test_adapter_spec.rb b/sentry-rails/spec/active_job/test_adapter_spec.rb index 7fcbbf6b8..bde362193 100644 --- a/sentry-rails/spec/active_job/test_adapter_spec.rb +++ b/sentry-rails/spec/active_job/test_adapter_spec.rb @@ -6,9 +6,5 @@ include_context "active_job backend harness", adapter: :test it_behaves_like "a Sentry-instrumented ActiveJob backend" - it_behaves_like "an ActiveJob backend that records messaging span data on the consumer transaction" - it_behaves_like "an ActiveJob backend that emits a producer span on enqueue" - it_behaves_like "an ActiveJob backend that propagates trace context through the job payload" - it_behaves_like "an ActiveJob backend that propagates Sentry user context through job payloads" - it_behaves_like "an ActiveJob backend that isolates Sentry context per worker thread" + it_behaves_like "an ActiveJob backend that supports distributed tracing" end From 529d724d4c1502904deeddc4c22bfb91ce6f153f Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 7 May 2026 13:02:47 +0000 Subject: [PATCH 08/18] fixup(rails): widen latency tolerance on Rails < 7 in messaging_span_data spec Rails 6.x's ActiveSupport::Testing::TimeHelpers#travel does not accept the with_usec: option, so it truncates Time.now to whole seconds and the measured latency can land up to ~999ms below the travel delta. Use a 1100ms tolerance on Rails < 7.0 and the original 50ms tolerance on 7.0+ where with_usec: true is available. Verified against Rails 6.1, 7.1, and 8.1 via ./bin/test --version. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../shared_examples/tracing/messaging_span_data.rb | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/sentry-rails/spec/active_job/shared_examples/tracing/messaging_span_data.rb b/sentry-rails/spec/active_job/shared_examples/tracing/messaging_span_data.rb index e68f6e184..aa7a032ee 100644 --- a/sentry-rails/spec/active_job/shared_examples/tracing/messaging_span_data.rb +++ b/sentry-rails/spec/active_job/shared_examples/tracing/messaging_span_data.rb @@ -45,12 +45,19 @@ def perform; end it "records messaging.message.receive.latency in milliseconds", skip: RAILS_VERSION < 6.1 do successful_job.perform_later - travel(5.seconds, with_usec: true) do - drain + # Older Rails versions truncate Time.now to whole seconds inside `travel` + # (no `with_usec:` option until 7.0+), so the measured latency can be up + # to ~999ms below the travel delta. Widen the tolerance accordingly. + if RAILS_VERSION >= 7.0 + travel(5.seconds, with_usec: true) { drain } + tolerance = 50 + else + travel(5.seconds) { drain } + tolerance = 1100 end latency = consumer_transaction.contexts.dig(:trace, :data, "messaging.message.receive.latency") expect(latency).to be_a(Integer) - expect(latency).to be_within(50).of(5_000) + expect(latency).to be_within(tolerance).of(5_000) end end From b610cb2a833a0e6fd1698b3d1b3da7c92bef39d2 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 7 May 2026 13:56:54 +0000 Subject: [PATCH 09/18] refactor(rails): introduce worker_thread harness hook for the hub-isolation example The worker_hub_isolation shared example previously hard-coded Thread.new for the two concurrent jobs it spawns. That works fine on adapters that keep their queue state in-process (:test, :inline) but some real adapters need per-thread setup (e.g. :solid_queue on SQLite needs an isolated database per worker thread to avoid SQLite3::BusyException). Adds a `worker_thread(&block)` hook on the harness, defaulting to `Thread.new(&block)`, and switches the shared example to call it. Adapters that need extra worker setup override the hook. Behaviour on :test (the only adapter on this branch) is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../shared_examples/tracing/worker_hub_isolation.rb | 4 ++-- sentry-rails/spec/active_job/support/harness.rb | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/sentry-rails/spec/active_job/shared_examples/tracing/worker_hub_isolation.rb b/sentry-rails/spec/active_job/shared_examples/tracing/worker_hub_isolation.rb index 53be4d15d..a90317785 100644 --- a/sentry-rails/spec/active_job/shared_examples/tracing/worker_hub_isolation.rb +++ b/sentry-rails/spec/active_job/shared_examples/tracing/worker_hub_isolation.rb @@ -20,8 +20,8 @@ def perform Sentry.get_current_scope.set_tags(test_thread: true) - thread_a = Thread.new { job_a.perform_later; drain } - thread_b = Thread.new { job_b.perform_later; drain } + thread_a = worker_thread { job_a.perform_later; drain } + thread_b = worker_thread { job_b.perform_later; drain } [thread_a, thread_b].each(&:join) txn_a = transactions.find { |t| t.tags[:job] == "A" } diff --git a/sentry-rails/spec/active_job/support/harness.rb b/sentry-rails/spec/active_job/support/harness.rb index 1ad6ce678..59c909194 100644 --- a/sentry-rails/spec/active_job/support/harness.rb +++ b/sentry-rails/spec/active_job/support/harness.rb @@ -69,4 +69,12 @@ def within_parent_transaction(name: "parent.test", op: "test") ensure txn&.finish end + + # Hook used by the worker_hub_isolation shared example. The default + # is a plain Thread.new — adapters that need extra setup (e.g. an + # isolated database per worker thread, like :solid_queue on SQLite) + # override this to wrap the block in their isolation scope. + def worker_thread(&block) + Thread.new(&block) + end end From d61680ad14e1cce410a7d4a938840b83619db492 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Fri, 8 May 2026 08:40:29 +0000 Subject: [PATCH 10/18] fix(rails): no with_usec in 7.0 --- .../active_job/shared_examples/tracing/messaging_span_data.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry-rails/spec/active_job/shared_examples/tracing/messaging_span_data.rb b/sentry-rails/spec/active_job/shared_examples/tracing/messaging_span_data.rb index aa7a032ee..3218dd39c 100644 --- a/sentry-rails/spec/active_job/shared_examples/tracing/messaging_span_data.rb +++ b/sentry-rails/spec/active_job/shared_examples/tracing/messaging_span_data.rb @@ -48,7 +48,7 @@ def perform; end # Older Rails versions truncate Time.now to whole seconds inside `travel` # (no `with_usec:` option until 7.0+), so the measured latency can be up # to ~999ms below the travel delta. Widen the tolerance accordingly. - if RAILS_VERSION >= 7.0 + if RAILS_VERSION > 7.0 travel(5.seconds, with_usec: true) { drain } tolerance = 50 else From ffc2bc91c48662db43e5874947669e9083922cd0 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Tue, 12 May 2026 09:38:14 +0000 Subject: [PATCH 11/18] chore(rails): patch AJ test adapter for 5.2 --- .../spec/active_job/support/harness.rb | 60 ++++++++++++++++++- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/sentry-rails/spec/active_job/support/harness.rb b/sentry-rails/spec/active_job/support/harness.rb index 59c909194..277d9de39 100644 --- a/sentry-rails/spec/active_job/support/harness.rb +++ b/sentry-rails/spec/active_job/support/harness.rb @@ -1,5 +1,30 @@ # frozen_string_literal: true +# Rails 5.2's TestAdapter stores a minimal hash per enqueued job (only job +# class, args, queue) and its instantiate_job recreates jobs via `.new(*args)` +# — never calling our `deserialize` override. That means the `_sentry` +# payload injected by `serialize` is silently discarded before the consumer +# ever sees it, breaking distributed-tracing propagation. +# +# This adapter subclass calls `job.serialize` a second time after `super` has +# stored the minimal hash and saves the full output alongside it. The drain +# then drives each job through `ActiveJob::Base.execute(full_payload)`, which +# goes through the normal deserialize → perform_now path and picks up the +# Sentry trace headers and user context that were captured at enqueue time. +class Rails52FullPayloadTestAdapter < ::ActiveJob::QueueAdapters::TestAdapter + def enqueue(job) + prev = enqueued_jobs.length + super + enqueued_jobs.last[:_sentry_full_payload] = job.serialize if enqueued_jobs.length > prev + end + + def enqueue_at(job, timestamp) + prev = enqueued_jobs.length + super + enqueued_jobs.last[:_sentry_full_payload] = job.serialize if enqueued_jobs.length > prev + end +end + RSpec.shared_context "active_job backend harness" do |adapter:| let(:adapter) { adapter } let(:configure_sentry) { proc { } } @@ -8,12 +33,33 @@ make_basic_app(&configure_sentry) setup_sentry_test - ::ActiveJob::Base.queue_adapter = adapter + # Rails 5.2's TestAdapter discards the full serialize output (including the + # _sentry payload) when deferring jobs. Use our augmented subclass instead + # so the drain can replay jobs through the proper deserialize path. + # + # NOTE: In Rails 5.2 test specs, ActiveJob::TestHelper installs a + # _test_adapter on ActiveJob::Base via an outer around hook (before_setup). + # The queue_adapter class method returns _test_adapter when present, so we + # must use enable_test_adapter (not queue_adapter=) to override it. + if RAILS_VERSION < 6.0 && adapter == :test + @_original_test_adapter = ::ActiveJob::Base._test_adapter + ::ActiveJob::Base.enable_test_adapter(Rails52FullPayloadTestAdapter.new) + else + ::ActiveJob::Base.queue_adapter = adapter + end boot_adapter(adapter) example.run ensure + if RAILS_VERSION < 6.0 && adapter == :test + if @_original_test_adapter + ::ActiveJob::Base.enable_test_adapter(@_original_test_adapter) + else + ::ActiveJob::Base.disable_test_adapter + end + end + reset_adapter(adapter) teardown_sentry_test @@ -35,9 +81,19 @@ def drain(at: nil) if RAILS_VERSION < 6.0 # Rails 5.2: perform_enqueued_jobs always requires a block and only runs # jobs enqueued *inside* the block. Manually flush already-enqueued jobs. + # When using Rails52FullPayloadTestAdapter, each payload also carries a + # :_sentry_full_payload key with the complete serialize output. Drive + # those jobs through Base.execute so our deserialize override runs and + # populates @_sentry_trace_headers / @_sentry_user before perform_now. jobs = queue_adapter.enqueued_jobs.dup queue_adapter.enqueued_jobs.clear - jobs.each { |payload| send(:instantiate_job, payload).perform_now } + jobs.each do |payload| + if (full = payload[:_sentry_full_payload]) + ::ActiveJob::Base.execute(full) + else + send(:instantiate_job, payload).perform_now + end + end else kwargs = at ? { at: at } : {} perform_enqueued_jobs(**kwargs) From 0d4d3c0220197e7edb0cfba800c4ecc1906063c6 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Tue, 12 May 2026 09:57:20 +0000 Subject: [PATCH 12/18] fix(active_job): always emit retry count when job is retryable Use rescue_handlers.any? to determine if a job is configured as retryable. When it is, always set messaging.message.retry.count (starting at 0 on the first execution) to match Sidekiq behavior. Non-retryable jobs never emit this key. Co-Authored-By: github-copilot --- sentry-rails/lib/sentry/rails/active_job.rb | 4 +-- .../tracing/messaging_span_data.rb | 32 +++++++++++++------ 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/sentry-rails/lib/sentry/rails/active_job.rb b/sentry-rails/lib/sentry/rails/active_job.rb index d95034bfc..b173e9727 100644 --- a/sentry-rails/lib/sentry/rails/active_job.rb +++ b/sentry-rails/lib/sentry/rails/active_job.rb @@ -139,8 +139,8 @@ def set_messaging_data(transaction, job) transaction.set_data(Sentry::Span::DataConventions::MESSAGING_MESSAGE_ID, job.job_id) transaction.set_data(Sentry::Span::DataConventions::MESSAGING_DESTINATION_NAME, job.queue_name) - if job.executions && job.executions > 1 - transaction.set_data(Sentry::Span::DataConventions::MESSAGING_MESSAGE_RETRY_COUNT, job.executions - 1) + if job.class.rescue_handlers.any? + transaction.set_data(Sentry::Span::DataConventions::MESSAGING_MESSAGE_RETRY_COUNT, [job.executions.to_i - 1, 0].max) end if (latency = compute_latency(job)) diff --git a/sentry-rails/spec/active_job/shared_examples/tracing/messaging_span_data.rb b/sentry-rails/spec/active_job/shared_examples/tracing/messaging_span_data.rb index 3218dd39c..331b3a191 100644 --- a/sentry-rails/spec/active_job/shared_examples/tracing/messaging_span_data.rb +++ b/sentry-rails/spec/active_job/shared_examples/tracing/messaging_span_data.rb @@ -20,7 +20,7 @@ def perform; end expect(data["messaging.destination.name"]).to eq("critical") end - it "omits messaging.message.retry.count on the first execution" do + it "omits messaging.message.retry.count for non-retryable jobs" do successful_job.perform_later drain @@ -28,18 +28,32 @@ def perform; end expect(data).not_to have_key("messaging.message.retry.count") end - it "records messaging.message.retry.count = executions - 1 on retried executions" do - klass = job_fixture do - def perform; end + context "when the job is retryable" do + let(:retryable_job) do + job_fixture do + retry_on StandardError, attempts: 3, wait: 0 + + def perform; end + end end - allow_any_instance_of(klass).to receive(:executions).and_return(3) + it "records messaging.message.retry.count = 0 on the first execution" do + retryable_job.perform_later + drain - klass.perform_later - drain + data = consumer_transaction.contexts.dig(:trace, :data) + expect(data["messaging.message.retry.count"]).to eq(0) + end - data = consumer_transaction.contexts.dig(:trace, :data) - expect(data["messaging.message.retry.count"]).to eq(2) + it "records messaging.message.retry.count = executions - 1 on retried executions" do + allow_any_instance_of(retryable_job).to receive(:executions).and_return(3) + + retryable_job.perform_later + drain + + data = consumer_transaction.contexts.dig(:trace, :data) + expect(data["messaging.message.retry.count"]).to eq(2) + end end it "records messaging.message.receive.latency in milliseconds", skip: RAILS_VERSION < 6.1 do From a4e20109038f49617e3384d43962d9556ece0339 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Tue, 12 May 2026 09:58:26 +0000 Subject: [PATCH 13/18] feat(active_job): add active_job_propagate_traces config option Mirrors Sidekiq's propagate_traces flag. When set to false, trace propagation headers are not injected into the serialized job payload and the consumer starts a new unconnected transaction. Defaults to true (existing behaviour is preserved). Co-Authored-By: github-copilot --- sentry-rails/lib/sentry/rails/active_job.rb | 6 ++-- .../lib/sentry/rails/configuration.rb | 6 ++++ .../tracing/trace_propagation.rb | 33 +++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/sentry-rails/lib/sentry/rails/active_job.rb b/sentry-rails/lib/sentry/rails/active_job.rb index b173e9727..fce7a551e 100644 --- a/sentry-rails/lib/sentry/rails/active_job.rb +++ b/sentry-rails/lib/sentry/rails/active_job.rb @@ -27,8 +27,10 @@ def serialize begin sentry_data = {} - headers = Sentry.get_trace_propagation_headers - sentry_data["trace_propagation_headers"] = headers if headers && !headers.empty? + if Sentry.configuration.rails.active_job_propagate_traces + headers = Sentry.get_trace_propagation_headers + sentry_data["trace_propagation_headers"] = headers if headers && !headers.empty? + end if Sentry.configuration.send_default_pii user = Sentry.get_current_scope.user || {} diff --git a/sentry-rails/lib/sentry/rails/configuration.rb b/sentry-rails/lib/sentry/rails/configuration.rb index a37c4446c..9e4c1e7ef 100644 --- a/sentry-rails/lib/sentry/rails/configuration.rb +++ b/sentry-rails/lib/sentry/rails/configuration.rb @@ -172,6 +172,11 @@ class Configuration # Set this option to true if you want Sentry to capture each retry failure attr_accessor :active_job_report_on_retry_error + # Whether we should inject trace propagation headers into the serialized job + # payload in order to have a connected trace between producer and consumer. + # Defaults to true. Set to false to opt out. + attr_accessor :active_job_propagate_traces + # Configuration for structured logging feature # @return [StructuredLoggingConfiguration] attr_reader :structured_logging @@ -193,6 +198,7 @@ def initialize @db_query_source_threshold_ms = 100 @active_support_logger_subscription_items = Sentry::Rails::ACTIVE_SUPPORT_LOGGER_SUBSCRIPTION_ITEMS_DEFAULT.dup @active_job_report_on_retry_error = false + @active_job_propagate_traces = true @structured_logging = StructuredLoggingConfiguration.new end end diff --git a/sentry-rails/spec/active_job/shared_examples/tracing/trace_propagation.rb b/sentry-rails/spec/active_job/shared_examples/tracing/trace_propagation.rb index d6c975547..61c872019 100644 --- a/sentry-rails/spec/active_job/shared_examples/tracing/trace_propagation.rb +++ b/sentry-rails/spec/active_job/shared_examples/tracing/trace_propagation.rb @@ -47,4 +47,37 @@ def perform; end expect(consumer_transaction).not_to be_nil expect(consumer_transaction.contexts.dig(:trace, :trace_id)).to eq(parent_trace_id) end + + context "when active_job_propagate_traces is false" do + let(:configure_sentry) do + proc do |config| + config.traces_sample_rate = 1.0 + config.rails.active_job_propagate_traces = false + end + end + + it "does not inject trace headers into the job payload" do + within_parent_transaction do + successful_job.perform_later + end + + payload = queue_adapter.enqueued_jobs.last + sentry_payload = (payload[:_sentry_full_payload] || payload)["_sentry"] + expect(sentry_payload&.dig("trace_propagation_headers")).to be_nil + end + + it "starts a new unconnected consumer transaction" do + parent_trace_id = nil + + within_parent_transaction do |parent| + parent_trace_id = parent.trace_id + successful_job.perform_later + end + + drain + + expect(consumer_transaction).not_to be_nil + expect(consumer_transaction.contexts.dig(:trace, :trace_id)).not_to eq(parent_trace_id) + end + end end From 3bc2cfa5bb830ad68b23791b62859dd414d9cf78 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Tue, 12 May 2026 09:59:40 +0000 Subject: [PATCH 14/18] feat(active_job): set scope tags and context on consumer like Sidekiq Mirror Sidekiq's scope enrichment: set a 'queue' scope tag and an 'active_job' context block (job_class, job_id, queue, provider_job_id) on every event captured within the consumer scope, including the transaction and any captured errors. Co-Authored-By: github-copilot --- sentry-rails/lib/sentry/rails/active_job.rb | 7 ++++ .../tracing/consumer_transaction.rb | 37 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/sentry-rails/lib/sentry/rails/active_job.rb b/sentry-rails/lib/sentry/rails/active_job.rb index fce7a551e..5a68e6ba9 100644 --- a/sentry-rails/lib/sentry/rails/active_job.rb +++ b/sentry-rails/lib/sentry/rails/active_job.rb @@ -104,6 +104,13 @@ def record(job, trace_headers: nil, user: nil, &block) begin scope.set_user(user) if user && !user.empty? scope.set_transaction_name(job.class.name, source: :task) + scope.set_tags(queue: job.queue_name) + scope.set_contexts(active_job: { + job_class: job.class.name, + job_id: job.job_id, + queue: job.queue_name, + provider_job_id: job.provider_job_id + }) transaction_options = { name: scope.transaction_name, diff --git a/sentry-rails/spec/active_job/shared_examples/tracing/consumer_transaction.rb b/sentry-rails/spec/active_job/shared_examples/tracing/consumer_transaction.rb index 5cc2d1fb3..715a2d5b6 100644 --- a/sentry-rails/spec/active_job/shared_examples/tracing/consumer_transaction.rb +++ b/sentry-rails/spec/active_job/shared_examples/tracing/consumer_transaction.rb @@ -32,6 +32,43 @@ def perform expect(transaction.contexts.dig(:trace, :status)).to eq("ok") end + it "sets queue scope tag on the consumer transaction" do + successful_job.set(queue: "important").perform_later + drain + + transaction = sentry_events.find { |e| e.is_a?(Sentry::TransactionEvent) } + expect(transaction).not_to be_nil + expect(transaction.tags[:queue]).to eq("important") + end + + it "sets active_job context on the consumer transaction" do + successful_job.perform_later + drain + + transaction = sentry_events.find { |e| e.is_a?(Sentry::TransactionEvent) } + expect(transaction).not_to be_nil + + ctx = transaction.contexts[:active_job] + expect(ctx).not_to be_nil + expect(ctx[:job_class]).to eq(successful_job.name) + expect(ctx[:job_id]).to be_a(String).and(satisfy { |v| !v.empty? }) + expect(ctx[:queue]).to eq("default") + end + + it "sets active_job context on the error event" do + expect do + failing_job.perform_later + drain + end.to raise_error(RuntimeError, /boom from tracing spec/) + + error_event = sentry_events.find { |e| e.is_a?(Sentry::ErrorEvent) } + expect(error_event).not_to be_nil + + ctx = error_event.contexts[:active_job] + expect(ctx).not_to be_nil + expect(ctx[:job_class]).to eq(failing_job.name) + end + it "records a db.sql.active_record child span when the job performs a query" do query_job = job_fixture do def perform From 58f89133cb96f02b091ce9eb7ce3b787ffe92eba Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Tue, 12 May 2026 11:07:22 +0000 Subject: [PATCH 15/18] fix(active_job): avoid shared queue race in jruby Co-Authored-By: github-copilot --- .../shared_examples/tracing/worker_hub_isolation.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry-rails/spec/active_job/shared_examples/tracing/worker_hub_isolation.rb b/sentry-rails/spec/active_job/shared_examples/tracing/worker_hub_isolation.rb index a90317785..59a0ebd2d 100644 --- a/sentry-rails/spec/active_job/shared_examples/tracing/worker_hub_isolation.rb +++ b/sentry-rails/spec/active_job/shared_examples/tracing/worker_hub_isolation.rb @@ -20,8 +20,8 @@ def perform Sentry.get_current_scope.set_tags(test_thread: true) - thread_a = worker_thread { job_a.perform_later; drain } - thread_b = worker_thread { job_b.perform_later; drain } + thread_a = worker_thread { job_a.perform_now } + thread_b = worker_thread { job_b.perform_now } [thread_a, thread_b].each(&:join) txn_a = transactions.find { |t| t.tags[:job] == "A" } From 03ad13207ff9134b3539daefcff252c80d19d731 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Tue, 12 May 2026 11:20:14 +0000 Subject: [PATCH 16/18] fix(active_job): save and restore hub around job execution This correctly handles all execution modes: - Dedicated async workers (new thread, nil hub): clone -> restore nil - Inline inside a Rack request (rack hub on thread): clone -> restore rack hub so the HTTP response completes normally - Thread-pool workers (recycled thread, stale hub): clone -> restore stale hub (irrelevant; next job will clone again) Co-Authored-By: github-copilot --- sentry-rails/lib/sentry/rails/active_job.rb | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/sentry-rails/lib/sentry/rails/active_job.rb b/sentry-rails/lib/sentry/rails/active_job.rb index 5a68e6ba9..d864e2311 100644 --- a/sentry-rails/lib/sentry/rails/active_job.rb +++ b/sentry-rails/lib/sentry/rails/active_job.rb @@ -98,7 +98,14 @@ def record_producer_span(job) end def record(job, trace_headers: nil, user: nil, &block) - Sentry.clone_hub_to_current_thread if Thread.current != Thread.main + # Always give this thread a fresh hub cloned from the main hub so + # the job's events are fully isolated. Save and restore whatever + # hub was on the thread before (e.g. the Rack request hub set by + # CaptureExceptions, or a stale hub left by a recycled thread-pool + # thread) so the outer context continues working correctly after + # the job finishes. + original_hub = Thread.current.thread_variable_get(Sentry::THREAD_LOCAL) + Sentry.clone_hub_to_current_thread Sentry.with_scope do |scope| begin @@ -142,6 +149,8 @@ def record(job, trace_headers: nil, user: nil, &block) raise end end + ensure + Thread.current.thread_variable_set(Sentry::THREAD_LOCAL, original_hub) end def set_messaging_data(transaction, job) From 642fa493868c509a29f037a738f06ced7fb5834d Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Tue, 12 May 2026 11:45:31 +0000 Subject: [PATCH 17/18] fix(active_job): run isolation threads sequentially to avoid transport race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running two threads concurrently in the scope-isolation test caused a race condition on DummyTransport#events (a plain Array). Under JRuby's true thread parallelism, two concurrent Array#<< calls can lose one write, so one transaction event disappeared, making the assertion 'expect(txn_b).not_to be_nil' fail intermittently. The sleep 0.05 in each job's perform was there to guarantee both threads overlapped in time. True concurrency is NOT required to verify scope isolation — what matters is that each job runs on its own Thread with its own cloned hub/scope. Running the threads sequentially (join A, then start B) tests the same property without the shared transport write race. Also removes the sleep 0.05 since it is no longer meaningful. Co-Authored-By: github-copilot --- .../shared_examples/tracing/worker_hub_isolation.rb | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/sentry-rails/spec/active_job/shared_examples/tracing/worker_hub_isolation.rb b/sentry-rails/spec/active_job/shared_examples/tracing/worker_hub_isolation.rb index 59a0ebd2d..85315369e 100644 --- a/sentry-rails/spec/active_job/shared_examples/tracing/worker_hub_isolation.rb +++ b/sentry-rails/spec/active_job/shared_examples/tracing/worker_hub_isolation.rb @@ -7,22 +7,19 @@ job_a = job_fixture do def perform Sentry.get_current_scope.set_tags(job: "A") - sleep 0.05 end end job_b = job_fixture do def perform Sentry.get_current_scope.set_tags(job: "B") - sleep 0.05 end end Sentry.get_current_scope.set_tags(test_thread: true) - thread_a = worker_thread { job_a.perform_now } - thread_b = worker_thread { job_b.perform_now } - [thread_a, thread_b].each(&:join) + worker_thread { job_a.perform_now }.join + worker_thread { job_b.perform_now }.join txn_a = transactions.find { |t| t.tags[:job] == "A" } txn_b = transactions.find { |t| t.tags[:job] == "B" } From bc8bb652f9606ea78f64f5ab51db3eaf4835b879 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Tue, 12 May 2026 12:13:49 +0000 Subject: [PATCH 18/18] fix(active_job): correct retry counter --- .../tracing/messaging_span_data.rb | 23 +++++++--- .../spec/active_job/support/harness.rb | 44 ++++++++++++------- 2 files changed, 45 insertions(+), 22 deletions(-) diff --git a/sentry-rails/spec/active_job/shared_examples/tracing/messaging_span_data.rb b/sentry-rails/spec/active_job/shared_examples/tracing/messaging_span_data.rb index 331b3a191..e3b9069c1 100644 --- a/sentry-rails/spec/active_job/shared_examples/tracing/messaging_span_data.rb +++ b/sentry-rails/spec/active_job/shared_examples/tracing/messaging_span_data.rb @@ -45,14 +45,27 @@ def perform; end expect(data["messaging.message.retry.count"]).to eq(0) end - it "records messaging.message.retry.count = executions - 1 on retried executions" do - allow_any_instance_of(retryable_job).to receive(:executions).and_return(3) + it "records messaging.message.retry.count across real retried executions", skip: RAILS_VERSION < 6.0 do + # Mirrors sentry-sidekiq's convention (see sentry-sidekiq's + # error_handler.rb): retry.count is the producer-side retry counter as + # observed when the consumer starts, NOT a "this is retry N" index. + # On attempt 1 ActiveJob has not yet incremented executions, so we + # report 0; on attempt 2 executions is 1 (set by the prior run), still + # max(1 - 1, 0) = 0; on attempt 3 executions is 2 → 1. + retried_job = job_fixture do + retry_on StandardError, attempts: 3, wait: 0 - retryable_job.perform_later + def perform + raise StandardError, "trigger retry" if executions < 3 + end + end + + retried_job.perform_later drain - data = consumer_transaction.contexts.dig(:trace, :data) - expect(data["messaging.message.retry.count"]).to eq(2) + consumer_txns = transactions.select { |t| t.contexts.dig(:trace, :op) == "queue.active_job" } + retry_counts = consumer_txns.map { |t| t.contexts.dig(:trace, :data, "messaging.message.retry.count") } + expect(retry_counts).to eq([0, 0, 1]) end end diff --git a/sentry-rails/spec/active_job/support/harness.rb b/sentry-rails/spec/active_job/support/harness.rb index 277d9de39..c66c03eb3 100644 --- a/sentry-rails/spec/active_job/support/harness.rb +++ b/sentry-rails/spec/active_job/support/harness.rb @@ -78,25 +78,35 @@ def reset_adapter(_adapter) def drain(at: nil) case adapter when :test - if RAILS_VERSION < 6.0 - # Rails 5.2: perform_enqueued_jobs always requires a block and only runs - # jobs enqueued *inside* the block. Manually flush already-enqueued jobs. - # When using Rails52FullPayloadTestAdapter, each payload also carries a - # :_sentry_full_payload key with the complete serialize output. Drive - # those jobs through Base.execute so our deserialize override runs and - # populates @_sentry_trace_headers / @_sentry_user before perform_now. - jobs = queue_adapter.enqueued_jobs.dup - queue_adapter.enqueued_jobs.clear - jobs.each do |payload| - if (full = payload[:_sentry_full_payload]) - ::ActiveJob::Base.execute(full) - else - send(:instantiate_job, payload).perform_now + # Loop until the queue is empty so retries (which re-enqueue during a + # drain pass) are cascaded through to completion. Both Rails 5.2's + # manual flush and Rails 6+'s perform_enqueued_jobs(no block) operate + # on a snapshot, so a single pass would only run jobs that existed + # before draining started. + loop do + break if queue_adapter.enqueued_jobs.empty? + + if RAILS_VERSION < 6.0 + # Rails 5.2: perform_enqueued_jobs always requires a block and only + # runs jobs enqueued *inside* the block. Manually flush already- + # enqueued jobs. When using Rails52FullPayloadTestAdapter, each + # payload also carries a :_sentry_full_payload key with the complete + # serialize output. Drive those jobs through Base.execute so our + # deserialize override runs and populates @_sentry_trace_headers / + # @_sentry_user before perform_now. + jobs = queue_adapter.enqueued_jobs.dup + queue_adapter.enqueued_jobs.clear + jobs.each do |payload| + if (full = payload[:_sentry_full_payload]) + ::ActiveJob::Base.execute(full) + else + send(:instantiate_job, payload).perform_now + end end + else + kwargs = at ? { at: at } : {} + perform_enqueued_jobs(**kwargs) end - else - kwargs = at ? { at: at } : {} - perform_enqueued_jobs(**kwargs) end else raise NotImplementedError, "active_job backend harness has no drain strategy for adapter: #{adapter.inspect}"