Skip to content

Commit c50b6dd

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

10 files changed

Lines changed: 837 additions & 115 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: 124 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -13,30 +13,42 @@ module ActiveJobExtensions
1313

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+
delivery_queue: data["delivery_queue"],
32+
payload: _sentry_job_data,
33+
transaction: transaction,
34+
scope: scope
2735
) { super }
2836
end
2937
end
3038

3139
def serialize
3240
payload = super
3341
return payload if !Sentry.initialized? || already_supported_by_sentry_integration?
42+
return payload if SentryReporter.producer.instrumented_by_extension?(self)
3443

3544
begin
3645
sentry_data = {}
3746
if Sentry.configuration.rails.active_job_propagate_traces
3847
headers = Sentry.get_trace_propagation_headers
39-
sentry_data["trace_propagation_headers"] = headers if headers && !headers.empty?
48+
if headers && !headers.empty?
49+
sentry_data["trace_propagation_headers"] = headers
50+
payload = SentryReporter.producer.inject_trace_headers(self, payload, headers)
51+
end
4052
end
4153

4254
if Sentry.configuration.data_collection.user_info
@@ -58,18 +70,18 @@ def deserialize(job_data)
5870
return if !Sentry.initialized? || already_supported_by_sentry_integration?
5971

6072
begin
73+
@_sentry_job_data = job_data
6174
self._sentry = job_data[SENTRY_PAYLOAD_KEY]
6275
rescue StandardError => e
6376
Sentry.sdk_logger&.error("sentry-rails: failed to extract _sentry payload: #{e.class}: #{e.message}\n #{Array(e.backtrace).first(5).join("\n ")}")
6477
end
6578
end
6679

6780
def already_supported_by_sentry_integration?
68-
Sentry.configuration.rails.skippable_job_adapters.include?(self.class.queue_adapter.class.to_s)
81+
!Sentry::Rails.active_job_owns_adapter?(self.class.queue_adapter.class)
6982
end
7083

7184
class SentryReporter
72-
OP_NAME = "queue.process"
7385
SPAN_ORIGIN = "auto.queue.active_job"
7486

7587
# Emitted as messaging.system when the configured queue adapter's
@@ -85,6 +97,22 @@ class SentryReporter
8597
class << self
8698
include ErrorReporterContext
8799

100+
def consumer
101+
@consumer ||= Sentry::JobExtensions::Consumer.new(integration: Sentry::Rails, origin: SPAN_ORIGIN)
102+
end
103+
104+
def producer
105+
@producer ||= Sentry::JobExtensions::Producer.new(origin: SPAN_ORIGIN)
106+
end
107+
108+
def job_extensions
109+
@job_extensions ||= Sentry.job_extensions
110+
end
111+
112+
def call_hook(job, hook, **arguments)
113+
job_extensions.call(job_extensions.for(job), hook, job: job, **arguments)
114+
end
115+
88116
def producer_callback_registered?
89117
@producer_callback_registered ||= false
90118
end
@@ -95,104 +123,83 @@ def producer_callback_registered!
95123

96124
def record_producer_span(job, &enqueue)
97125
return yield if !Sentry.initialized? || job.already_supported_by_sentry_integration?
98-
99-
enqueued = false
100-
101-
run_enqueue = lambda do
102-
enqueued = true
103-
enqueue.call
104-
end
105-
106-
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
123-
124-
log_producer_span_error(e)
125-
126-
run_enqueue.call
127-
end
126+
return yield if producer.instrumented_by_extension?(job)
127+
128+
producer.record_span(
129+
job,
130+
description: job.class.name,
131+
messaging_data: -> {
132+
{
133+
system: messaging_system(job),
134+
message_id: job.job_id,
135+
destination: job.queue_name
136+
}.compact
137+
},
138+
default_retry_count: -> { executions(job) },
139+
on_instrumentation_error: method(:log_producer_span_error),
140+
&enqueue
141+
)
128142
end
129143

130144
def log_producer_span_error(e)
131145
Sentry.sdk_logger&.error("sentry-rails: failed to record producer span: #{e.class}: #{e.message}\n #{Array(e.backtrace).first(5).join("\n ")}")
132146
end
133147

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
148+
def record(job, trace_headers: nil, user: nil, delivery_queue: nil, payload: nil, transaction: nil, scope: nil, &block)
149+
options = { trace_headers: trace_headers, user: user, delivery_queue: delivery_queue, payload: payload, transaction: transaction }
150+
return record_in_scope(scope, job, **options, backend_scope: true, &block) if scope
137151

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)
152+
with_isolated_scope do |isolated_scope|
153+
record_in_scope(isolated_scope, job, **options, &block)
154+
end
155+
end
176156

177-
capture_exception(job, e)
157+
def record_in_scope(scope, job, trace_headers: nil, user: nil, delivery_queue: nil, payload: nil, transaction: nil, backend_scope: false)
158+
begin
159+
scope.set_user(user.transform_keys(&:to_sym)) if user && !user.empty?
160+
161+
consumer.apply_scope_data(scope, job)
162+
163+
scope.set_transaction_name(consumer.transaction_name(job) || job.class.name, source: :task)
164+
scope.set_tags(queue: delivery_queue || job.queue_name)
178165

179-
raise
166+
scope.set_contexts(active_job: {
167+
job_class: job.class.name,
168+
job_id: job.job_id,
169+
queue: job.queue_name,
170+
provider_job_id: job.provider_job_id
171+
})
172+
173+
transaction = consumer.start_transaction(
174+
scope, job,
175+
transaction: transaction.is_a?(Sentry::Transaction) ? transaction : nil,
176+
trace_headers: trace_headers,
177+
payload: payload,
178+
messaging_data: -> {
179+
{
180+
system: messaging_system(job),
181+
message_id: job.job_id,
182+
destination: job.queue_name,
183+
receive_latency: receive_latency(job),
184+
body_size: payload && body_size { payload }
185+
}
186+
},
187+
default_retry_count: executions(job)
188+
)
189+
190+
yield.tap do
191+
consumer.finish_transaction(transaction, 200)
180192
end
181-
end
182-
ensure
183-
Sentry.set_current_hub_internal(original_hub)
184-
end
193+
rescue Exception => e # rubocop:disable Lint/RescueException
194+
consumer.finish_transaction(transaction, 500)
185195

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
196+
if backend_scope
197+
scope.update_from_options(**exception_data(job))
198+
elsif consumer.report_exception?(job, e)
199+
capture_exception(job, e)
200+
end
193201

194-
if (latency = compute_latency(job))
195-
transaction.set_data(Sentry::Span::DataConventions::MESSAGING_MESSAGE_RECEIVE_LATENCY, latency)
202+
raise
196203
end
197204
end
198205

@@ -208,31 +215,30 @@ def messaging_system(job)
208215
# its first retry reads 1, and so on. On the producer the retry enqueue
209216
# runs after the failed attempt bumped +executions+, so it observes the
210217
# same progression.
211-
def retry_count(job)
218+
def executions(job)
212219
job.executions.to_i if job.respond_to?(:executions)
213220
end
214221

215-
def compute_latency(job)
216-
return unless job.respond_to?(:enqueued_at) && job.enqueued_at
222+
def receive_latency(job)
223+
Sentry::Utils::Latency.milliseconds_since(job.enqueued_at) if job.respond_to?(:enqueued_at)
224+
end
217225

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
226+
def body_size
227+
::JSON.generate(yield).bytesize
228+
rescue StandardError
229+
nil
220230
end
221231

222232
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
233+
consumer.capture_exception(job, e, **exception_data(job))
234+
end
234235

235-
Sentry::Rails.capture_exception(e, **options)
236+
def exception_data(job)
237+
{
238+
contexts: Sentry.configuration.data_collection.queues ? execution_context : {},
239+
tags: { job_id: job.job_id, provider_job_id: job.provider_job_id },
240+
extra: sentry_context(job)
241+
}
236242
end
237243

238244
def register_event_handlers
@@ -255,7 +261,7 @@ def retry_handler(*args)
255261
handle_error_event(*args) do |job, error|
256262
return if error.nil?
257263
return if !Sentry.initialized? || job.already_supported_by_sentry_integration?
258-
return unless Sentry.configuration.rails.active_job_report_on_retry_error
264+
return unless consumer.report_retried_exception?(job, error, default: Sentry.configuration.rails.active_job_report_on_retry_error)
259265

260266
capture_exception(job, error)
261267
end
@@ -266,13 +272,6 @@ def handle_error_event(*args)
266272
yield(event.payload[:job], event.payload[:error])
267273
end
268274

269-
def finish_sentry_transaction(transaction, status)
270-
return unless transaction
271-
272-
transaction.set_http_status(status)
273-
transaction.finish
274-
end
275-
276275
def sentry_context(job)
277276
context = {
278277
active_job: job.class.name,
@@ -293,8 +292,18 @@ def sentry_serialize_arguments(argument)
293292
Sentry::Rails::Serializer.serialize(argument)
294293
end
295294

295+
private :record_in_scope, :exception_data
296+
296297
private
297298

299+
def with_isolated_scope(&block)
300+
original_hub = Sentry.get_current_hub_internal
301+
Sentry.clone_hub_to_current_thread
302+
Sentry.with_scope(&block)
303+
ensure
304+
Sentry.set_current_hub_internal(original_hub)
305+
end
306+
298307
def subscribers
299308
@__subscribers__ ||= Set.new
300309
end

0 commit comments

Comments
 (0)