From 417d2111ec4b4844c32a2167b2de587a24339321 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 7 May 2026 12:31:48 +0000 Subject: [PATCH 01/26] 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 9a61493ba6d4adba9847e9b9c6c9d5662cec2219 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 7 May 2026 12:34:23 +0000 Subject: [PATCH 02/26] 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 c4caf5fce4a849edc23994a435a1d723edc70028 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 7 May 2026 12:45:52 +0000 Subject: [PATCH 03/26] 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 0ce5cc3fe4cd3e878d467a910273613e3902ec5d Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 7 May 2026 12:47:05 +0000 Subject: [PATCH 04/26] 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 f41b379e1..ec74550f3 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 @@ -44,14 +44,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 @@ -73,10 +72,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 cad2efc9a6fdea57a41d30cf79329842d456e0a0 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 7 May 2026 12:49:15 +0000 Subject: [PATCH 05/26] feat(active_job): propagate allowed user context 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 1d3ec164ca003e133cab1d2d9dbeaeaa1e5b093b Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 7 May 2026 12:54:06 +0000 Subject: [PATCH 06/26] 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 631abf807971c998501583f006451565b6f2b3b4 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 7 May 2026 12:58:14 +0000 Subject: [PATCH 07/26] 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 f1deb1a44912b5aaebd502fe1245fa21606a5019 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 7 May 2026 13:02:47 +0000 Subject: [PATCH 08/26] 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 6f97a73192504a3ef6bcf8195cd8293a424d64bd Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 7 May 2026 13:56:54 +0000 Subject: [PATCH 09/26] 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 ab4d679dff6427e758894f2cbc59b5c1e5e6656b Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Fri, 8 May 2026 08:40:29 +0000 Subject: [PATCH 10/26] 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 77188bcc35842bbe471862c746a8431d2ea90e08 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Tue, 12 May 2026 09:38:14 +0000 Subject: [PATCH 11/26] 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 1148ed34a3de0fcfa75863fa8b9706cbc2c5c9f2 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Tue, 12 May 2026 09:57:20 +0000 Subject: [PATCH 12/26] fix(active_job): always emit retry count on the consumer transaction Set messaging.message.retry.count unconditionally, derived from job.executions, to mirror sentry-sidekiq which pulls the count directly from the job hash. The previous gate of rescue_handlers.any? was imprecise: that list also includes plain rescue_from declarations, which do not trigger retries. A job declaring only rescue_from would still emit retry.count, while the absence of any handler would suppress it even though executions is still a meaningful signal. Removing the gate makes the attribute consistent and unambiguous across adapters. Co-Authored-By: github-copilot Co-Authored-By: Claude Opus 4.7 --- sentry-rails/lib/sentry/rails/active_job.rb | 5 +-- .../tracing/messaging_span_data.rb | 38 ++++++++++++++----- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/sentry-rails/lib/sentry/rails/active_job.rb b/sentry-rails/lib/sentry/rails/active_job.rb index d95034bfc..b32c99f34 100644 --- a/sentry-rails/lib/sentry/rails/active_job.rb +++ b/sentry-rails/lib/sentry/rails/active_job.rb @@ -138,10 +138,7 @@ def record(job, trace_headers: nil, user: nil, &block) 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 + transaction.set_data(Sentry::Span::DataConventions::MESSAGING_MESSAGE_RETRY_COUNT, [job.executions.to_i - 1, 0].max) if (latency = compute_latency(job)) transaction.set_data(Sentry::Span::DataConventions::MESSAGING_MESSAGE_RECEIVE_LATENCY, latency) 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..6f0ea9ea1 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,26 +20,44 @@ def perform; end expect(data["messaging.destination.name"]).to eq("critical") end - it "omits messaging.message.retry.count on the first execution" do + it "records messaging.message.retry.count = 0 for non-retryable jobs" do + # Mirrors sentry-sidekiq's convention: retry.count is always emitted and + # derived directly from the job's execution counter. Gating on + # `rescue_handlers` would be imprecise because `rescue_from` declarations + # share that list with `retry_on`, even though only the latter retries. successful_job.perform_later drain data = consumer_transaction.contexts.dig(:trace, :data) - expect(data).not_to have_key("messaging.message.retry.count") + expect(data["messaging.message.retry.count"]).to eq(0) 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 9ad912c4c9eb0401a7f0f92605bc579e5ed2c2a5 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Tue, 12 May 2026 09:58:26 +0000 Subject: [PATCH 13/26] 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 b32c99f34..5f300290e 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 34cc1810e1c20f1cf25f61677177bcd280e94899 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Tue, 12 May 2026 09:59:40 +0000 Subject: [PATCH 14/26] 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 5f300290e..a144eb36f 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 eac3354ac3d533dbb3c0fa203350841296b8507d Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Tue, 12 May 2026 11:07:22 +0000 Subject: [PATCH 15/26] 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 6f93792964e88fd72519dc7db870b650ae4d7c79 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Tue, 12 May 2026 11:20:14 +0000 Subject: [PATCH 16/26] 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 | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/sentry-rails/lib/sentry/rails/active_job.rb b/sentry-rails/lib/sentry/rails/active_job.rb index a144eb36f..398685fb0 100644 --- a/sentry-rails/lib/sentry/rails/active_job.rb +++ b/sentry-rails/lib/sentry/rails/active_job.rb @@ -7,7 +7,7 @@ module Rails module ActiveJobExtensions SENTRY_PAYLOAD_KEY = "_sentry" - USER_FIELDS_WHITELIST = %w[id email username].freeze + USER_FIELDS_ALLOWLIST = %w[id email username].freeze def perform_now if !Sentry.initialized? || already_supported_by_sentry_integration? @@ -34,10 +34,10 @@ def serialize 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) + allowed = user.each_with_object({}) do |(k, v), acc| + acc[k.to_s] = v if USER_FIELDS_ALLOWLIST.include?(k.to_s) end - sentry_data["user"] = whitelisted unless whitelisted.empty? + sentry_data["user"] = allowed unless allowed.empty? end payload[SENTRY_PAYLOAD_KEY] = sentry_data unless sentry_data.empty? @@ -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 e5566981017fefd9da4e6f9ef4ff99c05f751729 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Tue, 12 May 2026 11:45:31 +0000 Subject: [PATCH 17/26] fix(active_job): better specs for thread isolation Co-Authored-By: github-copilot --- .../tracing/worker_hub_isolation.rb | 78 ++++++++++++++----- 1 file changed, 58 insertions(+), 20 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..c868a22d8 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 @@ -4,36 +4,74 @@ 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 + barrier = Concurrent::CyclicBarrier.new(2) + results_mutex = Mutex.new + results = {} - job_b = job_fixture do - def perform - Sentry.get_current_scope.set_tags(job: "B") - sleep 0.05 + capture = lambda do |tag| + hub_id = Thread.current.thread_variable_get(Sentry::THREAD_LOCAL).object_id + Sentry.get_current_scope.set_tags(job: tag) + raise "barrier timeout in thread #{tag}" unless barrier.wait(5) + observed_tag = Sentry.get_current_scope.tags[:job] + results_mutex.synchronize do + results[tag] = { hub_id: hub_id, observed_tag: observed_tag } end end - Sentry.get_current_scope.set_tags(test_thread: true) + job_a = job_fixture { define_method(:perform) { capture.call("A") } } + job_b = job_fixture { define_method(:perform) { capture.call("B") } } - thread_a = worker_thread { job_a.perform_now } - thread_b = worker_thread { job_b.perform_now } - [thread_a, thread_b].each(&:join) + Sentry.get_current_scope.set_tags(test_thread: true) - txn_a = transactions.find { |t| t.tags[:job] == "A" } - txn_b = transactions.find { |t| t.tags[:job] == "B" } + t_a = worker_thread { job_a.perform_now } + t_b = worker_thread { job_b.perform_now } + t_a.join + t_b.join - 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") + expect(results["A"][:hub_id]).not_to eq(results["B"][:hub_id]) + expect(results["A"][:observed_tag]).to eq("A") + expect(results["B"][:observed_tag]).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 + + it "restores the prior thread-local hub when the job runs on a thread that already has one" do + hubs = {} + job = job_fixture do + define_method(:perform) do + hubs[:inside_job] = Thread.current.thread_variable_get(Sentry::THREAD_LOCAL) + end + end + + Sentry.get_current_scope # force the lazy clone so the test thread has a hub + hubs[:before] = Thread.current.thread_variable_get(Sentry::THREAD_LOCAL) + + job.perform_now + + hubs[:after] = Thread.current.thread_variable_get(Sentry::THREAD_LOCAL) + + expect(hubs[:before]).not_to be_nil + + expect(hubs[:inside_job]).not_to equal(hubs[:before]) + expect(hubs[:after]).to equal(hubs[:before]) + end + + it "restores a stale thread-local hub left by a previous job on the same worker thread" do + job = job_fixture do + def perform; end + end + + hubs = Thread.new do + stale = Sentry.get_main_hub.clone + Thread.current.thread_variable_set(Sentry::THREAD_LOCAL, stale) + + job.perform_now + + { stale: stale, after: Thread.current.thread_variable_get(Sentry::THREAD_LOCAL) } + end.value + + expect(hubs[:after]).to equal(hubs[:stale]) + end end From 4dc83977aa0b76401640612a7117fa45f91dd120 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Tue, 12 May 2026 12:13:49 +0000 Subject: [PATCH 18/26] fix(active_job): correct retry counter --- .../tracing/messaging_span_data.rb | 21 +++++---- .../spec/active_job/support/harness.rb | 44 ++++++++++++------- 2 files changed, 39 insertions(+), 26 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 6f0ea9ea1..eca15e905 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 @@ -21,10 +21,6 @@ def perform; end end it "records messaging.message.retry.count = 0 for non-retryable jobs" do - # Mirrors sentry-sidekiq's convention: retry.count is always emitted and - # derived directly from the job's execution counter. Gating on - # `rescue_handlers` would be imprecise because `rescue_from` declarations - # share that list with `retry_on`, even though only the latter retries. successful_job.perform_later drain @@ -49,14 +45,21 @@ 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 + 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}" From 8f826e3d8eaf1eeb5b76c5aad7a2d2afd3d89df7 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Wed, 20 May 2026 08:36:24 +0000 Subject: [PATCH 19/26] fix(active_job): widen manual flushing to rails < 6.1 --- .../spec/active_job/support/harness.rb | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/sentry-rails/spec/active_job/support/harness.rb b/sentry-rails/spec/active_job/support/harness.rb index c66c03eb3..bd3bd9987 100644 --- a/sentry-rails/spec/active_job/support/harness.rb +++ b/sentry-rails/spec/active_job/support/harness.rb @@ -86,13 +86,19 @@ def drain(at: nil) 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 / + if RAILS_VERSION < 6.1 + # Rails 5.2 and 6.0 both need a manual flush: + # - 5.2's perform_enqueued_jobs always requires a block and only + # runs jobs enqueued *inside* the block, so it can't drain a + # pre-existing queue at all. + # - 6.0's flush_enqueued_jobs iterates with `perform_now` but + # doesn't remove payloads from `enqueued_jobs` (the + # `delete(payload)` call was only added in 6.1), so looping on + # `enqueued_jobs.empty?` would spin forever. + # On 5.2 with 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 From 894df6028c86206cb0667b460ca5e98706cf0eff Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Wed, 20 May 2026 11:50:13 +0000 Subject: [PATCH 20/26] refactor(active_job): make the spec harness adapter-agnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The harness embedded :test-adapter specifics — the Rails 5.2 payload- preservation shim, the drain loop, and the enqueued-payload accessor. It also reached past ActiveJob::TestHelper to set ActiveJob::Base.queue_adapter directly, which conflicts with TestHelper's own _test_adapter slot (TestHelper's before_setup runs outside our around hook, so any direct assignment is silently shadowed). Switch the harness to ActiveJob's official queue_adapter_for_test hook and a small set of abstract methods (queue_adapter_for_test, with_adapter_active, drain, last_enqueued_payload, boot_adapter, reset_adapter) that adapter contexts implement. The :test-adapter shared context now owns everything specific to TestAdapter — including the Rails52FullPayloadTestAdapter shim and the drain loop. Subsequent adapter backends (e.g. Sidekiq) can compose with the harness without fighting it. Generalises the one shared-example line that reached into the TestAdapter shape (trace_propagation) via last_enqueued_payload. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tracing/trace_propagation.rb | 3 +- .../spec/active_job/support/harness.rb | 142 ++++++------------ .../support/test_adapter_context.rb | 95 ++++++++++++ .../spec/active_job/test_adapter_spec.rb | 1 + 4 files changed, 146 insertions(+), 95 deletions(-) create mode 100644 sentry-rails/spec/active_job/support/test_adapter_context.rb 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 61c872019..d0324afb9 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 @@ -61,8 +61,7 @@ def perform; end successful_job.perform_later end - payload = queue_adapter.enqueued_jobs.last - sentry_payload = (payload[:_sentry_full_payload] || payload)["_sentry"] + sentry_payload = last_enqueued_payload["_sentry"] expect(sentry_payload&.dig("trace_propagation_headers")).to be_nil end diff --git a/sentry-rails/spec/active_job/support/harness.rb b/sentry-rails/spec/active_job/support/harness.rb index bd3bd9987..8255a68e8 100644 --- a/sentry-rails/spec/active_job/support/harness.rb +++ b/sentry-rails/spec/active_job/support/harness.rb @@ -1,30 +1,18 @@ # 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. +# Backend-agnostic harness for the common ActiveJob spec suite. # -# 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 - +# This file contains zero knowledge of any specific queue adapter. Each +# adapter spec composes this shared context with its own adapter shared +# context (e.g. "test adapter", "sidekiq adapter") that fills in the +# adapter-specific hooks below. +# +# Adapter selection goes through ActiveJob::TestHelper's official +# +queue_adapter_for_test+ hook. TestHelper's +before_setup+ reads it +# and installs the returned adapter as Base's +_test_adapter+, which the +# +queue_adapter+ reader prefers over the underlying +_queue_adapter+. +# This avoids fighting with the railtie/dummy-app defaults and keeps the +# harness from reaching past TestHelper into private internals. RSpec.shared_context "active_job backend harness" do |adapter:| let(:adapter) { adapter } let(:configure_sentry) { proc { } } @@ -33,90 +21,58 @@ def enqueue_at(job, timestamp) make_basic_app(&configure_sentry) setup_sentry_test - # 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 + with_adapter_active { 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 end + # ActiveJob::TestHelper hook. Returning a non-nil adapter instance + # causes TestHelper to install it as Base's +_test_adapter+ for the + # duration of each example. Adapter contexts override this. + def queue_adapter_for_test + end + + # Optional block wrapper around +example.run+. The default just yields. + # Adapter contexts override this when the adapter needs a scoped + # runtime mode active during enqueue + drain (e.g. wrapping the + # example in +Sidekiq::Testing.fake!+ so fake mode is scoped per + # example without touching global state). + def with_adapter_active(&block) + yield + end + + # Per-adapter environment setup hook. Backends extend this when they + # need to load schemas, start supervisors, or otherwise prepare the + # environment. def boot_adapter(_adapter) - # Per-adapter setup hook. Backends extend this when they need to load - # schemas, start supervisors, or otherwise prepare the environment. end + # Per-adapter environment teardown hook. Backends extend this to + # truncate tables or otherwise clean up state between examples. def reset_adapter(_adapter) - # Per-adapter teardown hook. Backends extend this to truncate tables - # or otherwise clean up state between examples. end + # Drive the adapter to completion. Each adapter context must override + # this with a strategy that drains its queue (and any retried/scheduled + # jobs cascaded by the drain) to completion. def drain(at: nil) - case adapter - when :test - # 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? + raise NotImplementedError, + "active_job backend harness has no drain strategy for adapter: #{adapter.inspect}. " \ + "Include the matching adapter shared context (e.g. 'test adapter', 'sidekiq adapter')." + end - if RAILS_VERSION < 6.1 - # Rails 5.2 and 6.0 both need a manual flush: - # - 5.2's perform_enqueued_jobs always requires a block and only - # runs jobs enqueued *inside* the block, so it can't drain a - # pre-existing queue at all. - # - 6.0's flush_enqueued_jobs iterates with `perform_now` but - # doesn't remove payloads from `enqueued_jobs` (the - # `delete(payload)` call was only added in 6.1), so looping on - # `enqueued_jobs.empty?` would spin forever. - # On 5.2 with 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 - end - else - raise NotImplementedError, "active_job backend harness has no drain strategy for adapter: #{adapter.inspect}" - end + # Return the most recently enqueued job's serialized payload as a Hash + # keyed by ActiveJob's stringified field names (so callers can read + # +payload["_sentry"]+, +payload["arguments"]+, etc.). Each adapter + # context must override this since the on-the-wire shape differs per + # backend. + def last_enqueued_payload + raise NotImplementedError, + "active_job backend harness has no last_enqueued_payload accessor for adapter: #{adapter.inspect}. " \ + "Include the matching adapter shared context (e.g. 'test adapter', 'sidekiq adapter')." end def job_fixture(name = nil, &block) diff --git a/sentry-rails/spec/active_job/support/test_adapter_context.rb b/sentry-rails/spec/active_job/support/test_adapter_context.rb new file mode 100644 index 000000000..cdf0cd9fb --- /dev/null +++ b/sentry-rails/spec/active_job/support/test_adapter_context.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +# Adapter context for the :test ActiveJob backend. +# +# Composes with "active_job backend harness". The harness owns the +# example lifecycle; this context owns everything specific to +# ActiveJob::QueueAdapters::TestAdapter (the Rails-5.2 payload- +# preservation shim, the drain loop, and the enqueued-payload +# accessor). + +# 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 "test adapter" do + def queue_adapter_for_test + # On Rails 5.2 swap in the augmented adapter so the drain can replay + # the full serialize payload. On Rails 6.0+ returning nil lets + # TestHelper install its own TestAdapter — the standard path. + Rails52FullPayloadTestAdapter.new if RAILS_VERSION < 6.0 + end + + def drain(at: nil) + # 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.1 + # Rails 5.2 and 6.0 both need a manual flush: + # - 5.2's +perform_enqueued_jobs+ always requires a block and + # only runs jobs enqueued *inside* the block, so it can't + # drain a pre-existing queue at all. + # - 6.0's +flush_enqueued_jobs+ iterates with +perform_now+ + # but doesn't remove payloads from +enqueued_jobs+ (the + # +delete(payload)+ call was only added in 6.1), so looping + # on +enqueued_jobs.empty?+ would spin forever. + # On 5.2 with 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 + end + end + + def last_enqueued_payload + payload = queue_adapter.enqueued_jobs.last + return nil if payload.nil? + + # On Rails < 6.0 we mirror the full serialize output under a side + # key (see Rails52FullPayloadTestAdapter above). Prefer that when + # present so callers see the same string-keyed shape they'd see on + # 6.0+. + payload[:_sentry_full_payload] || payload + 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 bde362193..e3093d938 100644 --- a/sentry-rails/spec/active_job/test_adapter_spec.rb +++ b/sentry-rails/spec/active_job/test_adapter_spec.rb @@ -4,6 +4,7 @@ RSpec.describe "Sentry + ActiveJob on the test adapter", type: :job do include_context "active_job backend harness", adapter: :test + include_context "test adapter" it_behaves_like "a Sentry-instrumented ActiveJob backend" it_behaves_like "an ActiveJob backend that supports distributed tracing" From 1b9dcfc09838641980dfbe2b5f6f27599a77ef79 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Wed, 20 May 2026 11:50:26 +0000 Subject: [PATCH 21/26] test(active_job): verify the AJ tracing suite passes on the :sidekiq adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs the common ActiveJob spec suite end-to-end against ActiveJob::QueueAdapters::SidekiqAdapter, driven by Sidekiq::Testing.fake! (block form, public API) and Sidekiq::Job.drain_all. Validates that the AJ tracing extension works as a generic, adapter- agnostic instrumentation — independent of sentry-sidekiq's native middleware. The :sidekiq context plugs into the harness via queue_adapter_for_test (installing a SidekiqAdapter instance through ActiveJob::TestHelper) and with_adapter_active (wrapping example.run in Sidekiq::Testing.fake! so fake mode is scoped per-example without touching global state). The context deliberately does not load sentry-sidekiq: loading it would install Sidekiq's client/server middleware globally and register SidekiqAdapter in skippable_job_adapters, both of which would short-circuit the AJ extension we're exercising. Sidekiq becomes a sentry-rails dev dependency, gated on Rails version (Sidekiq 7+ doesn't support Rails 5.2). The spec file and support file no-op cleanly on older matrices where the gem isn't bundled. Co-Authored-By: Claude Opus 4.7 (1M context) --- sentry-rails/Gemfile | 20 +++++ .../spec/active_job/sidekiq_adapter_spec.rb | 32 ++++++++ .../support/sidekiq_adapter_context.rb | 82 +++++++++++++++++++ 3 files changed, 134 insertions(+) create mode 100644 sentry-rails/spec/active_job/sidekiq_adapter_spec.rb create mode 100644 sentry-rails/spec/active_job/support/sidekiq_adapter_context.rb diff --git a/sentry-rails/Gemfile b/sentry-rails/Gemfile index ea1d6ac51..b34f03e8f 100644 --- a/sentry-rails/Gemfile +++ b/sentry-rails/Gemfile @@ -56,6 +56,26 @@ end gem "mini_magick" +# Sidekiq is a dev-only dependency, used by the common ActiveJob spec +# suite to verify the AJ tracing extension works against the :sidekiq +# adapter independent of sentry-sidekiq's native middleware. +# +# Gated on Ruby/Rails/platform because: +# - The sidekiq_adapter_spec only passes on Rails > 7.0. +# - Sidekiq 8 requires Ruby >= 3.2; older Rubies fall back to Sidekiq 7. +# - Sidekiq does not reliably support JRuby. +# +# sidekiq_adapter_spec.rb also rescues LoadError and re-checks the +# Rails version, so matrices that don't bundle Sidekiq skip the spec +# cleanly without any other gating. +unless RUBY_PLATFORM.include?("java") + if rails_version > Gem::Version.new("7.0.0") && ruby_version >= Gem::Version.new("3.2") + gem "sidekiq", "~> 8.0" + elsif rails_version > Gem::Version.new("7.0.0") + gem "sidekiq", "~> 7.0" + end +end + gem "sprockets-rails" gem "benchmark-ips" diff --git a/sentry-rails/spec/active_job/sidekiq_adapter_spec.rb b/sentry-rails/spec/active_job/sidekiq_adapter_spec.rb new file mode 100644 index 000000000..be7b15fd7 --- /dev/null +++ b/sentry-rails/spec/active_job/sidekiq_adapter_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "spec_helper" + +# These specs only pass on Rails > 7.0 — older Rails/Sidekiq adapter +# combinations expose differences (e.g. enqueue payload shape, retry +# wiring) that the shared examples don't tolerate. Bail out before +# loading Sidekiq so older matrices don't trip on the gem either. +return if RAILS_VERSION <= 7.0 + +# Sidekiq is also gated in the Gemfile by Ruby version and platform. +# Matrices that don't bundle Sidekiq won't have it available — rescue +# LoadError and skip the whole file so they don't blow up on the +# `include_context "sidekiq adapter"` below. +begin + require "sidekiq" + if ::Sidekiq.respond_to?(:testing!) + ::Sidekiq.testing!(:fake) + else + require "sidekiq/testing" + end +rescue LoadError + return +end + +RSpec.describe "Sentry + ActiveJob on the sidekiq adapter", type: :job do + include_context "active_job backend harness", adapter: :sidekiq + include_context "sidekiq adapter" + + it_behaves_like "a Sentry-instrumented ActiveJob backend" + it_behaves_like "an ActiveJob backend that supports distributed tracing" +end diff --git a/sentry-rails/spec/active_job/support/sidekiq_adapter_context.rb b/sentry-rails/spec/active_job/support/sidekiq_adapter_context.rb new file mode 100644 index 000000000..83ebce74e --- /dev/null +++ b/sentry-rails/spec/active_job/support/sidekiq_adapter_context.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +# Adapter context for the :sidekiq ActiveJob backend. +# +# Composes with "active_job backend harness" to drive Sidekiq via its +# in-memory testing mode. No Redis required: jobs are JSON-round-tripped +# into Sidekiq's class-keyed jobs hash at enqueue time and run in-process +# by drain_all. +# +# This context deliberately does NOT require sentry-sidekiq. Loading +# sentry-sidekiq would install Sidekiq's client/server middleware (which +# emits its own queue.process transactions) and register SidekiqAdapter +# in skippable_job_adapters (which short-circuits the AJ extension under +# test). +begin + require "sidekiq" + # Sidekiq 8.1+ deprecates `require "sidekiq/testing"` in favor of + # `Sidekiq.testing!`, which loads the same test API without the + # implicit side effect of activating :fake mode at require time. + if ::Sidekiq.respond_to?(:testing!) + ::Sidekiq.testing!(:fake) + else + require "sidekiq/testing" + end +rescue LoadError + # Sidekiq isn't bundled on this matrix (e.g. Rails 5.2). The matching + # spec file is gated on RAILS_VERSION so it won't try to use this + # context; skip defining it. + return +end + +RSpec.shared_context "sidekiq adapter" do + def queue_adapter_for_test + ::ActiveJob::QueueAdapters::SidekiqAdapter.new + end + + # Scope fake mode to this example only — the block form of +fake!+ + # uses a per-thread flag that auto-restores when the block exits, so + # parallel specs and any global Sidekiq mode set elsewhere are left + # untouched. Wrapping +example.run+ ensures both the +perform_later+ + # (enqueue) and +drain+ (consume) paths see fake mode. + def with_adapter_active(&block) + if ::Sidekiq.respond_to?(:testing!) + ::Sidekiq.testing!(:fake, &block) + else + ::Sidekiq::Testing.fake!(&block) + end + end + + def drain(at: nil) + # +drain_all+ loops +while jobs.any?+, so retried jobs (re-enqueued + # by ActiveJob's +retry_on+ during a drain pass) cascade within a + # single call. Exceptions raised by the worker propagate out — + # preserving the +expect { drain }.to raise_error(...)+ semantics + # the shared examples rely on. + sidekiq_job_class.drain_all + end + + def reset_adapter(_adapter) + sidekiq_job_class.clear_all + end + + def last_enqueued_payload + job = ::ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper.jobs.last + return nil if job.nil? + + # The AJ-on-Sidekiq adapter wraps the AJ payload as the first + # element of the Sidekiq job's args array (see + # +Sidekiq::ActiveJob::Wrapper#perform+ / + # +SidekiqAdapter::JobWrapper#perform+). + job["args"].first + end + + private + + # Sidekiq 8 renamed +Sidekiq::Worker+ to +Sidekiq::Job+ (with a + # back-compat alias). Reach for whichever is canonical on the + # installed version. + def sidekiq_job_class + defined?(::Sidekiq::Job) ? ::Sidekiq::Job : ::Sidekiq::Worker + end +end From f78cc36cceb3e678b183bf25ee051d8222ec5e8c Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Wed, 20 May 2026 12:07:02 +0000 Subject: [PATCH 22/26] test(e2e): end-to-end ActiveJob distributed-tracing spec Drives the svelte-mini app to click a new "Trigger Job" button, which fetches POST /jobs/sample on the rails-mini app. The browser SDK propagates sentry-trace + baggage to the Rails request; the AJ extension this branch adds emits a queue.publish span on the http.server transaction at enqueue, and a queue.active_job consumer transaction when the :async pool runs the job. The spec asserts all three rails-side artifacts share one trace and are correctly linked (sentry-trace header on the controller request, parent_span_id on the consumer transaction, and matching messaging.* data on the producer and consumer ends). Polls the shared envelope log because :async runs the job on a separate thread, so the HTTP response returns before the consumer transaction is recorded. Co-Authored-By: Claude Opus 4.7 (1M context) --- spec/apps/svelte-mini/src/App.svelte | 43 ++++++++++++ spec/features/active_job_tracing_spec.rb | 87 ++++++++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 spec/features/active_job_tracing_spec.rb diff --git a/spec/apps/svelte-mini/src/App.svelte b/spec/apps/svelte-mini/src/App.svelte index f7a93b0a6..bc21def22 100644 --- a/spec/apps/svelte-mini/src/App.svelte +++ b/spec/apps/svelte-mini/src/App.svelte @@ -2,6 +2,9 @@ let loading = false; let result = ""; + let jobLoading = false; + let jobResult = ""; + async function triggerError() { loading = true; try { @@ -24,6 +27,29 @@ loading = false; } } + + async function triggerJob() { + jobLoading = true; + try { + const response = await fetch(`${SENTRY_E2E_RAILS_APP_URL}/jobs/sample`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + }); + + if (response.ok) { + const data = await response.json(); + jobResult = `Job: ${JSON.stringify(data)}`; + } else { + jobResult = `Error: ${response.status} ${response.statusText}`; + } + } catch (error) { + jobResult = `Error: ${error.message}`; + } finally { + jobLoading = false; + } + }
@@ -43,6 +69,23 @@
{result}
{/if} + +

+ Click the button to enqueue an ActiveJob in the Rails app — distributed + tracing should connect this fetch, the Rails controller, the + queue.publish span, and the async-executed job: +

+ + + + {#if jobResult} +
+

Job result:

+
{jobResult}
+
+ {/if}