diff --git a/sentry-rails/Rakefile b/sentry-rails/Rakefile index abb28f1a5..a591eba8e 100644 --- a/sentry-rails/Rakefile +++ b/sentry-rails/Rakefile @@ -4,7 +4,7 @@ require "bundler/gem_tasks" require_relative "../lib/sentry/test/rake_tasks" Sentry::Test::RakeTasks.define_spec_tasks( - spec_pattern: "spec/sentry/**/*_spec.rb", + spec_pattern: "{spec/sentry,spec/active_job}/**/*_spec.rb", spec_rspec_opts: "--order rand --format progress", isolated_specs_pattern: "spec/isolated/**/*_spec.rb", isolated_rspec_opts: "--format progress" diff --git a/sentry-rails/spec/active_job/shared_examples/adapter_skipping.rb b/sentry-rails/spec/active_job/shared_examples/adapter_skipping.rb new file mode 100644 index 000000000..cb7f0045d --- /dev/null +++ b/sentry-rails/spec/active_job/shared_examples/adapter_skipping.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +RSpec.shared_examples "an ActiveJob backend that respects skippable_job_adapters" do + let(:failing_job) do + job_fixture do + def perform + raise "boom from failing_job spec" + end + end + end + + it "captures no events when the adapter is in skippable_job_adapters" do + Sentry.configuration.rails.skippable_job_adapters = [ + failing_job.queue_adapter.class.to_s + ] + + expect do + failing_job.perform_later + drain + end.to raise_error(RuntimeError, /boom from failing_job spec/) + + expect(sentry_events).to be_empty + end +end diff --git a/sentry-rails/spec/active_job/shared_examples/argument_serialization.rb b/sentry-rails/spec/active_job/shared_examples/argument_serialization.rb new file mode 100644 index 000000000..43eb300df --- /dev/null +++ b/sentry-rails/spec/active_job/shared_examples/argument_serialization.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +RSpec.shared_examples "an ActiveJob backend that serializes complex arguments" do + let(:failing_job) do + job_fixture do + def perform(*_args, **_kwargs) + raise "boom from argument_serialization spec" + end + end + end + + def event_arguments + last_sentry_event.extra[:arguments] + end + + it "serializes ActiveRecord arguments via global id" do + post = Post.create! + + expect do + failing_job.perform_later(post) + drain + end.to raise_error(RuntimeError, /boom from argument_serialization spec/) + + expect(event_arguments).to eq([post.to_global_id.to_s]) + end + + it "recursively serializes nested hashes containing global ids" do + post = Post.create! + + expect do + failing_job.perform_later(wrapper: { post: post }) + drain + end.to raise_error(RuntimeError, /boom from argument_serialization spec/) + + expect(event_arguments).to eq([{ wrapper: { post: post.to_global_id.to_s } }]) + end + + it "expands integer ranges into arrays", skip: RAILS_VERSION < 7.0 do + expect do + failing_job.perform_later(1..3) + drain + end.to raise_error(RuntimeError, /boom from argument_serialization spec/) + + expect(event_arguments).to eq([[1, 2, 3]]) + end + + it "stringifies ActiveSupport::TimeWithZone ranges preserving the boundary operator", skip: RAILS_VERSION < 7.0 do + range = 1.day.ago...Time.zone.now + + expect do + failing_job.perform_later(range) + drain + end.to raise_error(RuntimeError, /boom from argument_serialization spec/) + + serialized = event_arguments.first + expect(serialized).to be_a(String) + expect(serialized).to eq("#{range.first}...#{range.last}") + end + + it "falls back to the original argument when to_global_id raises" do + post = Post.create! + + problematic_job = job_fixture do + def perform(passed_post) + def passed_post.to_global_id + raise "intentional" + end + + raise "boom from argument_serialization spec" + end + end + + expect do + problematic_job.perform_later(post) + drain + end.to raise_error(RuntimeError, /boom from argument_serialization spec/) + + expect(event_arguments).to eq([post]) + end + + it "passes through objects that do not respond to to_global_id unchanged" do + mod = Module.new + + module_job = job_fixture do + def perform(_mod) + raise "boom from argument_serialization spec" + end + end + + expect do + module_job.perform_now(mod) + end.to raise_error(RuntimeError, /boom from argument_serialization spec/) + + expect(event_arguments).to eq([mod]) + end +end diff --git a/sentry-rails/spec/active_job/shared_examples/cron_check_ins.rb b/sentry-rails/spec/active_job/shared_examples/cron_check_ins.rb new file mode 100644 index 000000000..6de6c5209 --- /dev/null +++ b/sentry-rails/spec/active_job/shared_examples/cron_check_ins.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +RSpec.shared_examples "an ActiveJob backend that emits cron check-ins for monitor jobs" do + let(:cron_job) do + job_fixture do + include Sentry::Cron::MonitorCheckIns + sentry_monitor_check_ins slug: "test-cron-ok-job" + + def perform + "ok" + end + end + end + + let(:failing_cron_job) do + job_fixture do + include Sentry::Cron::MonitorCheckIns + sentry_monitor_check_ins( + slug: "test-cron-fail-job", + monitor_config: Sentry::Cron::MonitorConfig.from_crontab("5 * * * *") + ) + + def perform + raise "boom from failing_cron_job spec" + end + end + end + + it "emits in_progress and ok check-ins with correct slug for a successful job" do + cron_job.perform_later + drain + + check_ins = sentry_events.select { |e| e.is_a?(Sentry::CheckInEvent) } + expect(check_ins.size).to eq(2) + + first, second = check_ins + expect(first.to_h).to include( + type: "check_in", + status: :in_progress, + monitor_slug: "test-cron-ok-job" + ) + expect(second.to_h).to include( + type: "check_in", + status: :ok, + check_in_id: first.check_in_id, + monitor_slug: "test-cron-ok-job" + ) + expect(second.to_h).to have_key(:duration) + end + + it "returns the job's perform value through the cron mixin" do + result = cron_job.perform_now + expect(result).to eq("ok") + end + + it "emits in_progress and error check-ins with monitor_config for a failing job" do + expect do + failing_cron_job.perform_later + drain + end.to raise_error(RuntimeError, /boom from failing_cron_job spec/) + + check_ins = sentry_events.select { |e| e.is_a?(Sentry::CheckInEvent) } + error_events = sentry_events.select { |e| e.is_a?(Sentry::ErrorEvent) } + + expect(check_ins.map { |e| e.to_h[:status] }).to eq(%i[in_progress error]) + expect(check_ins.map { |e| e.to_h[:monitor_slug] }).to all(eq("test-cron-fail-job")) + expect(check_ins.map { |e| e.to_h[:monitor_config] }).to all(include( + schedule: { type: :crontab, value: "5 * * * *" } + )) + expect(error_events.size).to eq(1) + end +end diff --git a/sentry-rails/spec/active_job/shared_examples/deserialization_error.rb b/sentry-rails/spec/active_job/shared_examples/deserialization_error.rb new file mode 100644 index 000000000..2e275514a --- /dev/null +++ b/sentry-rails/spec/active_job/shared_examples/deserialization_error.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +RSpec.shared_examples "an ActiveJob backend that unwraps DeserializationError" do + let(:deserialization_error_job) do + job_fixture do + def perform + 1 / 0 + rescue + err = ActiveJob::DeserializationError.new + # DeserializationError#initialize copies $!.backtrace, which on JRuby can + # contain nil elements for frames defined in anonymous Class.new blocks. + # Compact the backtrace to avoid a JRuby NPE in traceRaise at shutdown. + err.set_backtrace(Array(err.backtrace).compact) + raise err + end + end + end + + it "captures the root cause when wrapped in ActiveJob::DeserializationError" do + expect do + deserialization_error_job.perform_later + drain + end.to raise_error(ActiveJob::DeserializationError) + + expect(sentry_events.size).to eq(1) + + types = extract_sentry_exceptions(sentry_events.last).map(&:type) + expect(types.first).to eq("ZeroDivisionError") + end +end diff --git a/sentry-rails/spec/active_job/shared_examples/discard_semantics.rb b/sentry-rails/spec/active_job/shared_examples/discard_semantics.rb new file mode 100644 index 000000000..6ccf4fa84 --- /dev/null +++ b/sentry-rails/spec/active_job/shared_examples/discard_semantics.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +RSpec.shared_examples "an ActiveJob backend that respects discard semantics" do + let(:discardable_job) do + job_fixture do + discard_on StandardError + + def perform + raise "boom from discardable_job spec" + end + end + end + + it "does not capture an event when the job is discarded" do + expect do + discardable_job.perform_later + drain + end.not_to raise_error + + expect(sentry_events).to be_empty + end +end diff --git a/sentry-rails/spec/active_job/shared_examples/error_capture.rb b/sentry-rails/spec/active_job/shared_examples/error_capture.rb new file mode 100644 index 000000000..8f60c42f7 --- /dev/null +++ b/sentry-rails/spec/active_job/shared_examples/error_capture.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +RSpec.shared_examples "an ActiveJob backend that captures errors" do + let(:failing_job) do + job_fixture do + def perform + raise "boom from failing_job spec" + end + end + end + + it "captures an error event when a job fails" do + expect do + failing_job.perform_later + drain + end.to raise_error(RuntimeError, /boom from failing_job spec/) + + expect(sentry_events.size).to eq(1) + + exception = extract_sentry_exceptions(sentry_events.last).first + expect(exception.value).to match(/boom from failing_job spec/) + end +end diff --git a/sentry-rails/spec/active_job/shared_examples/error_context.rb b/sentry-rails/spec/active_job/shared_examples/error_context.rb new file mode 100644 index 000000000..897d8dede --- /dev/null +++ b/sentry-rails/spec/active_job/shared_examples/error_context.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +RSpec.shared_examples "an ActiveJob backend that attaches job context to error events" do + let(:failing_job) do + job_fixture do + def perform + a = 1 + b = 0 + raise "boom from failing_job spec" + end + end + end + + it "attaches job context to extras and tags on the captured event" do + expect do + failing_job.perform_later + drain + end.to raise_error(RuntimeError, /boom from failing_job spec/) + + event = last_sentry_event + + expect(event.extra).to include( + active_job: failing_job.name, + arguments: [], + job_id: a_kind_of(String) + ) + expect(event.extra).to have_key(:provider_job_id) + expect(event.extra).to have_key(:locale) + expect(event.extra).to have_key(:scheduled_at) + + expect(event.tags).to include( + job_id: event.extra[:job_id], + provider_job_id: event.extra[:provider_job_id] + ) + + last_frame = event.exception.values.first.stacktrace.frames.last + expect(last_frame.vars).to include(a: "1", b: "0") + end +end diff --git a/sentry-rails/spec/active_job/shared_examples/rescue_from_handling.rb b/sentry-rails/spec/active_job/shared_examples/rescue_from_handling.rb new file mode 100644 index 000000000..9c1ad49c7 --- /dev/null +++ b/sentry-rails/spec/active_job/shared_examples/rescue_from_handling.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +RSpec.shared_examples "an ActiveJob backend that respects rescue_from" do + context "when rescue_from suppresses the error" do + let(:rescued_job) do + job_fixture do + rescue_from(StandardError) { |_error| nil } + + def perform + raise "boom from rescued_job spec" + end + end + end + + it "does not capture an event" do + expect do + rescued_job.perform_later + drain + end.not_to raise_error + + expect(sentry_events).to be_empty + end + end + + context "when the rescue_from callback raises a new error" do + let(:problematic_rescued_job) do + job_fixture do + rescue_from(StandardError) { |_error| raise "boom from rescue callback" } + + def perform + raise "original boom from problematic_rescued_job spec" + end + end + end + + it "captures one event chaining the original and callback errors" do + expect do + problematic_rescued_job.perform_later + drain + end.to raise_error(RuntimeError, /boom from rescue callback/) + + expect(sentry_events.size).to eq(1) + + messages = extract_sentry_exceptions(sentry_events.last).map(&:value) + expect(messages).to include(match(/original boom from problematic_rescued_job spec/)) + expect(messages).to include(match(/boom from rescue callback/)) + end + end +end diff --git a/sentry-rails/spec/active_job/shared_examples/retry_semantics.rb b/sentry-rails/spec/active_job/shared_examples/retry_semantics.rb new file mode 100644 index 000000000..4feead2f8 --- /dev/null +++ b/sentry-rails/spec/active_job/shared_examples/retry_semantics.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +RSpec.shared_examples "an ActiveJob backend that respects retry semantics" do + let(:retryable_job) do + job_fixture do + retry_on StandardError, attempts: 3, wait: 0 + + def perform + raise "boom from retryable_job spec" + end + end + end + + it "captures one error event after retries are exhausted", skip: RAILS_VERSION < 6.0 do + expect do + retryable_job.perform_later + 3.times { drain } + end.to raise_error(RuntimeError, /boom from retryable_job spec/) + + expect(sentry_events.size).to eq(1) + + exception = extract_sentry_exceptions(sentry_events.last).first + expect(exception.value).to match(/boom from retryable_job spec/) + end + + context "when active_job_report_on_retry_error is true" do + before do + Sentry.configuration.rails.active_job_report_on_retry_error = true + end + + it "captures one error event per attempt", skip: RAILS_VERSION < 6.0 do + expect do + retryable_job.perform_later + 3.times { drain } + end.to raise_error(RuntimeError, /boom from retryable_job spec/) + + expect(sentry_events.size).to eq(3) + end + end +end diff --git a/sentry-rails/spec/active_job/shared_examples/return_value_preservation.rb b/sentry-rails/spec/active_job/shared_examples/return_value_preservation.rb new file mode 100644 index 000000000..ab97562fd --- /dev/null +++ b/sentry-rails/spec/active_job/shared_examples/return_value_preservation.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +RSpec.shared_examples "an ActiveJob backend that preserves the job return value" do + let(:returning_job) do + job_fixture do + def perform + "return value from job" + end + end + end + + it "returns the job's perform value from perform_now" do + result = returning_job.perform_now + expect(result).to eq("return value from job") + end +end diff --git a/sentry-rails/spec/active_job/shared_examples/scheduled_jobs.rb b/sentry-rails/spec/active_job/shared_examples/scheduled_jobs.rb new file mode 100644 index 000000000..64e19dda9 --- /dev/null +++ b/sentry-rails/spec/active_job/shared_examples/scheduled_jobs.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +RSpec.shared_examples "an ActiveJob backend that records scheduled_at on delayed jobs" do + let(:failing_job) do + job_fixture do + def perform + raise "boom from scheduled_jobs spec" + end + end + end + + it "records scheduled_at in the event extras", skip: RAILS_VERSION < 6.1 do + expect do + failing_job.set(wait: 5.seconds).perform_later + drain(at: 1.minute.from_now) + end.to raise_error(RuntimeError, /boom from scheduled_jobs spec/) + + expect(last_sentry_event.extra[:scheduled_at]).not_to be_nil + end +end diff --git a/sentry-rails/spec/active_job/shared_examples/scope_isolation.rb b/sentry-rails/spec/active_job/shared_examples/scope_isolation.rb new file mode 100644 index 000000000..7f38cd4ee --- /dev/null +++ b/sentry-rails/spec/active_job/shared_examples/scope_isolation.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +RSpec.shared_examples "an ActiveJob backend that isolates per-job scope" do + let(:scope_polluting_job) do + job_fixture do + def perform + Sentry.get_current_scope.set_extras(scope_marker: "from-job") + raise "boom from scope_polluting_job spec" + end + end + end + + it "applies in-job scope changes to the captured event but does not leak them" do + expect do + scope_polluting_job.perform_later + drain + end.to raise_error(RuntimeError, /boom from scope_polluting_job spec/) + + event = last_sentry_event + expect(event.extra).to include(scope_marker: "from-job") + + expect(Sentry.get_current_scope.extra).to eq({}) + end +end diff --git a/sentry-rails/spec/active_job/shared_examples/sentry_instrumented_backend.rb b/sentry-rails/spec/active_job/shared_examples/sentry_instrumented_backend.rb new file mode 100644 index 000000000..c11f17df3 --- /dev/null +++ b/sentry-rails/spec/active_job/shared_examples/sentry_instrumented_backend.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +RSpec.shared_examples "a Sentry-instrumented ActiveJob backend" do + it_behaves_like "an ActiveJob backend that captures errors" + it_behaves_like "an ActiveJob backend that attaches job context to error events" + it_behaves_like "an ActiveJob backend that isolates per-job scope" + it_behaves_like "an ActiveJob backend that respects rescue_from" + it_behaves_like "an ActiveJob backend that respects skippable_job_adapters" + it_behaves_like "an ActiveJob backend that serializes complex arguments" + it_behaves_like "an ActiveJob backend that unwraps DeserializationError" + it_behaves_like "an ActiveJob backend that emits a consumer transaction" + it_behaves_like "an ActiveJob backend that records scheduled_at on delayed jobs" + it_behaves_like "an ActiveJob backend that emits cron check-ins for monitor jobs" + it_behaves_like "an ActiveJob backend that produces structured logs" + it_behaves_like "an ActiveJob backend that respects retry semantics" + it_behaves_like "an ActiveJob backend that respects discard semantics" + it_behaves_like "an ActiveJob backend that preserves the job return value" +end diff --git a/sentry-rails/spec/active_job/shared_examples/structured_logging.rb b/sentry-rails/spec/active_job/shared_examples/structured_logging.rb new file mode 100644 index 000000000..7a6fbc5df --- /dev/null +++ b/sentry-rails/spec/active_job/shared_examples/structured_logging.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +RSpec.shared_examples "an ActiveJob backend that produces structured logs" do + let(:configure_sentry) do + proc do |config, _app| + config.enable_logs = true + config.rails.structured_logging.enabled = true + config.rails.structured_logging.subscribers = { + active_job: Sentry::Rails::LogSubscribers::ActiveJobSubscriber + } + end + end + + let(:simple_job) do + job_fixture do + def perform; end + end + end + + it "emits structured log entries for enqueue and perform events" do + simple_job.perform_later + drain + Sentry.get_current_client.flush + + enqueue_log = sentry_logs.find { |log| log[:body]&.include?("Job enqueued") } + perform_log = sentry_logs.find { |log| log[:body]&.include?("Job performed") } + + expect(enqueue_log).not_to be_nil + expect(enqueue_log[:level]).to eq("info") + expect(enqueue_log[:attributes][:job_class][:value]).to eq(simple_job.name) + + expect(perform_log).not_to be_nil + expect(perform_log[:level]).to eq("info") + expect(perform_log[:attributes][:job_class][:value]).to eq(simple_job.name) + expect(perform_log[:attributes][:duration_ms][:value]).to be >= 0 + end +end 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 new file mode 100644 index 000000000..5cc2d1fb3 --- /dev/null +++ b/sentry-rails/spec/active_job/shared_examples/tracing/consumer_transaction.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +RSpec.shared_examples "an ActiveJob backend that emits a consumer transaction" 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 tracing spec" + end + end + end + + context "with traces_sample_rate = 1.0" do + let(:configure_sentry) { proc { |config| config.traces_sample_rate = 1.0 } } + + it "captures a successful transaction with name, op, origin, source, and ok status" do + successful_job.perform_later + drain + + transaction = sentry_events.find { |e| e.is_a?(Sentry::TransactionEvent) } + expect(transaction).not_to be_nil + + expect(transaction.transaction).to eq(successful_job.name) + expect(transaction.transaction_info).to eq(source: :task) + expect(transaction.contexts.dig(:trace, :op)).to eq("queue.active_job") + expect(transaction.contexts.dig(:trace, :origin)).to eq("auto.queue.active_job") + expect(transaction.contexts.dig(:trace, :status)).to eq("ok") + end + + it "records a db.sql.active_record child span when the job performs a query" do + query_job = job_fixture do + def perform + Post.all.to_a + end + end + + query_job.perform_later + drain + + transaction = sentry_events.find { |e| e.is_a?(Sentry::TransactionEvent) } + expect(transaction).not_to be_nil + + db_span = transaction.spans.find { |s| s[:op] == "db.sql.active_record" } + expect(db_span).not_to be_nil + end + + it "marks the failing transaction internal_error and links the error event by trace_id" do + expect do + failing_job.perform_later + drain + end.to raise_error(RuntimeError, /boom from tracing spec/) + + transaction = sentry_events.find { |e| e.is_a?(Sentry::TransactionEvent) } + error_event = sentry_events.find { |e| e.is_a?(Sentry::ErrorEvent) } + + expect(transaction.contexts.dig(:trace, :status)).to eq("internal_error") + expect(error_event.contexts.dig(:trace, :trace_id)).to eq(transaction.contexts.dig(:trace, :trace_id)) + end + end + + context "with traces_sample_rate = 0" do + before { Sentry.configuration.traces_sample_rate = 0 } + + it "does not capture a transaction" do + expect do + failing_job.perform_later + drain + end.to raise_error(RuntimeError, /boom from tracing spec/) + + transactions = sentry_events.select { |e| e.is_a?(Sentry::TransactionEvent) } + expect(transactions).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 new file mode 100644 index 000000000..4e489fb20 --- /dev/null +++ b/sentry-rails/spec/active_job/support/harness.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +RSpec.shared_context "active_job backend harness" do |adapter:| + let(:adapter) { adapter } + let(:configure_sentry) { proc { } } + + around do |example| + make_basic_app(&configure_sentry) + setup_sentry_test + + ::ActiveJob::Base.queue_adapter = adapter + + boot_adapter(adapter) + + example.run + ensure + reset_adapter(adapter) + + 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. + end + + def reset_adapter(_adapter) + # Per-adapter teardown hook. Backends extend this to truncate tables + # or otherwise clean up state between examples. + end + + 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. + jobs = queue_adapter.enqueued_jobs.dup + queue_adapter.enqueued_jobs.clear + jobs.each { |payload| send(:instantiate_job, payload).perform_now } + 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}" + end + end + + def job_fixture(name = nil, &block) + name ||= "JobFixture_#{SecureRandom.hex(4)}" + klass = Class.new(::ActiveJob::Base, &block) + stub_const(name, klass) + klass + end +end diff --git a/sentry-rails/spec/active_job/test_adapter_spec.rb b/sentry-rails/spec/active_job/test_adapter_spec.rb new file mode 100644 index 000000000..4d5e704de --- /dev/null +++ b/sentry-rails/spec/active_job/test_adapter_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "Sentry + ActiveJob on the test adapter", type: :job do + include_context "active_job backend harness", adapter: :test + + it_behaves_like "a Sentry-instrumented ActiveJob backend" +end diff --git a/sentry-rails/spec/active_job/without_sentry_spec.rb b/sentry-rails/spec/active_job/without_sentry_spec.rb new file mode 100644 index 000000000..d3622134d --- /dev/null +++ b/sentry-rails/spec/active_job/without_sentry_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "spec_helper" + +# These examples exercise the `if !Sentry.initialized?` short-circuit in +# ActiveJobExtensions#perform_now. They MUST run with Sentry not initialized, +# so each example resets all SDK globals before running. +RSpec.describe "ActiveJob without Sentry initialized", type: :job do + around do |example| + reset_sentry_globals! + example.run + end + + it "runs the job normally (raises the original error)" do + expect { FailedJob.perform_now }.to raise_error(FailedJob::TestError) + end + + it "returns the #perform method's return value" do + expect(NormalJob.perform_now).to eq("foo") + end +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 3d06d8e9a..6275220de 100644 --- a/sentry-rails/spec/dummy/test_rails_app/config/application.rb +++ b/sentry-rails/spec/dummy/test_rails_app/config/application.rb @@ -143,6 +143,20 @@ def before_initialize! end def after_initialize! + # The active_job.custom_serializers railtie initializer calls + # add_serializers(app.config.active_job.custom_serializers). Under some + # Rails/Ruby combinations custom_serializers resolves to nil instead of the + # railtie default of [], inserting nil into the global serializers Set. + # Remove it right after initialization so it cannot affect any test. + # Rails < 8 uses mattr_accessor _additional_serializers; Rails 8+ uses @serializers. + if defined?(::ActiveJob::Serializers) + if ::ActiveJob::Serializers.respond_to?(:_additional_serializers) + ::ActiveJob::Serializers._additional_serializers.delete(nil) + elsif ::ActiveJob::Serializers.instance_variable_defined?(:@serializers) + ::ActiveJob::Serializers.instance_variable_get(:@serializers).delete(nil) + end + end + if Sentry.initialized? # Run a query to make sure the schema metadata gets loaded and cached Post.all.to_a.inspect diff --git a/sentry-rails/spec/sentry/rails/activejob_spec.rb b/sentry-rails/spec/sentry/rails/activejob_spec.rb deleted file mode 100644 index b16b272a8..000000000 --- a/sentry-rails/spec/sentry/rails/activejob_spec.rb +++ /dev/null @@ -1,400 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" -require_relative "../../support/test_jobs" - -RSpec.describe "without Sentry initialized", type: :job do - it "runs job" do - expect { FailedJob.perform_now }.to raise_error(FailedJob::TestError) - end - - it "returns #perform method's return value" do - expect(NormalJob.perform_now).to eq("foo") - end -end - -RSpec.describe "ActiveJob integration", type: :job do - let(:event) do - transport.events.last.to_json_compatible - end - - let(:transport) do - Sentry.get_current_client.transport - end - - it "returns #perform method's return value" do - expect(NormalJob.perform_now).to eq("foo") - end - - describe "ActiveJob arguments serialization" do - before do - make_basic_app - end - - it "serializes ActiveRecord arguments in globalid form" do - post = Post.create! - post2 = Post.create! - - expect do - JobWithArgument.perform_now("foo", { bar: Sentry }, integer: 1, post: post, nested: { another_level: { post: post2 } }) - end.to raise_error(RuntimeError) - - expect(transport.events.size).to be(1) - - event = transport.events.last.to_json_compatible - - expect(event.dig("extra", "arguments")).to eq( - [ - "foo", - { "bar" => "Sentry" }, - { - "integer" => 1, - "post" => post.to_global_id.to_s, - "nested" => { "another_level" => { "post" => post2.to_global_id.to_s } } - } - ] - ) - end - - it "handles problematic globalid conversion gracefully" do - post = Post.create! - - def post.to_global_id - raise - end - - expect do - JobWithArgument.perform_now(integer: 1, post: post) - end.to raise_error(RuntimeError) - - expect(transport.events.size).to be(1) - - event = transport.events.last.to_json_compatible - - expect(event.dig("extra", "arguments")).to eq( - [ - { - "integer" => 1, - "post" => post.to_s - } - ] - ) - end - - it "serializes range arguments gracefully when Range#map is implemented" do - post = Post.create! - - expect do - JobWithArgument.perform_now("foo", { bar: Sentry }, integer: 1, post: post, range: 1..3) - end.to raise_error(RuntimeError) - - expect(transport.events.size).to be(1) - - event = transport.events.last.to_json_compatible - - expect(event.dig("extra", "arguments")).to eq( - [ - "foo", - { "bar" => "Sentry" }, - { - "integer" => 1, - "post" => post.to_global_id.to_s, - "range" => [1, 2, 3] - } - ] - ) - end - - it "serializes range arguments gracefully when Range consists of ActiveSupport::TimeWithZone" do - post = Post.create! - range = 5.days.ago...1.day.ago - - expect do - JobWithArgument.perform_now("foo", { bar: Sentry }, integer: 1, post: post, range: range) - end.to raise_error(RuntimeError) - - expect(transport.events.size).to be(1) - - event = transport.events.last.to_json_compatible - - expect(event.dig("extra", "arguments")).to eq( - [ - "foo", - { "bar" => "Sentry" }, - { - "integer" => 1, - "post" => post.to_global_id.to_s, - "range" => "#{range.first}...#{range.last}" - } - ] - ) - end - end - - describe "handling context" do - before do - make_basic_app - end - - it "adds useful context to extra" do - expect { FailedJob.perform_now }.to raise_error(FailedJob::TestError) - - expect(transport.events.size).to be(1) - - event = transport.events.last.to_json_compatible - - expect(event.dig("extra", "active_job")).to eq("FailedJob") - expect(event.dig("extra", "job_id")).to be_a(String) - expect(event.dig("extra", "provider_job_id")).to be_nil - expect(event.dig("extra", "arguments")).to eq([]) - - expect(event.dig("tags", "job_id")).to eq(event.dig("extra", "job_id")) - expect(event.dig("tags", "provider_job_id")).to eq(event.dig("extra", "provider_job_id")) - last_frame = event.dig("exception", "values", 0, "stacktrace", "frames").last - expect(last_frame["vars"]).to include({ "a" => "1", "b" => "0" }) - end - - it "clears context" do - expect { FailedWithExtraJob.perform_now }.to raise_error(FailedWithExtraJob::TestError) - - expect(transport.events.size).to be(1) - - event = transport.events.last.to_json_compatible - - expect(event["extra"]["foo"]).to eq("bar") - - expect(Sentry.get_current_scope.extra).to eq({}) - end - end - - context "with tracing enabled" do - before do - make_basic_app do |config| - config.traces_sample_rate = 1.0 - end - end - - it "sends transaction" do - QueryPostJob.perform_now - - expect(transport.events.size).to be(1) - - transaction = transport.events.last - expect(transaction.transaction).to eq("QueryPostJob") - expect(transaction.transaction_info).to eq({ source: :task }) - expect(transaction.contexts.dig(:trace, :trace_id)).to be_present - expect(transaction.contexts.dig(:trace, :span_id)).to be_present - expect(transaction.contexts.dig(:trace, :status)).to eq("ok") - expect(transaction.contexts.dig(:trace, :op)).to eq("queue.active_job") - expect(transaction.contexts.dig(:trace, :origin)).to eq("auto.queue.active_job") - - expect(transaction.spans.count).to eq(1) - expect(transaction.spans.first[:op]).to eq("db.sql.active_record") - end - - context "with error" do - it "sends transaction and associates it with the event" do - expect { FailedWithExtraJob.perform_now }.to raise_error(FailedWithExtraJob::TestError) - - expect(transport.events.size).to be(2) - - transaction = transport.events.first - expect(transaction.transaction).to eq("FailedWithExtraJob") - expect(transaction.transaction_info).to eq({ source: :task }) - expect(transaction.contexts.dig(:trace, :trace_id)).to be_present - expect(transaction.contexts.dig(:trace, :span_id)).to be_present - expect(transaction.contexts.dig(:trace, :status)).to eq("internal_error") - expect(transaction.contexts.dig(:trace, :origin)).to eq("auto.queue.active_job") - - event = transport.events.last - expect(event.transaction).to eq("FailedWithExtraJob") - expect(event.contexts.dig(:trace, :trace_id)).to eq(transaction.contexts.dig(:trace, :trace_id)) - end - end - end - - context "when DeserializationError happens in user's jobs" do - before do - make_basic_app - end - - class DeserializationErrorJob < ActiveJob::Base - def perform - 1/0 - rescue - raise ActiveJob::DeserializationError - end - end - - it "reports the root cause to Sentry" do - expect do - DeserializationErrorJob.perform_now - end.to raise_error(ActiveJob::DeserializationError, /divided by 0/) - - expect(transport.events.size).to be(1) - - event = transport.events.last.to_json_compatible - expect(event.dig("exception", "values", 0, "type")).to eq("ZeroDivisionError") - end - end - - context "using rescue_from" do - before do - make_basic_app - end - - it 'does not trigger Sentry' do - expect_any_instance_of(RescuedActiveJob).to receive(:rescue_callback).once.and_call_original - - expect { RescuedActiveJob.perform_now }.not_to raise_error - - expect(transport.events.size).to eq(0) - end - - context "with exception in rescue_from" do - it "reports both the original error and callback error" do - expect_any_instance_of(ProblematicRescuedActiveJob).to receive(:rescue_callback).once.and_call_original - - expect { ProblematicRescuedActiveJob.perform_now }.to raise_error(RuntimeError) - - expect(transport.events.size).to eq(1) - - event = transport.events.first - exceptions_data = event.exception.to_h[:values] - - expect(exceptions_data.count).to eq(2) - expect(exceptions_data[0][:type]).to eq("FailedJob::TestError") - expect(exceptions_data[1][:type]).to eq("RuntimeError") - end - end - end - - context "when we are using an adapter which has a specific integration" do - before do - make_basic_app do |config| - config.rails.skippable_job_adapters = ["ActiveJob::QueueAdapters::TestAdapter"] - end - end - - it "does not trigger sentry and re-raises" do - expect { FailedJob.perform_now }.to raise_error(FailedJob::TestError) - expect(transport.events.size).to eq(0) - end - end - - context "with cron monitoring mixin" do - before do - make_basic_app - end - - context "normal job" do - it "returns #perform method's return value" do - expect(NormalJobWithCron.perform_now).to eq("foo") - end - - it "captures two check ins" do - NormalJobWithCron.perform_now - - expect(transport.events.size).to eq(2) - - first = transport.events[0] - check_in_id = first.check_in_id - expect(first).to be_a(Sentry::CheckInEvent) - expect(first.to_h).to include( - type: 'check_in', - check_in_id: check_in_id, - monitor_slug: "normaljobwithcron", - status: :in_progress - ) - - second = transport.events[1] - expect(second).to be_a(Sentry::CheckInEvent) - expect(second.to_h).to include( - :duration, - type: 'check_in', - check_in_id: check_in_id, - monitor_slug: "normaljobwithcron", - status: :ok - ) - end - end - - context "failed job" do - it "captures two check ins" do - expect { FailedJobWithCron.perform_now }.to raise_error(FailedJob::TestError) - - expect(transport.events.size).to eq(3) - - first = transport.events[0] - check_in_id = first.check_in_id - expect(first).to be_a(Sentry::CheckInEvent) - expect(first.to_h).to include( - type: 'check_in', - check_in_id: check_in_id, - monitor_slug: "failed_job", - status: :in_progress, - monitor_config: { schedule: { type: :crontab, value: "5 * * * *" } } - ) - - second = transport.events[1] - expect(second).to be_a(Sentry::CheckInEvent) - expect(second.to_h).to include( - :duration, - type: 'check_in', - check_in_id: check_in_id, - monitor_slug: "failed_job", - status: :error, - monitor_config: { schedule: { type: :crontab, value: "5 * * * *" } } - ) - end - end - end - - describe "Reporting on retry errors", skip: RAILS_VERSION < 7.0 do - before do - make_basic_app - end - - context "when active_job_report_on_retry_error is true" do - before do - Sentry.configuration.rails.active_job_report_on_retry_error = true - end - - after do - Sentry.configuration.rails.active_job_report_on_retry_error = false - end - - it "reports 3 exceptions" do - allow(Sentry::Rails::ActiveJobExtensions::SentryReporter) - .to receive(:capture_exception).and_call_original - - FailedJobWithRetryOn.perform_later rescue nil - - perform_enqueued_jobs - perform_enqueued_jobs - perform_enqueued_jobs rescue nil - - expect(Sentry::Rails::ActiveJobExtensions::SentryReporter) - .to have_received(:capture_exception) - .exactly(3).times - end - end - - context "when active_job_report_on_retry_error is false" do - it "reports 1 exception on final attempt failure" do - allow(Sentry::Rails::ActiveJobExtensions::SentryReporter) - .to receive(:capture_exception).and_call_original - - FailedJobWithRetryOn.perform_later rescue nil - - perform_enqueued_jobs - perform_enqueued_jobs - perform_enqueued_jobs rescue nil - - expect(Sentry::Rails::ActiveJobExtensions::SentryReporter) - .to have_received(:capture_exception) - .exactly(1).times - end - end - end -end diff --git a/sentry-rails/spec/spec_helper.rb b/sentry-rails/spec/spec_helper.rb index 420f47dfa..cd5d5ca80 100644 --- a/sentry-rails/spec/spec_helper.rb +++ b/sentry-rails/spec/spec_helper.rb @@ -27,6 +27,8 @@ end Dir["#{__dir__}/support/**/*.rb"].each { |file| require file } +Dir["#{__dir__}/active_job/support/**/*.rb"].each { |file| require file } +Dir["#{__dir__}/active_job/shared_examples/**/*.rb"].each { |file| require file } RAILS_VERSION = Rails.version.to_f