Skip to content

Commit 27970e7

Browse files
solnicCodex
andcommitted
feat(ruby): add job extensions and shared instrumentation
Co-Authored-By: Codex <noreply@example.com>
1 parent 7e575da commit 27970e7

13 files changed

Lines changed: 1951 additions & 0 deletions

File tree

‎sentry-ruby/lib/sentry-ruby.rb‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
require "sentry/cron/monitor_check_ins"
3030
require "sentry/vernier/profiler"
3131
require "sentry/metrics"
32+
require "sentry/job_extensions"
3233

3334
[
3435
"sentry/rake",
@@ -63,6 +64,8 @@ module Sentry
6364
exception_locals_tp
6465
].freeze
6566

67+
JOB_EXTENSIONS = JobExtensions::Registry.new
68+
6669
class << self
6770
# @!visibility private
6871
def exception_locals_tp
@@ -148,6 +151,27 @@ def register_integration(name, version)
148151
integrations[name.to_s] = meta
149152
end
150153

154+
##### Job Extensions #####
155+
156+
# Returns the process-wide registry of background-job extensions.
157+
#
158+
# @return [JobExtensions::Registry]
159+
def job_extensions
160+
JOB_EXTENSIONS
161+
end
162+
163+
# Registers a background-job extension built by a backend gem. It can be
164+
# called at load time, before or after {Sentry.init}.
165+
#
166+
# @param extension [JobExtensions::Extension] an extension instance,
167+
# constructed with its own configuration and collaborators
168+
# @return [JobExtensions::Extension] the registered extension
169+
# @raise [ArgumentError] if the argument is not a {JobExtensions::Extension}
170+
# instance, or an extension with the same name is already registered
171+
def register_job_extension(extension)
172+
job_extensions.register(extension)
173+
end
174+
151175
##### Method Delegation #####
152176

153177
extend Forwardable
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# frozen_string_literal: true
2+
3+
require "sentry/job_extensions/validators"
4+
require "sentry/job_extensions/hooks"
5+
require "sentry/job_extensions/extension"
6+
require "sentry/job_extensions/composite"
7+
require "sentry/job_extensions/registry"
8+
require "sentry/job_extensions/instrumentation"
9+
require "sentry/job_extensions/consumer"
10+
require "sentry/job_extensions/producer"
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# frozen_string_literal: true
2+
3+
module Sentry
4+
module JobExtensions
5+
class Composite < Extension
6+
def initialize(extension, default_extension:, registry:)
7+
super(name: extension.name)
8+
@extension = extension
9+
@default_extension = default_extension
10+
@registry = registry
11+
end
12+
13+
Hooks::CONTRACTS.each_key do |hook|
14+
define_method(hook) do |**arguments|
15+
default = @registry.call(@default_extension, hook, **arguments)
16+
arguments = arguments.merge(payload: default) if hook == :inject_trace_headers
17+
override = @registry.call(@extension, hook, **arguments)
18+
@default_extension.merge_hook(hook, default: default, override: override)
19+
end
20+
end
21+
end
22+
end
23+
end
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# frozen_string_literal: true
2+
3+
module Sentry
4+
module JobExtensions
5+
# @api private
6+
class Consumer < Instrumentation
7+
OP_NAME = "queue.process"
8+
9+
attr_reader :integration, :origin
10+
11+
def initialize(integration:, origin:, registry: Sentry.job_extensions)
12+
super(registry: registry)
13+
@integration = integration
14+
@origin = origin
15+
end
16+
17+
def apply_scope_data(scope, job, default_scope_data: {})
18+
apply_scope_values(scope, default_scope_data)
19+
data = registry.call(registry.for(job), :scope_data, job: job)
20+
apply_scope_values(scope, data)
21+
end
22+
23+
def transaction_name(job)
24+
registry.call(registry.for(job), :transaction_name, job: job)
25+
end
26+
27+
def start_transaction(scope, job, transaction: nil, trace_headers: nil, payload: nil, always_continue_trace: false, messaging_data: {}, default_retry_count: nil)
28+
extension = registry.for(job)
29+
options = {
30+
name: scope.transaction_name,
31+
source: scope.transaction_source,
32+
op: OP_NAME,
33+
origin: registry.call(extension, :span_origin, job: job) || origin
34+
}
35+
36+
if (trace_headers.nil? || trace_headers.empty?) && payload
37+
extracted_headers = registry.call(extension, :extract_trace_headers, job: job, payload: payload)
38+
39+
unless extracted_headers.nil?
40+
trace_headers = extracted_headers
41+
always_continue_trace = true
42+
end
43+
end
44+
45+
if transaction
46+
transaction.set_name(options[:name], source: options[:source])
47+
transaction.set_origin(options[:origin])
48+
else
49+
transaction = if always_continue_trace || (trace_headers && !trace_headers.empty?)
50+
continued = Sentry.continue_trace(trace_headers, **options)
51+
Sentry.start_transaction(transaction: continued, **options)
52+
else
53+
Sentry.start_transaction(**options)
54+
end
55+
end
56+
return unless transaction
57+
58+
set_messaging_data(transaction, resolve(messaging_data).reject { |_key, value| value.nil? })
59+
set_messaging_data(transaction, registry.call(extension, :messaging_data, job: job))
60+
set_retry_count(transaction, extension, job, default: default_retry_count)
61+
62+
scope.set_span(transaction)
63+
transaction
64+
end
65+
66+
def finish_transaction(transaction, status)
67+
return if !transaction || transaction.timestamp
68+
69+
transaction.set_http_status(status)
70+
transaction.finish
71+
end
72+
73+
def will_retry?(job)
74+
info = registry.call(registry.for(job), :retry_info, job: job)
75+
info ? info[:will_retry] : false
76+
end
77+
78+
def report_exception?(job, exception)
79+
decision = registry.call(registry.for(job), :report_exception?, job: job, exception: exception)
80+
decision.nil? ? !will_retry?(job) : decision
81+
end
82+
83+
def report_retried_exception?(job, exception, default:)
84+
decision = registry.call(registry.for(job), :report_retried_exception?, job: job, exception: exception)
85+
decision.nil? ? default : decision
86+
end
87+
88+
def capture_exception(job, exception, contexts: {}, tags: {}, extra: {})
89+
extension = registry.for(job)
90+
error_data = additive_error_data(
91+
registry.call(extension, :error_data, job: job, exception: exception)
92+
)
93+
94+
options = {
95+
extra: error_data[:extra].merge(extra),
96+
tags: error_data[:tags].merge(tags),
97+
hint: { background: false }
98+
}
99+
contexts = error_data[:contexts].merge(matching_keys(contexts, error_data[:contexts]))
100+
options[:contexts] = contexts unless contexts.empty?
101+
102+
capturing_integration = registry.call(extension, :integration, job: job) || integration
103+
capturing_integration.capture_exception(exception, **options)
104+
end
105+
106+
private
107+
108+
def apply_scope_values(scope, data)
109+
scope.set_contexts(data[:contexts]) if data[:contexts]
110+
scope.set_tags(data[:tags]) if data[:tags]
111+
scope.set_extras(data[:extra]) if data[:extra]
112+
scope.set_user(data[:user]) if data[:user]
113+
end
114+
115+
def additive_error_data(data)
116+
scope = Sentry.get_current_scope
117+
118+
{
119+
contexts: matching_keys(data.fetch(:contexts, {}), scope.contexts).to_h do |name, context|
120+
scope_context = scope.contexts.fetch(name, {})
121+
context = matching_keys(context, scope_context)
122+
[name, context.merge(scope_context)]
123+
end,
124+
tags: matching_keys(data.fetch(:tags, {}), scope.tags).reject { |key, _value| scope.tags.key?(key) },
125+
extra: matching_keys(data.fetch(:extra, {}), scope.extra).reject { |key, _value| scope.extra.key?(key) }
126+
}
127+
end
128+
129+
def matching_keys(data, reference)
130+
keys = reference.keys.to_h { |key| [key.to_s, key] }
131+
data.transform_keys { |key| keys.fetch(key.to_s, key) }
132+
end
133+
end
134+
end
135+
end

0 commit comments

Comments
 (0)