Skip to content

Commit 2ee2e77

Browse files
solnicCodex
andcommitted
feat(rails): delegate ActiveJob to backend extensions
Co-Authored-By: Codex <noreply@example.com>
1 parent 48aea69 commit 2ee2e77

13 files changed

Lines changed: 975 additions & 190 deletions

File tree

‎sentry-rails/lib/sentry/rails.rb‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,28 @@ module Sentry
1313
module Rails
1414
extend Integrable
1515
register_integration name: "rails", version: Sentry::Rails::VERSION
16+
17+
# Returns whether sentry-rails' ActiveJob integration reports jobs run
18+
# through the given queue adapter.
19+
#
20+
# When it returns true, the integration captures errors from those jobs,
21+
# records their queue.process transactions and queue.publish spans,
22+
# propagates traces, and consults extensions registered with
23+
# {Sentry.register_job_extension}. A backend gem should then not report the
24+
# same ActiveJob jobs itself.
25+
#
26+
# The result depends on {Sentry.initialized?} and the current configuration,
27+
# so it can change after {Sentry.init}. Call it when a job is handled rather
28+
# than caching the result at load time.
29+
#
30+
# @param adapter_class_name [String, Class] the ActiveJob queue adapter's
31+
# class name, such as "ActiveJob::QueueAdapters::SidekiqAdapter", or the
32+
# adapter class itself. It is compared against
33+
# +config.rails.skippable_job_adapters+.
34+
# @return [Boolean] true if the SDK is initialized and the adapter is not
35+
# listed in +config.rails.skippable_job_adapters+, false otherwise
36+
def self.active_job_owns_adapter?(adapter_class_name)
37+
Sentry.initialized? && !Sentry.configuration.rails.skippable_job_adapters.include?(adapter_class_name.to_s)
38+
end
1639
end
1740
end

‎sentry-rails/lib/sentry/rails/active_job.rb‎

Lines changed: 65 additions & 181 deletions
Original file line numberDiff line numberDiff line change
@@ -3,87 +3,81 @@
33
require "set"
44
require "sentry/rails/serializer"
55
require "sentry/rails/error_reporter_context"
6+
require "sentry/rails/active_job/job_extension"
7+
require "sentry/rails/active_job/producer"
68

79
module Sentry
810
module Rails
911
module ActiveJobExtensions
1012
SENTRY_PAYLOAD_KEY = "_sentry"
1113

12-
USER_FIELDS_ALLOWLIST = %w[id email username].freeze
13-
1414
def self.prepended(base)
1515
base.attr_accessor :_sentry
16+
base.attr_reader :_sentry_job_data
1617
end
1718

1819
def perform_now
1920
if !Sentry.initialized? || already_supported_by_sentry_integration?
2021
super
2122
else
2223
data = _sentry || {}
24+
current_scope = Sentry.get_current_scope
25+
scope = current_scope if data["execution_scope_id"] == current_scope.object_id
26+
transaction = scope&.get_span
2327
SentryReporter.record(
2428
self,
2529
trace_headers: data["trace_propagation_headers"],
26-
user: data["user"]
30+
user: data["user"],
31+
payload: _sentry_job_data,
32+
transaction: transaction,
33+
scope: scope
2734
) { super }
2835
end
2936
end
3037

3138
def serialize
3239
payload = super
3340
return payload if !Sentry.initialized? || already_supported_by_sentry_integration?
34-
35-
begin
36-
sentry_data = {}
37-
if Sentry.configuration.rails.active_job_propagate_traces
38-
headers = Sentry.get_trace_propagation_headers
39-
sentry_data["trace_propagation_headers"] = headers if headers && !headers.empty?
40-
end
41-
42-
if Sentry.configuration.data_collection.user_info
43-
user = Sentry.get_current_scope.user
44-
allowed = user.transform_keys(&:to_s).slice(*USER_FIELDS_ALLOWLIST)
45-
sentry_data["user"] = allowed unless allowed.empty?
46-
end
47-
48-
payload[SENTRY_PAYLOAD_KEY] = sentry_data unless sentry_data.empty?
49-
rescue StandardError => e
50-
Sentry.sdk_logger&.error("sentry-rails: failed to inject _sentry payload: #{e.class}: #{e.message}\n #{Array(e.backtrace).first(5).join("\n ")}")
51-
end
52-
53-
payload
41+
SentryReporter.producer(self).prepare_payload(self, payload)
5442
end
5543

5644
def deserialize(job_data)
5745
super
5846
return if !Sentry.initialized? || already_supported_by_sentry_integration?
5947

6048
begin
49+
@_sentry_job_data = job_data
6150
self._sentry = job_data[SENTRY_PAYLOAD_KEY]
6251
rescue StandardError => e
6352
Sentry.sdk_logger&.error("sentry-rails: failed to extract _sentry payload: #{e.class}: #{e.message}\n #{Array(e.backtrace).first(5).join("\n ")}")
6453
end
6554
end
6655

6756
def already_supported_by_sentry_integration?
68-
Sentry.configuration.rails.skippable_job_adapters.include?(self.class.queue_adapter.class.to_s)
57+
!Sentry::Rails.active_job_owns_adapter?(self.class.queue_adapter.class)
6958
end
7059

7160
class SentryReporter
72-
OP_NAME = "queue.process"
73-
SPAN_ORIGIN = "auto.queue.active_job"
74-
75-
# Emitted as messaging.system when the configured queue adapter's
76-
# identity cannot be resolved from the job. ActiveJob is adapter-agnostic
77-
# and supports arbitrary third-party backends, so the concrete value is
78-
# normally derived from the adapter itself; this is only the last resort.
79-
MESSAGING_SYSTEM_FALLBACK = "activejob"
80-
8161
EVENT_HANDLERS = {
8262
"enqueue_retry.active_job" => :retry_handler
8363
}
8464

8565
class << self
86-
include ErrorReporterContext
66+
def default_extension
67+
@default_extension ||= JobExtension.new
68+
end
69+
70+
def consumer
71+
@consumer ||= Sentry::JobExtensions::Consumer.new(integration: Sentry::Rails, origin: JobExtension::SPAN_ORIGIN, registry: job_extensions)
72+
end
73+
74+
def producer(job)
75+
job_extensions.call(job_extensions.for(job), :producer, job: job)
76+
end
77+
78+
def job_extensions
79+
default_extension.registry
80+
end
8781

8882
def producer_callback_registered?
8983
@producer_callback_registered ||= false
@@ -95,144 +89,51 @@ def producer_callback_registered!
9589

9690
def record_producer_span(job, &enqueue)
9791
return yield if !Sentry.initialized? || job.already_supported_by_sentry_integration?
92+
producer(job).record_span(job, &enqueue)
93+
end
9894

99-
enqueued = false
95+
def record(job, trace_headers: nil, user: nil, payload: nil, transaction: nil, scope: nil, &block)
96+
options = { trace_headers: trace_headers, user: user, payload: payload, transaction: transaction }
97+
return record_in_scope(scope, job, **options, backend_scope: true, &block) if scope
10098

101-
run_enqueue = lambda do
102-
enqueued = true
103-
enqueue.call
99+
with_isolated_scope do |isolated_scope|
100+
record_in_scope(isolated_scope, job, **options, &block)
104101
end
102+
end
105103

104+
def record_in_scope(scope, job, trace_headers: nil, user: nil, payload: nil, transaction: nil, backend_scope: false)
106105
begin
107-
Sentry.with_child_span(op: "queue.publish", description: job.class.name) do |span|
108-
if span
109-
span.set_origin(SPAN_ORIGIN)
110-
span.set_data(Sentry::Span::DataConventions::MESSAGING_SYSTEM, messaging_system(job))
111-
span.set_data(Sentry::Span::DataConventions::MESSAGING_MESSAGE_ID, job.job_id)
112-
span.set_data(Sentry::Span::DataConventions::MESSAGING_DESTINATION_NAME, job.queue_name)
113-
114-
if (count = retry_count(job))
115-
span.set_data(Sentry::Span::DataConventions::MESSAGING_MESSAGE_RETRY_COUNT, count)
116-
end
117-
end
118-
119-
run_enqueue.call
120-
end
121-
rescue StandardError => e
122-
raise if enqueued
106+
scope.set_user(user.transform_keys(&:to_sym)) if user && !user.empty?
123107

124-
log_producer_span_error(e)
108+
consumer.apply_scope_data(scope, job)
125109

126-
run_enqueue.call
127-
end
128-
end
110+
scope.set_transaction_name(consumer.transaction_name(job), source: :task)
129111

130-
def log_producer_span_error(e)
131-
Sentry.sdk_logger&.error("sentry-rails: failed to record producer span: #{e.class}: #{e.message}\n #{Array(e.backtrace).first(5).join("\n ")}")
132-
end
133-
134-
def record(job, trace_headers: nil, user: nil, &block)
135-
original_hub = Sentry.get_current_hub_internal
136-
Sentry.clone_hub_to_current_thread
112+
transaction = consumer.start_transaction(
113+
scope, job,
114+
transaction: transaction.is_a?(Sentry::Transaction) ? transaction : nil,
115+
trace_headers: trace_headers,
116+
payload: payload
117+
)
137118

138-
Sentry.with_scope do |scope|
139-
begin
140-
scope.set_user(user.transform_keys(&:to_sym)) if user && !user.empty?
141-
142-
scope.set_transaction_name(job.class.name, source: :task)
143-
scope.set_tags(queue: job.queue_name)
144-
145-
scope.set_contexts(active_job: {
146-
job_class: job.class.name,
147-
job_id: job.job_id,
148-
queue: job.queue_name,
149-
provider_job_id: job.provider_job_id
150-
})
151-
152-
transaction_options = {
153-
name: scope.transaction_name,
154-
source: scope.transaction_source,
155-
op: OP_NAME,
156-
origin: SPAN_ORIGIN
157-
}
158-
159-
transaction = if trace_headers && !trace_headers.empty?
160-
continued = Sentry.continue_trace(trace_headers, **transaction_options)
161-
Sentry.start_transaction(transaction: continued, **transaction_options)
162-
else
163-
Sentry.start_transaction(**transaction_options)
164-
end
165-
166-
if transaction
167-
set_messaging_data(transaction, job)
168-
scope.set_span(transaction)
169-
end
170-
171-
yield.tap do
172-
finish_sentry_transaction(transaction, 200)
173-
end
174-
rescue Exception => e # rubocop:disable Lint/RescueException
175-
finish_sentry_transaction(transaction, 500)
119+
yield.tap do
120+
consumer.finish_transaction(transaction, 200)
121+
end
122+
rescue Exception => e # rubocop:disable Lint/RescueException
123+
consumer.finish_transaction(transaction, 500)
176124

125+
if backend_scope
126+
scope.update_from_options(**default_extension.error_data(job: job, exception: e))
127+
elsif consumer.report_exception?(job, e)
177128
capture_exception(job, e)
178-
179-
raise
180129
end
181-
end
182-
ensure
183-
Sentry.set_current_hub_internal(original_hub)
184-
end
185-
186-
def set_messaging_data(transaction, job)
187-
transaction.set_data(Sentry::Span::DataConventions::MESSAGING_SYSTEM, messaging_system(job))
188-
transaction.set_data(Sentry::Span::DataConventions::MESSAGING_MESSAGE_ID, job.job_id)
189-
transaction.set_data(Sentry::Span::DataConventions::MESSAGING_DESTINATION_NAME, job.queue_name)
190-
if (count = retry_count(job))
191-
transaction.set_data(Sentry::Span::DataConventions::MESSAGING_MESSAGE_RETRY_COUNT, count)
192-
end
193130

194-
if (latency = compute_latency(job))
195-
transaction.set_data(Sentry::Span::DataConventions::MESSAGING_MESSAGE_RECEIVE_LATENCY, latency)
131+
raise
196132
end
197133
end
198134

199-
def messaging_system(job)
200-
name = job.class.queue_adapter_name if job.class.respond_to?(:queue_adapter_name)
201-
name = name.to_s
202-
name.empty? ? MESSAGING_SYSTEM_FALLBACK : name
203-
end
204-
205-
# Number of retries the job has already gone through, as observed at
206-
# the moment the span is opened. On the consumer the span opens before
207-
# ActiveJob increments +executions+, so a job's first attempt reads 0,
208-
# its first retry reads 1, and so on. On the producer the retry enqueue
209-
# runs after the failed attempt bumped +executions+, so it observes the
210-
# same progression.
211-
def retry_count(job)
212-
job.executions.to_i if job.respond_to?(:executions)
213-
end
214-
215-
def compute_latency(job)
216-
return unless job.respond_to?(:enqueued_at) && job.enqueued_at
217-
218-
enqueued_time = job.enqueued_at.is_a?(String) ? Time.parse(job.enqueued_at) : job.enqueued_at
219-
(Time.now.to_f - enqueued_time.to_f) * 1000.0
220-
end
221-
222135
def capture_exception(job, e)
223-
options = {
224-
extra: sentry_context(job),
225-
tags: {
226-
job_id: job.job_id,
227-
provider_job_id: job.provider_job_id
228-
},
229-
# Send synchronously: a worker process may exit before the async
230-
# background worker flushes its queue, which would drop the event.
231-
hint: { background: false }
232-
}
233-
options[:contexts] = execution_context if Sentry.configuration.data_collection.queues
234-
235-
Sentry::Rails.capture_exception(e, **options)
136+
consumer.capture_exception(job, e, **default_extension.error_data(job: job, exception: e))
236137
end
237138

238139
def register_event_handlers
@@ -255,7 +156,7 @@ def retry_handler(*args)
255156
handle_error_event(*args) do |job, error|
256157
return if error.nil?
257158
return if !Sentry.initialized? || job.already_supported_by_sentry_integration?
258-
return unless Sentry.configuration.rails.active_job_report_on_retry_error
159+
return unless consumer.report_retried_exception?(job, error, default: nil)
259160

260161
capture_exception(job, error)
261162
end
@@ -266,35 +167,18 @@ def handle_error_event(*args)
266167
yield(event.payload[:job], event.payload[:error])
267168
end
268169

269-
def finish_sentry_transaction(transaction, status)
270-
return unless transaction
271-
272-
transaction.set_http_status(status)
273-
transaction.finish
274-
end
170+
private :record_in_scope
275171

276-
def sentry_context(job)
277-
context = {
278-
active_job: job.class.name,
279-
scheduled_at: job.scheduled_at,
280-
job_id: job.job_id,
281-
provider_job_id: job.provider_job_id,
282-
locale: job.locale
283-
}
284-
285-
if Sentry.configuration.data_collection.queues
286-
context[:arguments] = sentry_serialize_arguments(job.arguments)
287-
end
288-
289-
context
290-
end
172+
private
291173

292-
def sentry_serialize_arguments(argument)
293-
Sentry::Rails::Serializer.serialize(argument)
174+
def with_isolated_scope(&block)
175+
original_hub = Sentry.get_current_hub_internal
176+
Sentry.clone_hub_to_current_thread
177+
Sentry.with_scope(&block)
178+
ensure
179+
Sentry.set_current_hub_internal(original_hub)
294180
end
295181

296-
private
297-
298182
def subscribers
299183
@__subscribers__ ||= Set.new
300184
end

0 commit comments

Comments
 (0)