From 14d3f8602e8cd41a10276dbbbe48e0efa4d5b552 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 7 May 2026 12:31:48 +0000 Subject: [PATCH 01/13] 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 54386ae51e56a0b8ad71ec970975a17f4d3f7004 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 7 May 2026 12:34:23 +0000 Subject: [PATCH 02/13] 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 aaadbb96fb7c7d4eeaea3d4ba362ee0e1cd028f5 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 7 May 2026 12:45:52 +0000 Subject: [PATCH 03/13] 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 06f197533b0c8097978b40e3dc3c7cc955eec48a Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 7 May 2026 12:47:05 +0000 Subject: [PATCH 04/13] 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 91d4b9a57ad341cfe66d5154c22de31bdb7cc278 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 7 May 2026 12:49:15 +0000 Subject: [PATCH 05/13] 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 f1c51eb180a5de1ebcbd220bdf824728028ae431 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 7 May 2026 12:54:06 +0000 Subject: [PATCH 06/13] 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 ef2dd6c077592073cc0085dc4b390d52c0431c85 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 7 May 2026 12:58:14 +0000 Subject: [PATCH 07/13] 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 1745fbbfd93ce883baff8473dbf2f9a10b0533c1 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 7 May 2026 13:02:47 +0000 Subject: [PATCH 08/13] 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 6c5ec79aba04b7e0f8099c3acff0c26a5510824f Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Mon, 27 Apr 2026 12:37:45 +0000 Subject: [PATCH 09/13] feat(rails): initial support for Solid Queue --- sentry-rails/.gitignore | 2 +- sentry-rails/Gemfile | 3 + .../spec/active_job/solid_queue_spec.rb | 44 ++++++ .../spec/active_job/support/harness.rb | 33 ++++- .../test_rails_app/config/application.rb | 12 ++ .../dummy/test_rails_app/db/queue_schema.rb | 131 ++++++++++++++++++ 6 files changed, 218 insertions(+), 7 deletions(-) create mode 100644 sentry-rails/spec/active_job/solid_queue_spec.rb create mode 100644 sentry-rails/spec/dummy/test_rails_app/db/queue_schema.rb diff --git a/sentry-rails/.gitignore b/sentry-rails/.gitignore index e8acda211..27b24b079 100644 --- a/sentry-rails/.gitignore +++ b/sentry-rails/.gitignore @@ -5,7 +5,7 @@ /doc/ /pkg/ /spec/reports/ -/spec/dummy/test_rails_app/db* +/spec/dummy/test_rails_app/**/*.sqlite3* /tmp/ # rspec failure tracking diff --git a/sentry-rails/Gemfile b/sentry-rails/Gemfile index ea1d6ac51..3b658d47f 100644 --- a/sentry-rails/Gemfile +++ b/sentry-rails/Gemfile @@ -32,13 +32,16 @@ gem "rails", "~> #{rails_version}" if rails_version >= Gem::Version.new("8.1.0") gem "rspec-rails", "~> 8.0.0" + gem "solid_queue" gem "sqlite3", "~> 2.1.1", platform: :ruby elsif rails_version >= Gem::Version.new("8.0.0") gem "rspec-rails", "~> 8.0.0" + gem "solid_queue" gem "sqlite3", "~> 2.1.1", platform: :ruby elsif rails_version >= Gem::Version.new("7.1.0") gem "psych", "~> 4.0.0" gem "rspec-rails", "~> 7.0" + gem "solid_queue" gem "sqlite3", "~> 1.7.3", platform: :ruby elsif rails_version >= Gem::Version.new("6.1.0") gem "rspec-rails", "~> 6.0" diff --git a/sentry-rails/spec/active_job/solid_queue_spec.rb b/sentry-rails/spec/active_job/solid_queue_spec.rb new file mode 100644 index 000000000..ed1f3711c --- /dev/null +++ b/sentry-rails/spec/active_job/solid_queue_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require "spec_helper" + +if Gem::Version.new(Rails.version) >= Gem::Version.new("7.1") + require "solid_queue" +end + +RSpec.describe "Sentry + ActiveJob on SolidQueue", skip: Gem::Version.new(Rails.version) < Gem::Version.new("7.1") do + include ActiveSupport::Testing::TimeHelpers + include_context "active_job backend harness", adapter: :solid_queue + + def boot_adapter(_adapter) + Sentry::Rails::Test::Application.load_queue_schema + end + + def reset_adapter(_adapter) + [ + SolidQueue::ReadyExecution, + SolidQueue::ClaimedExecution, + SolidQueue::FailedExecution, + SolidQueue::BlockedExecution, + SolidQueue::ScheduledExecution, + SolidQueue::RecurringExecution, + SolidQueue::Process, + SolidQueue::Job + ].each(&:delete_all) + end + + def drain(at: nil) + process = SolidQueue::Process.register( + kind: "Worker", + pid: ::Process.pid, + name: "spec-#{SecureRandom.hex(4)}" + ) + + travel_to(at || Time.current) do + SolidQueue::ScheduledExecution.dispatch_next_batch(100) + SolidQueue::ReadyExecution.claim("*", 100, process.id).each(&:perform) + end + end + + it_behaves_like "a Sentry-instrumented ActiveJob backend" +end diff --git a/sentry-rails/spec/active_job/support/harness.rb b/sentry-rails/spec/active_job/support/harness.rb index 1ad6ce678..6ec924700 100644 --- a/sentry-rails/spec/active_job/support/harness.rb +++ b/sentry-rails/spec/active_job/support/harness.rb @@ -19,14 +19,27 @@ teardown_sentry_test end - def boot_adapter(_adapter) - # Per-adapter setup hook. Backends extend this when they need to load - # schemas, start supervisors, or otherwise prepare the environment. + def boot_adapter(adapter) + case adapter + when :solid_queue + Sentry::Rails::Test::Application.load_queue_schema + end end - def reset_adapter(_adapter) - # Per-adapter teardown hook. Backends extend this to truncate tables - # or otherwise clean up state between examples. + def reset_adapter(adapter) + case adapter + when :solid_queue + [ + SolidQueue::ReadyExecution, + SolidQueue::ClaimedExecution, + SolidQueue::FailedExecution, + SolidQueue::BlockedExecution, + SolidQueue::ScheduledExecution, + SolidQueue::RecurringExecution, + SolidQueue::Process, + SolidQueue::Job + ].each(&:delete_all) + end end def drain(at: nil) @@ -42,6 +55,14 @@ def drain(at: nil) kwargs = at ? { at: at } : {} perform_enqueued_jobs(**kwargs) end + when :solid_queue + process = SolidQueue::Process.register( + kind: "Worker", + pid: ::Process.pid, + name: "spec-#{SecureRandom.hex(4)}" + ) + + SolidQueue::ReadyExecution.claim("*", 100, process.id).each(&:perform) else raise NotImplementedError, "active_job backend harness has no drain strategy for adapter: #{adapter.inspect}" end diff --git a/sentry-rails/spec/dummy/test_rails_app/config/application.rb b/sentry-rails/spec/dummy/test_rails_app/config/application.rb index 6275220de..31b70bfa2 100644 --- a/sentry-rails/spec/dummy/test_rails_app/config/application.rb +++ b/sentry-rails/spec/dummy/test_rails_app/config/application.rb @@ -45,6 +45,10 @@ def self.schema_file @schema_file ||= root_path.join("db/schema.rb") end + def self.queue_schema_file + @queue_schema_file ||= root_path.join("db/queue_schema.rb") + end + def self.db_path @db_path ||= root_path.join("db", "db.sqlite3") end @@ -77,6 +81,14 @@ def self.load_test_schema end end + def self.load_queue_schema + @__queue_schema_loaded__ ||= begin + load_test_schema + require Test::Application.queue_schema_file + true + end + end + # Configure method that sets up base configuration # This can be inherited and extended by subclasses def configure diff --git a/sentry-rails/spec/dummy/test_rails_app/db/queue_schema.rb b/sentry-rails/spec/dummy/test_rails_app/db/queue_schema.rb new file mode 100644 index 000000000..0c9e37bfa --- /dev/null +++ b/sentry-rails/spec/dummy/test_rails_app/db/queue_schema.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +ActiveRecord::Schema[7.1].define(version: 1) do + create_table "solid_queue_blocked_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.string "concurrency_key", null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.index [ "concurrency_key", "priority", "job_id" ], name: "index_solid_queue_blocked_executions_for_release" + t.index [ "expires_at", "concurrency_key" ], name: "index_solid_queue_blocked_executions_for_maintenance" + t.index [ "job_id" ], name: "index_solid_queue_blocked_executions_on_job_id", unique: true + end + + create_table "solid_queue_claimed_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.bigint "process_id" + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_claimed_executions_on_job_id", unique: true + t.index [ "process_id", "job_id" ], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id" + end + + create_table "solid_queue_failed_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.text "error" + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_failed_executions_on_job_id", unique: true + end + + create_table "solid_queue_jobs", force: :cascade do |t| + t.string "queue_name", null: false + t.string "class_name", null: false + t.text "arguments" + t.integer "priority", default: 0, null: false + t.string "active_job_id" + t.datetime "scheduled_at" + t.datetime "finished_at" + t.string "concurrency_key" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "active_job_id" ], name: "index_solid_queue_jobs_on_active_job_id" + t.index [ "class_name" ], name: "index_solid_queue_jobs_on_class_name" + t.index [ "finished_at" ], name: "index_solid_queue_jobs_on_finished_at" + t.index [ "queue_name", "finished_at" ], name: "index_solid_queue_jobs_for_filtering" + t.index [ "scheduled_at", "finished_at" ], name: "index_solid_queue_jobs_for_alerting" + end + + create_table "solid_queue_pauses", force: :cascade do |t| + t.string "queue_name", null: false + t.datetime "created_at", null: false + t.index [ "queue_name" ], name: "index_solid_queue_pauses_on_queue_name", unique: true + end + + create_table "solid_queue_processes", force: :cascade do |t| + t.string "kind", null: false + t.datetime "last_heartbeat_at", null: false + t.bigint "supervisor_id" + t.integer "pid", null: false + t.string "hostname" + t.text "metadata" + t.datetime "created_at", null: false + t.string "name", null: false + t.index [ "last_heartbeat_at" ], name: "index_solid_queue_processes_on_last_heartbeat_at" + t.index [ "name", "supervisor_id" ], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true + t.index [ "supervisor_id" ], name: "index_solid_queue_processes_on_supervisor_id" + end + + create_table "solid_queue_ready_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_ready_executions_on_job_id", unique: true + t.index [ "priority", "job_id" ], name: "index_solid_queue_poll_all" + t.index [ "queue_name", "priority", "job_id" ], name: "index_solid_queue_poll_by_queue" + end + + create_table "solid_queue_recurring_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "task_key", null: false + t.datetime "run_at", null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_recurring_executions_on_job_id", unique: true + t.index [ "task_key", "run_at" ], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true + end + + create_table "solid_queue_recurring_tasks", force: :cascade do |t| + t.string "key", null: false + t.string "schedule", null: false + t.string "command", limit: 2048 + t.string "class_name" + t.text "arguments" + t.string "queue_name" + t.integer "priority", default: 0 + t.boolean "static", default: true, null: false + t.text "description" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "key" ], name: "index_solid_queue_recurring_tasks_on_key", unique: true + t.index [ "static" ], name: "index_solid_queue_recurring_tasks_on_static" + end + + create_table "solid_queue_scheduled_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.datetime "scheduled_at", null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true + t.index [ "scheduled_at", "priority", "job_id" ], name: "index_solid_queue_dispatch_all" + end + + create_table "solid_queue_semaphores", force: :cascade do |t| + t.string "key", null: false + t.integer "value", default: 1, null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "expires_at" ], name: "index_solid_queue_semaphores_on_expires_at" + t.index [ "key", "value" ], name: "index_solid_queue_semaphores_on_key_and_value" + t.index [ "key" ], name: "index_solid_queue_semaphores_on_key", unique: true + end + + add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade +end From 49f4cb1493cf898173dc0c6f23cf3586ca5be884 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 30 Apr 2026 15:39:07 +0200 Subject: [PATCH 10/13] fixup: do not crash on rails w/o SQ --- .../spec/active_job/solid_queue_spec.rb | 64 +++++++++---------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/sentry-rails/spec/active_job/solid_queue_spec.rb b/sentry-rails/spec/active_job/solid_queue_spec.rb index ed1f3711c..412ed2ff2 100644 --- a/sentry-rails/spec/active_job/solid_queue_spec.rb +++ b/sentry-rails/spec/active_job/solid_queue_spec.rb @@ -2,43 +2,43 @@ require "spec_helper" -if Gem::Version.new(Rails.version) >= Gem::Version.new("7.1") +if RAILS_VERSION >= 7.1 require "solid_queue" -end - -RSpec.describe "Sentry + ActiveJob on SolidQueue", skip: Gem::Version.new(Rails.version) < Gem::Version.new("7.1") do - include ActiveSupport::Testing::TimeHelpers - include_context "active_job backend harness", adapter: :solid_queue - def boot_adapter(_adapter) - Sentry::Rails::Test::Application.load_queue_schema - end + RSpec.describe "Sentry + ActiveJob on SolidQueue" do + include ActiveSupport::Testing::TimeHelpers + include_context "active_job backend harness", adapter: :solid_queue - def reset_adapter(_adapter) - [ - SolidQueue::ReadyExecution, - SolidQueue::ClaimedExecution, - SolidQueue::FailedExecution, - SolidQueue::BlockedExecution, - SolidQueue::ScheduledExecution, - SolidQueue::RecurringExecution, - SolidQueue::Process, - SolidQueue::Job - ].each(&:delete_all) - end + def boot_adapter(_adapter) + Sentry::Rails::Test::Application.load_queue_schema + end - def drain(at: nil) - process = SolidQueue::Process.register( - kind: "Worker", - pid: ::Process.pid, - name: "spec-#{SecureRandom.hex(4)}" - ) + def reset_adapter(_adapter) + [ + SolidQueue::ReadyExecution, + SolidQueue::ClaimedExecution, + SolidQueue::FailedExecution, + SolidQueue::BlockedExecution, + SolidQueue::ScheduledExecution, + SolidQueue::RecurringExecution, + SolidQueue::Process, + SolidQueue::Job + ].each(&:delete_all) + end - travel_to(at || Time.current) do - SolidQueue::ScheduledExecution.dispatch_next_batch(100) - SolidQueue::ReadyExecution.claim("*", 100, process.id).each(&:perform) + def drain(at: nil) + process = SolidQueue::Process.register( + kind: "Worker", + pid: ::Process.pid, + name: "spec-#{SecureRandom.hex(4)}" + ) + + travel_to(at || Time.current) do + SolidQueue::ScheduledExecution.dispatch_next_batch(100) + SolidQueue::ReadyExecution.claim("*", 100, process.id).each(&:perform) + end end - end - it_behaves_like "a Sentry-instrumented ActiveJob backend" + it_behaves_like "a Sentry-instrumented ActiveJob backend" + end end From 53b2b76a3b40cd5f104a2324c7d5c268c4679332 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 30 Apr 2026 15:48:37 +0200 Subject: [PATCH 11/13] fixup: fix syntax for older rubies --- sentry-rails/spec/active_job/solid_queue_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry-rails/spec/active_job/solid_queue_spec.rb b/sentry-rails/spec/active_job/solid_queue_spec.rb index 412ed2ff2..ec14b272b 100644 --- a/sentry-rails/spec/active_job/solid_queue_spec.rb +++ b/sentry-rails/spec/active_job/solid_queue_spec.rb @@ -2,7 +2,7 @@ require "spec_helper" -if RAILS_VERSION >= 7.1 +if RAILS_VERSION >= 7.1 && RUBY_VERSION >= "3.1" require "solid_queue" RSpec.describe "Sentry + ActiveJob on SolidQueue" do From 439ff9f1f730b66ad594218d70337d332acf0640 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 30 Apr 2026 15:55:59 +0200 Subject: [PATCH 12/13] fixup: remove Solid Queue specific logic from adapter methods --- .../spec/active_job/support/harness.rb | 25 ------------------- 1 file changed, 25 deletions(-) diff --git a/sentry-rails/spec/active_job/support/harness.rb b/sentry-rails/spec/active_job/support/harness.rb index 6ec924700..7bb1544d1 100644 --- a/sentry-rails/spec/active_job/support/harness.rb +++ b/sentry-rails/spec/active_job/support/harness.rb @@ -20,26 +20,9 @@ end def boot_adapter(adapter) - case adapter - when :solid_queue - Sentry::Rails::Test::Application.load_queue_schema - end end def reset_adapter(adapter) - case adapter - when :solid_queue - [ - SolidQueue::ReadyExecution, - SolidQueue::ClaimedExecution, - SolidQueue::FailedExecution, - SolidQueue::BlockedExecution, - SolidQueue::ScheduledExecution, - SolidQueue::RecurringExecution, - SolidQueue::Process, - SolidQueue::Job - ].each(&:delete_all) - end end def drain(at: nil) @@ -55,14 +38,6 @@ def drain(at: nil) kwargs = at ? { at: at } : {} perform_enqueued_jobs(**kwargs) end - when :solid_queue - process = SolidQueue::Process.register( - kind: "Worker", - pid: ::Process.pid, - name: "spec-#{SecureRandom.hex(4)}" - ) - - SolidQueue::ReadyExecution.claim("*", 100, process.id).each(&:perform) else raise NotImplementedError, "active_job backend harness has no drain strategy for adapter: #{adapter.inspect}" end From 22e181a6ac12a53942c6991ba6b05624d193742b Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 7 May 2026 13:48:23 +0000 Subject: [PATCH 13/13] test(rails): run distributed_tracing shared example against :solid_queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opts the SolidQueue adapter spec into the distributed_tracing meta so all five tracing examples (producer span, trace propagation, messaging span data, user propagation, worker hub isolation) run against :solid_queue alongside the existing :test adapter coverage. Two SolidQueue-specific harness adjustments were needed to make this work on the SQLite-backed test setup: - drain only wraps in travel_to when the caller passes an explicit `at:`. Without this, the inner travel_to(Time.current) collides with the outer travel block in messaging_span_data.rb's latency assertion. - worker_hub_isolation.rb now spawns its threads through a `worker_thread` harness hook (default = `Thread.new`). The :solid_queue spec overrides this hook to allocate an isolated SQLite shard per spawned thread via `connects_to(shards: ...)` + `connected_to(shard:)`. Each worker thread reads/writes its own SolidQueue tables, eliminating the SQLite3::BusyException that two concurrent perform_later/drain pipelines on the shared test DB would otherwise raise. The contract the spec enforces — concurrent jobs in different threads do not cross-pollute Sentry scope — holds the same way it does on :test. Verified GREEN on Rails 6.1 (SQ skipped, :test adapter only), 7.1, and 8.1 via ./bin/test --version. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tracing/worker_hub_isolation.rb | 4 +- .../spec/active_job/solid_queue_spec.rb | 61 ++++++++++++++++++- .../spec/active_job/support/harness.rb | 8 +++ 3 files changed, 70 insertions(+), 3 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/solid_queue_spec.rb b/sentry-rails/spec/active_job/solid_queue_spec.rb index ec14b272b..6f0e10865 100644 --- a/sentry-rails/spec/active_job/solid_queue_spec.rb +++ b/sentry-rails/spec/active_job/solid_queue_spec.rb @@ -9,8 +9,61 @@ include ActiveSupport::Testing::TimeHelpers include_context "active_job backend harness", adapter: :solid_queue + WORKER_SHARD_COUNT = 4 + def boot_adapter(_adapter) Sentry::Rails::Test::Application.load_queue_schema + + install_worker_shards + end + + # Sets up `WORKER_SHARD_COUNT` independent SQLite databases as AR + # shards alongside the primary test DB. Each worker thread spawned + # by `worker_thread` claims its own shard, so concurrent perform_later + # / drain calls from different threads never contend on the same + # SQLite file (which would otherwise raise SQLite3::BusyException). + def install_worker_shards + base_dir = Sentry::Rails::Test::Application.root_path.join("db") + worker_paths = (1..WORKER_SHARD_COUNT).map { |i| base_dir.join("queue_worker_#{i}.sqlite3") } + + # Wipe any previous run's files so each spec starts fresh. + worker_paths.each { |p| File.unlink(p) if File.exist?(p) } + + primary_db = Sentry::Rails::Test::Application.db_path.to_s + configs = { "primary" => { "adapter" => "sqlite3", "database" => primary_db, "timeout" => 5000 } } + worker_paths.each_with_index do |path, i| + configs["worker_#{i + 1}"] = { "adapter" => "sqlite3", "database" => path.to_s, "timeout" => 5000 } + end + + ActiveRecord::Base.configurations = { "test" => configs } + + shards = { default: { writing: :primary } } + WORKER_SHARD_COUNT.times { |i| shards[:"worker_#{i + 1}"] = { writing: :"worker_#{i + 1}" } } + ActiveRecord::Base.connects_to(shards: shards) + + # Load the queue schema into each worker shard so its tables exist. + WORKER_SHARD_COUNT.times do |i| + ActiveRecord::Base.connected_to(shard: :"worker_#{i + 1}") do + load Sentry::Rails::Test::Application.queue_schema_file + end + end + + @worker_shard_counter = 0 + @worker_shard_mutex = Mutex.new + end + + def next_worker_shard + @worker_shard_mutex.synchronize do + @worker_shard_counter = (@worker_shard_counter % WORKER_SHARD_COUNT) + 1 + :"worker_#{@worker_shard_counter}" + end + end + + def worker_thread(&block) + shard = next_worker_shard + Thread.new do + ActiveRecord::Base.connected_to(shard: shard, &block) + end end def reset_adapter(_adapter) @@ -33,12 +86,18 @@ def drain(at: nil) name: "spec-#{SecureRandom.hex(4)}" ) - travel_to(at || Time.current) do + run = lambda do SolidQueue::ScheduledExecution.dispatch_next_batch(100) SolidQueue::ReadyExecution.claim("*", 100, process.id).each(&:perform) end + + # Only wrap in travel_to when the caller explicitly asks for a future + # time — otherwise nested travel_to (e.g. from a spec that already + # called `travel`) raises. + at ? travel_to(at, &run) : run.call end it_behaves_like "a Sentry-instrumented ActiveJob backend" + it_behaves_like "an ActiveJob backend that supports distributed tracing" end end diff --git a/sentry-rails/spec/active_job/support/harness.rb b/sentry-rails/spec/active_job/support/harness.rb index 7bb1544d1..79d8e212a 100644 --- a/sentry-rails/spec/active_job/support/harness.rb +++ b/sentry-rails/spec/active_job/support/harness.rb @@ -65,4 +65,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