diff --git a/sentry-rails/lib/sentry/rails/active_job.rb b/sentry-rails/lib/sentry/rails/active_job.rb index c8108e7e9..e1d49af49 100644 --- a/sentry-rails/lib/sentry/rails/active_job.rb +++ b/sentry-rails/lib/sentry/rails/active_job.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true require "set" +require "sentry/rails/serializer" +require "sentry/rails/error_reporter_context" module Sentry module Rails @@ -193,6 +195,7 @@ def capture_exception(job, e) job_id: job.job_id, provider_job_id: job.provider_job_id }, + contexts: ErrorReporterContext.contexts, # Send synchronously: a worker process may exit before the async # background worker flushes its queue, which would drop the event. hint: { background: false } @@ -248,22 +251,7 @@ def sentry_context(job) end def sentry_serialize_arguments(argument) - case argument - when Range - if (argument.begin || argument.end).is_a?(ActiveSupport::TimeWithZone) - argument.to_s - else - argument.map { |v| sentry_serialize_arguments(v) } - end - when Hash - argument.transform_values { |v| sentry_serialize_arguments(v) } - when Array, Enumerable - argument.map { |v| sentry_serialize_arguments(v) } - when ->(v) { v.respond_to?(:to_global_id) } - argument.to_global_id.to_s rescue argument - else - argument - end + Sentry::Rails::Serializer.serialize(argument) end private diff --git a/sentry-rails/lib/sentry/rails/capture_exceptions.rb b/sentry-rails/lib/sentry/rails/capture_exceptions.rb index 703ac97a9..fe49c9275 100644 --- a/sentry-rails/lib/sentry/rails/capture_exceptions.rb +++ b/sentry-rails/lib/sentry/rails/capture_exceptions.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "sentry/rails/error_reporter_context" + module Sentry module Rails class CaptureExceptions < Sentry::Rack::CaptureExceptions @@ -30,7 +32,7 @@ def capture_exception(exception, env) return unless Sentry.initialized? return if show_exceptions?(exception, env) && !Sentry.configuration.rails.report_rescued_exceptions - Sentry::Rails.capture_exception(exception).tap do |event| + Sentry::Rails.capture_exception(exception, contexts: ErrorReporterContext.contexts).tap do |event| env[ERROR_EVENT_ID_KEY] = event.event_id if event end end diff --git a/sentry-rails/lib/sentry/rails/error_reporter_context.rb b/sentry-rails/lib/sentry/rails/error_reporter_context.rb new file mode 100644 index 000000000..630116936 --- /dev/null +++ b/sentry-rails/lib/sentry/rails/error_reporter_context.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "sentry/rails/serializer" + +module Sentry + module Rails + module ErrorReporterContext + SUPPORTS_EXECUTION_CONTEXT = Gem::Version.new(::Rails.version) >= Gem::Version.new("7.0.0") + + class << self + if SUPPORTS_EXECUTION_CONTEXT + def contexts + execution_context = ::ActiveSupport::ExecutionContext.to_h + return {} if execution_context.empty? + + { "rails.error" => Sentry::Rails::Serializer.serialize(execution_context) } + end + else + def contexts + {} + end + end + end + end + end +end diff --git a/sentry-rails/lib/sentry/rails/serializer.rb b/sentry-rails/lib/sentry/rails/serializer.rb new file mode 100644 index 000000000..75a1c6b57 --- /dev/null +++ b/sentry-rails/lib/sentry/rails/serializer.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Sentry + module Rails + module Serializer + def self.serialize(value) + case value + when Range + serialize_range(value) + when Hash + value.transform_values { |item| serialize(item) } + when Array, Enumerable + value.map { |item| serialize(item) } + when ->(item) { item.respond_to?(:to_global_id) } + serialize_global_id(value) + else + value + end + end + + def self.serialize_global_id(value) + value.to_global_id.to_s + rescue StandardError + value + end + + def self.serialize_range(range) + return range.to_s if range.begin.nil? || range.end.nil? + return range.to_s if range.begin.is_a?(::ActiveSupport::TimeWithZone) + + range.map { |item| serialize(item) } + end + end + 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 index 897d8dede..2632858e5 100644 --- a/sentry-rails/spec/active_job/shared_examples/error_context.rb +++ b/sentry-rails/spec/active_job/shared_examples/error_context.rb @@ -36,4 +36,34 @@ def perform last_frame = event.exception.values.first.stacktrace.frames.last expect(last_frame.vars).to include(a: "1", b: "0") end + + it "includes Rails.error.set_context data attached before the job raises", skip: RAILS_VERSION < 7.0 do + job_with_context = job_fixture do + def perform + Rails.error.set_context( + debug_key: "important_value", + timestamp: Time.utc(2026, 7, 21, 12, 34, 56), + zoned_timestamp: ActiveSupport::TimeZone["Eastern Time (US & Canada)"].parse("2026-07-21 12:34:56"), + date: Date.new(2026, 7, 21) + ) + raise "boom with rails error context" + end + end + + expect do + job_with_context.perform_later + drain + end.to raise_error(RuntimeError, /boom with rails error context/) + + event = last_sentry_event + + expect(event.contexts).to include( + "rails.error" => hash_including( + debug_key: "important_value", + timestamp: Time.utc(2026, 7, 21, 12, 34, 56), + zoned_timestamp: ActiveSupport::TimeZone["Eastern Time (US & Canada)"].parse("2026-07-21 12:34:56"), + date: Date.new(2026, 7, 21) + ) + ) + end end diff --git a/sentry-rails/spec/dummy/test_rails_app/app/controllers/hello_controller.rb b/sentry-rails/spec/dummy/test_rails_app/app/controllers/hello_controller.rb index 68d996f6a..43e8b1838 100644 --- a/sentry-rails/spec/dummy/test_rails_app/app/controllers/hello_controller.rb +++ b/sentry-rails/spec/dummy/test_rails_app/app/controllers/hello_controller.rb @@ -7,6 +7,16 @@ def exception raise "An unhandled exception!" end + def exception_with_error_context + Rails.error.set_context( + debug_key: "important_value", + timestamp: Time.utc(2026, 7, 21, 12, 34, 56), + zoned_timestamp: ActiveSupport::TimeZone["Eastern Time (US & Canada)"].parse("2026-07-21 12:34:56"), + date: Date.new(2026, 7, 21) + ) + raise "An unhandled exception with Rails.error context!" + end + def reporting render plain: Sentry.last_event_id 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 a1b68eeda..e952362d0 100644 --- a/sentry-rails/spec/dummy/test_rails_app/config/application.rb +++ b/sentry-rails/spec/dummy/test_rails_app/config/application.rb @@ -122,6 +122,7 @@ def configure routes.append do get "/exception", to: "hello#exception" + get "/exception_with_error_context", to: "hello#exception_with_error_context" get "/view_exception", to: "hello#view_exception" get "/view", to: "hello#view" get "/not_found", to: "hello#not_found" diff --git a/sentry-rails/spec/sentry/rails/serializer_spec.rb b/sentry-rails/spec/sentry/rails/serializer_spec.rb new file mode 100644 index 000000000..f6664e98c --- /dev/null +++ b/sentry-rails/spec/sentry/rails/serializer_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Sentry::Rails::Serializer do + describe ".serialize" do + it "recursively serializes hash values" do + result = described_class.serialize(a: 1, b: { c: 2..3 }) + + expect(result).to eq(a: 1, b: { c: [2, 3] }) + end + + it "recursively serializes array elements" do + result = described_class.serialize([1, [2..3]]) + + expect(result).to eq([1, [[2, 3]]]) + end + + it "expands ranges into arrays" do + expect(described_class.serialize(1..3)).to eq([1, 2, 3]) + end + + context "when the range is beginless" do + it "stringifies the range instead of expanding it" do + range = (..10) + + expect(described_class.serialize(range)).to eq(range.to_s) + end + end + + context "when the range is endless" do + it "stringifies the range instead of expanding it" do + range = (1..) + + expect(described_class.serialize(range)).to eq(range.to_s) + end + end + + context "when the range boundary is an ActiveSupport::TimeWithZone" do + it "stringifies the range instead of expanding it" do + zone = ActiveSupport::TimeZone["UTC"] + range = (zone.now - 1.day)...zone.now + + expect(described_class.serialize(range)).to eq(range.to_s) + end + end + + it "returns values without a serialization rule unchanged" do + object = Object.new + + expect(described_class.serialize(object)).to equal(object) + end + end +end diff --git a/sentry-rails/spec/sentry/rails_spec.rb b/sentry-rails/spec/sentry/rails_spec.rb index 1678a044f..8a002ca8a 100644 --- a/sentry-rails/spec/sentry/rails_spec.rb +++ b/sentry-rails/spec/sentry/rails_spec.rb @@ -373,6 +373,22 @@ def capture_in_separate_process(exit_code:) expect(transport.events.count).to eq(0) end + + it "includes Rails.error.set_context data attached before an unhandled request exception" do + get "/exception_with_error_context" + + expect(transport.events.count).to eq(1) + + event = transport.events.first + expect(event.contexts).to include( + "rails.error" => hash_including( + debug_key: "important_value", + timestamp: Time.utc(2026, 7, 21, 12, 34, 56), + zoned_timestamp: ActiveSupport::TimeZone["Eastern Time (US & Canada)"].parse("2026-07-21 12:34:56"), + date: Date.new(2026, 7, 21) + ) + ) + end end end end