appsignal 5.0.0.rc.1-java → 5.0.0.rc.2-java
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +140 -0
- data/README.md +2 -1
- data/Rakefile +110 -6
- data/appsignal.gemspec +7 -0
- data/build_matrix.yml +7 -2
- data/ext/agent.rb +27 -27
- data/lib/appsignal/cli/demo.rb +5 -0
- data/lib/appsignal/cli/diagnose.rb +8 -48
- data/lib/appsignal/cli/helpers.rb +45 -0
- data/lib/appsignal/config.rb +262 -37
- data/lib/appsignal/demo.rb +11 -9
- data/lib/appsignal/helpers/instrumentation.rb +88 -0
- data/lib/appsignal/hooks/action_cable.rb +8 -2
- data/lib/appsignal/hooks/active_job.rb +189 -1
- data/lib/appsignal/integrations/delayed_job_plugin.rb +172 -32
- data/lib/appsignal/integrations/que.rb +41 -9
- data/lib/appsignal/integrations/railtie.rb +4 -2
- data/lib/appsignal/integrations/resque.rb +26 -3
- data/lib/appsignal/integrations/shoryuken.rb +21 -2
- data/lib/appsignal/integrations/sidekiq.rb +23 -2
- data/lib/appsignal/integrations/webmachine.rb +13 -5
- data/lib/appsignal/opentelemetry/http_server_request.rb +35 -1
- data/lib/appsignal/opentelemetry/proxied_exporter.rb +83 -0
- data/lib/appsignal/opentelemetry.rb +182 -24
- data/lib/appsignal/rack/abstract_middleware.rb +4 -1
- data/lib/appsignal/rack/event_handler.rb +9 -2
- data/lib/appsignal/rack/grape_middleware.rb +37 -7
- data/lib/appsignal/rack.rb +29 -1
- data/lib/appsignal/transaction/base_backend.rb +21 -0
- data/lib/appsignal/transaction/extension_backend.rb +26 -0
- data/lib/appsignal/transaction/opentelemetry_backend.rb +90 -39
- data/lib/appsignal/transaction.rb +189 -32
- data/lib/appsignal/utils/request_headers.rb +78 -0
- data/lib/appsignal/utils.rb +1 -0
- data/lib/appsignal/version.rb +1 -1
- data/lib/appsignal.rb +1 -0
- data/sig/appsignal.rbi +205 -1
- data/sig/appsignal.rbs +196 -0
- metadata +3 -1
|
@@ -874,6 +874,10 @@ module Appsignal
|
|
|
874
874
|
# # The request headers will include:
|
|
875
875
|
# # { "PATH_INFO" => "/some-path", "HTTP_USER_AGENT" => "Firefox" }
|
|
876
876
|
#
|
|
877
|
+
# @deprecated Use {#add_request_headers} for request headers and
|
|
878
|
+
# {#add_request_environment} for the values a Rack environment holds
|
|
879
|
+
# that are not request headers. This method takes both kinds at once,
|
|
880
|
+
# so it has to work out which of them each value is.
|
|
877
881
|
# @since 4.0.0
|
|
878
882
|
# @param headers [Hash<String, Object>] The request headers to add to the transaction.
|
|
879
883
|
# @yield This block is called when the transaction is sampled. The block's
|
|
@@ -881,6 +885,8 @@ module Appsignal
|
|
|
881
885
|
# @yieldreturn [Hash<String, Object>]
|
|
882
886
|
# @return [void]
|
|
883
887
|
#
|
|
888
|
+
# @see #add_request_headers
|
|
889
|
+
# @see #add_request_environment
|
|
884
890
|
# @see https://docs.appsignal.com/guides/custom-data/sample-data.html
|
|
885
891
|
# Sample data guide
|
|
886
892
|
# @see https://docs.appsignal.com/guides/filter-data/filter-headers.html
|
|
@@ -894,6 +900,88 @@ module Appsignal
|
|
|
894
900
|
end
|
|
895
901
|
alias set_headers add_headers
|
|
896
902
|
|
|
903
|
+
# Add request headers to the current transaction.
|
|
904
|
+
#
|
|
905
|
+
# Request headers are automatically added by most of our integrations. It
|
|
906
|
+
# should not be necessary to call this method unless you want to also
|
|
907
|
+
# report different request headers.
|
|
908
|
+
#
|
|
909
|
+
# Name each header the way OpenTelemetry names it, in lowercase and with
|
|
910
|
+
# dashes. In agent mode the names are converted to the Rack spellings the
|
|
911
|
+
# environment uses, so `accept` is reported as `HTTP_ACCEPT`.
|
|
912
|
+
#
|
|
913
|
+
# To filter request headers, see our request header filtering guide.
|
|
914
|
+
#
|
|
915
|
+
# When both the `headers` argument and a block is given to this method,
|
|
916
|
+
# the block is leading and the argument will _not_ be used.
|
|
917
|
+
#
|
|
918
|
+
# @example Add request headers
|
|
919
|
+
# Appsignal.add_request_headers("accept" => "text/html")
|
|
920
|
+
# # The request headers will include:
|
|
921
|
+
# # { "accept" => "text/html" }
|
|
922
|
+
#
|
|
923
|
+
# @example Calling `add_request_headers` multiple times merges the values
|
|
924
|
+
# Appsignal.add_request_headers("accept" => "text/html")
|
|
925
|
+
# Appsignal.add_request_headers("user-agent" => "Firefox")
|
|
926
|
+
# # The request headers will include:
|
|
927
|
+
# # { "accept" => "text/html", "user-agent" => "Firefox" }
|
|
928
|
+
#
|
|
929
|
+
# @param headers [Hash<String, Object>] The request headers to add to the
|
|
930
|
+
# transaction.
|
|
931
|
+
# @yield This block is called when the transaction is sampled. The block's
|
|
932
|
+
# return value will become the new request headers.
|
|
933
|
+
# @yieldreturn [Hash<String, Object>]
|
|
934
|
+
# @return [void]
|
|
935
|
+
#
|
|
936
|
+
# @see #add_request_environment
|
|
937
|
+
# @see https://docs.appsignal.com/guides/custom-data/sample-data.html
|
|
938
|
+
# Sample data guide
|
|
939
|
+
# @see https://docs.appsignal.com/guides/filter-data/filter-headers.html
|
|
940
|
+
# Request headers filtering guide
|
|
941
|
+
def add_request_headers(headers = nil, &block)
|
|
942
|
+
return unless Appsignal.active?
|
|
943
|
+
return unless Appsignal::Transaction.current?
|
|
944
|
+
|
|
945
|
+
transaction = Appsignal::Transaction.current
|
|
946
|
+
transaction.add_request_headers(headers, &block)
|
|
947
|
+
end
|
|
948
|
+
|
|
949
|
+
# Add values from the request environment to the current transaction.
|
|
950
|
+
#
|
|
951
|
+
# These are the values a Rack environment holds that are not request
|
|
952
|
+
# headers, such as `REMOTE_ADDR` and `QUERY_STRING`. Name each one the
|
|
953
|
+
# way Rack names it. Use {#add_request_headers} for the request headers.
|
|
954
|
+
#
|
|
955
|
+
# The request environment is automatically added by most of our
|
|
956
|
+
# integrations. It should not be necessary to call this method unless you
|
|
957
|
+
# want to also report different values.
|
|
958
|
+
#
|
|
959
|
+
# When both the `environment` argument and a block is given to this
|
|
960
|
+
# method, the block is leading and the argument will _not_ be used.
|
|
961
|
+
#
|
|
962
|
+
# @example Add request environment values
|
|
963
|
+
# Appsignal.add_request_environment("REMOTE_ADDR" => "127.0.0.1")
|
|
964
|
+
# # The request environment will include:
|
|
965
|
+
# # { "REMOTE_ADDR" => "127.0.0.1" }
|
|
966
|
+
#
|
|
967
|
+
# @param environment [Hash<String, Object>] The request environment
|
|
968
|
+
# values to add to the transaction.
|
|
969
|
+
# @yield This block is called when the transaction is sampled. The block's
|
|
970
|
+
# return value will become the new request environment.
|
|
971
|
+
# @yieldreturn [Hash<String, Object>]
|
|
972
|
+
# @return [void]
|
|
973
|
+
#
|
|
974
|
+
# @see #add_request_headers
|
|
975
|
+
# @see https://docs.appsignal.com/guides/custom-data/sample-data.html
|
|
976
|
+
# Sample data guide
|
|
977
|
+
def add_request_environment(environment = nil, &block)
|
|
978
|
+
return unless Appsignal.active?
|
|
979
|
+
return unless Appsignal::Transaction.current?
|
|
980
|
+
|
|
981
|
+
transaction = Appsignal::Transaction.current
|
|
982
|
+
transaction.add_request_environment(environment, &block)
|
|
983
|
+
end
|
|
984
|
+
|
|
897
985
|
# Add breadcrumbs to the transaction.
|
|
898
986
|
#
|
|
899
987
|
# Breadcrumbs can be used to trace what path a user has taken
|
|
@@ -57,7 +57,10 @@ module Appsignal
|
|
|
57
57
|
transaction.set_metadata("path", request.path)
|
|
58
58
|
transaction.set_metadata("method", "websocket")
|
|
59
59
|
transaction.add_request_payload_if_nil { request.params }
|
|
60
|
-
|
|
60
|
+
headers, environment =
|
|
61
|
+
Appsignal::Utils::RequestHeaders.split_lazily { request.env }
|
|
62
|
+
transaction.add_request_headers_if_nil(&headers)
|
|
63
|
+
transaction.add_request_environment_if_nil(&environment)
|
|
61
64
|
transaction.add_session_data { request.session.to_h if request.respond_to? :session }
|
|
62
65
|
transaction.add_tags(:request_id => request_id) if request_id
|
|
63
66
|
Appsignal::Transaction.complete_current!
|
|
@@ -99,7 +102,10 @@ module Appsignal
|
|
|
99
102
|
transaction.set_metadata("path", request.path)
|
|
100
103
|
transaction.set_metadata("method", "websocket")
|
|
101
104
|
transaction.add_request_payload_if_nil { request.params }
|
|
102
|
-
|
|
105
|
+
headers, environment =
|
|
106
|
+
Appsignal::Utils::RequestHeaders.split_lazily { request.env }
|
|
107
|
+
transaction.add_request_headers_if_nil(&headers)
|
|
108
|
+
transaction.add_request_environment_if_nil(&environment)
|
|
103
109
|
transaction.add_session_data { request.session.to_h if request.respond_to? :session }
|
|
104
110
|
transaction.add_tags(:request_id => request_id) if request_id
|
|
105
111
|
Appsignal::Transaction.complete_current!
|
|
@@ -22,6 +22,14 @@ module Appsignal
|
|
|
22
22
|
Appsignal::EventFormatter::RecordedElsewhere
|
|
23
23
|
)
|
|
24
24
|
|
|
25
|
+
# Claimed for the same reason as the single enqueue above: this integration
|
|
26
|
+
# records the batch itself, as one producer event, and Active Job's own
|
|
27
|
+
# `enqueue_all.active_job` notification fires nested inside it.
|
|
28
|
+
Appsignal::EventFormatter.register(
|
|
29
|
+
"enqueue_all.active_job",
|
|
30
|
+
Appsignal::EventFormatter::RecordedElsewhere
|
|
31
|
+
)
|
|
32
|
+
|
|
25
33
|
def self.version_7_1_or_higher?
|
|
26
34
|
@version_7_1_or_higher ||=
|
|
27
35
|
if dependencies_present?
|
|
@@ -37,6 +45,59 @@ module Appsignal
|
|
|
37
45
|
defined?(::ActiveJob)
|
|
38
46
|
end
|
|
39
47
|
|
|
48
|
+
# The parameters of the Active Job method this hook wraps to record a
|
|
49
|
+
# bulk enqueue. The wrapper reads the array of jobs out of the second
|
|
50
|
+
# one, so it is only safe to wrap a method that still takes these.
|
|
51
|
+
EXPECTED_INSTRUMENT_ENQUEUE_ALL_PARAMETERS = [
|
|
52
|
+
[:req, :queue_adapter],
|
|
53
|
+
[:req, :jobs]
|
|
54
|
+
].freeze
|
|
55
|
+
|
|
56
|
+
# Whether Active Job records a bulk enqueue through a method at all. It
|
|
57
|
+
# only does so from version 7.1 on. Checking for the method, rather than
|
|
58
|
+
# for the version, keeps us from wrapping one on a version that has no
|
|
59
|
+
# bulk enqueue path to instrument.
|
|
60
|
+
def self.instrument_enqueue_all_defined?
|
|
61
|
+
::ActiveJob.singleton_class.private_method_defined?(:instrument_enqueue_all)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Whether that method still takes the arguments the wrapper reads.
|
|
65
|
+
#
|
|
66
|
+
# The method is private, so a later version of Active Job can change
|
|
67
|
+
# what it takes. Wrapping one that takes something else raises an
|
|
68
|
+
# ArgumentError inside every `ActiveJob.perform_all_later` call an
|
|
69
|
+
# application makes, which breaks enqueuing and not just its
|
|
70
|
+
# instrumentation. Refusing to wrap it is therefore the safe direction,
|
|
71
|
+
# whatever the refusal costs us.
|
|
72
|
+
def self.instrument_enqueue_all_parameters_match?
|
|
73
|
+
instrument_enqueue_all_parameters == EXPECTED_INSTRUMENT_ENQUEUE_ALL_PARAMETERS
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Active Job's own definition of the method, looked up past the wrapper
|
|
77
|
+
# this hook prepends. Installing twice would otherwise find that wrapper
|
|
78
|
+
# rather than the method it wraps, and so check it against itself. Only
|
|
79
|
+
# the wrapper is skipped, so a version of Active Job that defines the
|
|
80
|
+
# method somewhere else is still found.
|
|
81
|
+
def self.instrument_enqueue_all_method
|
|
82
|
+
method = ::ActiveJob.singleton_class.instance_method(:instrument_enqueue_all)
|
|
83
|
+
method = method.super_method while method&.owner == ActiveJobBulkEnqueueInstrumentation
|
|
84
|
+
method
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.instrument_enqueue_all_parameters
|
|
88
|
+
instrument_enqueue_all_method&.parameters
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Names both sides of the mismatch, so a log line is enough to tell what
|
|
92
|
+
# Active Job changed and what the wrapper was written against.
|
|
93
|
+
def self.instrument_enqueue_all_mismatch_message
|
|
94
|
+
"Not instrumenting Active Job bulk enqueues: " \
|
|
95
|
+
"`ActiveJob.instrument_enqueue_all` takes " \
|
|
96
|
+
"#{instrument_enqueue_all_parameters.inspect} in this version of " \
|
|
97
|
+
"Active Job, where AppSignal expects " \
|
|
98
|
+
"#{EXPECTED_INSTRUMENT_ENQUEUE_ALL_PARAMETERS.inspect}."
|
|
99
|
+
end
|
|
100
|
+
|
|
40
101
|
def dependencies_present?
|
|
41
102
|
self.class.dependencies_present? && Appsignal.config &&
|
|
42
103
|
Appsignal.config[:instrument_active_job]
|
|
@@ -52,6 +113,32 @@ module Appsignal
|
|
|
52
113
|
::ActiveJob::Base
|
|
53
114
|
.prepend ::Appsignal::Hooks::ActiveJobHook::ActiveJobTraceContext
|
|
54
115
|
|
|
116
|
+
# Wrap the method Active Job records a bulk enqueue through, but only
|
|
117
|
+
# when it is still the method the wrapper knows how to read. When it
|
|
118
|
+
# is not, the batch goes unrecorded: the claim above stands, because
|
|
119
|
+
# a worse event is not worth reporting in place of the one we set out
|
|
120
|
+
# to report.
|
|
121
|
+
if !Appsignal::Hooks::ActiveJobHook.instrument_enqueue_all_defined?
|
|
122
|
+
# Nothing to instrument on a version with no bulk enqueue path, so
|
|
123
|
+
# there is nothing to report either.
|
|
124
|
+
Appsignal.internal_logger.debug(
|
|
125
|
+
"Not instrumenting Active Job bulk enqueues: this version of " \
|
|
126
|
+
"Active Job does not record them through " \
|
|
127
|
+
"`ActiveJob.instrument_enqueue_all`."
|
|
128
|
+
)
|
|
129
|
+
elsif !Appsignal::Hooks::ActiveJobHook.instrument_enqueue_all_parameters_match?
|
|
130
|
+
# A bulk enqueue path exists, but not one that can be wrapped
|
|
131
|
+
# without breaking `ActiveJob.perform_all_later` for the whole
|
|
132
|
+
# application. Report that at a level someone will see, because a
|
|
133
|
+
# batch that used to be recorded no longer is.
|
|
134
|
+
Appsignal.internal_logger.warn(
|
|
135
|
+
Appsignal::Hooks::ActiveJobHook.instrument_enqueue_all_mismatch_message
|
|
136
|
+
)
|
|
137
|
+
else
|
|
138
|
+
::ActiveJob.singleton_class
|
|
139
|
+
.prepend ::Appsignal::Hooks::ActiveJobHook::ActiveJobBulkEnqueueInstrumentation
|
|
140
|
+
end
|
|
141
|
+
|
|
55
142
|
next unless Appsignal::Hooks::ActiveJobHook.version_7_1_or_higher?
|
|
56
143
|
|
|
57
144
|
# Only works on Active Job 7.1 and newer
|
|
@@ -63,6 +150,106 @@ module Appsignal
|
|
|
63
150
|
end
|
|
64
151
|
end
|
|
65
152
|
|
|
153
|
+
# Records an `enqueue_all.active_job` event when a batch of jobs is
|
|
154
|
+
# enqueued with `ActiveJob.perform_all_later`, so the batch shows up on the
|
|
155
|
+
# active transaction's timeline as one event, and as one producer span in
|
|
156
|
+
# collector mode.
|
|
157
|
+
#
|
|
158
|
+
# This wraps `instrument_enqueue_all` rather than `perform_all_later`, for
|
|
159
|
+
# two reasons. It is the method that records the batch, so it is called
|
|
160
|
+
# once for each queue adapter the batch spans, which is the same event
|
|
161
|
+
# count as the native notification it replaces. And it runs inside
|
|
162
|
+
# `perform_all_later`, after Active Job has split off the jobs it defers
|
|
163
|
+
# until the database transaction commits, so each of those halves is
|
|
164
|
+
# recorded when it is really enqueued.
|
|
165
|
+
#
|
|
166
|
+
# @!visibility private
|
|
167
|
+
module ActiveJobBulkEnqueueInstrumentation
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
def instrument_enqueue_all(_queue_adapter, jobs)
|
|
171
|
+
# When enqueue instrumentation is disabled, record nothing, the same as
|
|
172
|
+
# the single-job path.
|
|
173
|
+
return super if Appsignal.config && !Appsignal.config[:enable_job_enqueue_instrumentation]
|
|
174
|
+
|
|
175
|
+
# Another enqueue integration is already recording this enqueue, so
|
|
176
|
+
# don't record it a second time.
|
|
177
|
+
if Appsignal::Transaction.current? &&
|
|
178
|
+
Appsignal::Transaction.current.job_enqueue_events_suppressed?
|
|
179
|
+
return super
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
Appsignal.instrument(
|
|
183
|
+
"enqueue_all.active_job",
|
|
184
|
+
bulk_enqueue_title(jobs),
|
|
185
|
+
:opentelemetry_kind => :producer,
|
|
186
|
+
:opentelemetry_scope => ["appsignal-ruby/active_job", Appsignal::VERSION]
|
|
187
|
+
) do
|
|
188
|
+
Appsignal::Transaction.current.add_opentelemetry_attributes(
|
|
189
|
+
Appsignal::OpenTelemetry::Messaging.enqueue_attributes(
|
|
190
|
+
"active_job",
|
|
191
|
+
:destination => bulk_enqueue_destination(jobs),
|
|
192
|
+
:batch_size => jobs.size
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
inject_context_into(jobs)
|
|
196
|
+
# A bulk enqueue does not go through `ActiveJob::Base#enqueue`, so
|
|
197
|
+
# nothing has suppressed the adapter (Sidekiq, Resque, ...) yet, and
|
|
198
|
+
# its own enqueue instrumentation would record an event for every job
|
|
199
|
+
# in the batch. Suppress it so the batch is recorded once, as this
|
|
200
|
+
# event.
|
|
201
|
+
if Appsignal::Transaction.current?
|
|
202
|
+
Appsignal::Transaction.current.suppress_job_enqueue_events { super }
|
|
203
|
+
else
|
|
204
|
+
super
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Writes this producer span's context onto every job in the batch, and
|
|
210
|
+
# marks each one as part of a batch, so the jobs that perform later link
|
|
211
|
+
# back to this span. A bulk enqueue never goes through
|
|
212
|
+
# `ActiveJob::Base#enqueue`, so the injection the single-job path does
|
|
213
|
+
# never runs for these jobs. The adapter serializes each job after this,
|
|
214
|
+
# and the `serialize` patch carries the headers to the wire from there.
|
|
215
|
+
# A no-op outside collector mode.
|
|
216
|
+
def inject_context_into(jobs)
|
|
217
|
+
jobs.each do |job|
|
|
218
|
+
headers = job.__otel_headers
|
|
219
|
+
Appsignal::OpenTelemetry.inject_context(headers)
|
|
220
|
+
Appsignal::OpenTelemetry.mark_active_job_batch(headers)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# The batch's job class, when every job in it has the same one. Active
|
|
225
|
+
# Job groups the jobs it enqueues by queue adapter rather than by class,
|
|
226
|
+
# so a batch can mix classes, and then there is no one class to name.
|
|
227
|
+
def bulk_enqueue_title(jobs)
|
|
228
|
+
job_class = shared_across(jobs) { |job| job.class.name }
|
|
229
|
+
return "bulk enqueue jobs" unless job_class
|
|
230
|
+
|
|
231
|
+
"bulk enqueue #{job_class} jobs"
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# The queue the batch went to, when every job in it is on the same one.
|
|
235
|
+
# Grouping is by queue adapter and not by queue, so a batch can span
|
|
236
|
+
# queues, and then there is no one queue to name as the destination.
|
|
237
|
+
def bulk_enqueue_destination(jobs)
|
|
238
|
+
shared_across(jobs, &:queue_name)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# The one value every job in the batch shares, or nil when they differ
|
|
242
|
+
# or the batch is empty. Stops at the first job that disagrees, because
|
|
243
|
+
# a batch is as large as the caller made it and a single mismatch is
|
|
244
|
+
# enough to know.
|
|
245
|
+
def shared_across(jobs)
|
|
246
|
+
return if jobs.empty?
|
|
247
|
+
|
|
248
|
+
first = yield(jobs.first)
|
|
249
|
+
jobs.all? { |job| yield(job) == first } ? first : nil
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
66
253
|
module ActiveJobClassInstrumentation
|
|
67
254
|
def execute(job)
|
|
68
255
|
enqueued_at = job["enqueued_at"]
|
|
@@ -96,7 +283,8 @@ module Appsignal
|
|
|
96
283
|
:opentelemetry_context => Appsignal::OpenTelemetry.extract_job_context(job),
|
|
97
284
|
:opentelemetry_scope => ["appsignal-ruby/active_job", Appsignal::VERSION],
|
|
98
285
|
:opentelemetry_kind => :consumer,
|
|
99
|
-
:opentelemetry_relationship =>
|
|
286
|
+
:opentelemetry_relationship =>
|
|
287
|
+
Appsignal::OpenTelemetry.active_job_relationship(job)
|
|
100
288
|
)
|
|
101
289
|
end
|
|
102
290
|
|
|
@@ -11,6 +11,18 @@ module Appsignal
|
|
|
11
11
|
enqueue_with_instrumentation(job, block)
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
+
# Delayed Job asks a job for its own maximum run time before it invokes
|
|
15
|
+
# it, and answering that question reads the job's payload. A job whose
|
|
16
|
+
# payload will not deserialize therefore raises inside the worker and
|
|
17
|
+
# never reaches `invoke_job`, so the callback below does not run and the
|
|
18
|
+
# failure goes unreported. This callback sits one step further out, on
|
|
19
|
+
# the worker's whole run of the job, which is where such a job can still
|
|
20
|
+
# be seen.
|
|
21
|
+
lifecycle.around(:perform) do |worker, job, &block|
|
|
22
|
+
report_job_that_cannot_be_loaded(job)
|
|
23
|
+
block.call(worker, job)
|
|
24
|
+
end
|
|
25
|
+
|
|
14
26
|
lifecycle.around(:invoke_job) do |job, &block|
|
|
15
27
|
invoke_with_instrumentation(job, block)
|
|
16
28
|
end
|
|
@@ -75,18 +87,58 @@ module Appsignal
|
|
|
75
87
|
job.name
|
|
76
88
|
end
|
|
77
89
|
|
|
90
|
+
# Reports a job whose payload cannot be read, and does nothing at all for
|
|
91
|
+
# a job that loads. That is what keeps every ordinary job instrumented by
|
|
92
|
+
# the `:invoke_job` callback alone: no transaction is opened here, so an
|
|
93
|
+
# ordinary job's reported duration and its reported error are exactly
|
|
94
|
+
# what they were before this callback existed.
|
|
95
|
+
#
|
|
96
|
+
# Reading the payload here reads it a moment before the worker would have
|
|
97
|
+
# read it anyway. Delayed Job memoizes a payload that loads, so a job
|
|
98
|
+
# that is fine is deserialized once either way.
|
|
99
|
+
#
|
|
100
|
+
# Nothing is re-raised. The worker reads the same payload immediately
|
|
101
|
+
# after this returns, raises the same error, and fails the job as it
|
|
102
|
+
# always has. Raising here instead would move that error out of the
|
|
103
|
+
# worker's own handling and into the loop that reserves jobs.
|
|
104
|
+
def self.report_job_that_cannot_be_loaded(job)
|
|
105
|
+
job.payload_object
|
|
106
|
+
nil
|
|
107
|
+
rescue Exception => error
|
|
108
|
+
warn_unreadable_payload_once(error)
|
|
109
|
+
|
|
110
|
+
# No job data: the payload it would have been read from is the one that
|
|
111
|
+
# just failed to load.
|
|
112
|
+
transaction = create_perform_transaction(job, nil)
|
|
113
|
+
transaction.set_action_if_nil(action_name_without_payload(job))
|
|
114
|
+
transaction.set_error(error)
|
|
115
|
+
add_job_metadata(transaction, job)
|
|
116
|
+
Appsignal::Transaction.complete_current!
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The serialized Active Job job data inside a Delayed Job job, or nil when
|
|
120
|
+
# this is not an Active Job job. The Active Job adapter wraps the job data
|
|
121
|
+
# in an object that exposes it as `job_data`.
|
|
122
|
+
#
|
|
123
|
+
# Delayed Job has no carrier of its own, because a job's handler is a YAML
|
|
124
|
+
# dump of the object to run with nowhere to put a header, so an Active Job
|
|
125
|
+
# job is the only kind that arrives with a trace context at all.
|
|
126
|
+
#
|
|
127
|
+
# Reading it means deserializing the handler, which raises for a job whose
|
|
128
|
+
# class is gone, so a job that cannot be read gets no context. Delayed Job
|
|
129
|
+
# remembers a payload it read successfully, so doing this before the job
|
|
130
|
+
# runs costs no extra work later.
|
|
131
|
+
def self.active_job_data(job)
|
|
132
|
+
payload = job.payload_object
|
|
133
|
+
payload.job_data if payload.respond_to?(:job_data)
|
|
134
|
+
rescue => error
|
|
135
|
+
warn_unreadable_payload_once(error)
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
|
|
78
139
|
def self.invoke_with_instrumentation(job, block)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
Appsignal::Transaction::BACKGROUND_JOB,
|
|
82
|
-
:opentelemetry_scope => ["appsignal-ruby/delayed_job", Appsignal::VERSION],
|
|
83
|
-
:opentelemetry_kind => :consumer,
|
|
84
|
-
:opentelemetry_relationship => :both
|
|
85
|
-
)
|
|
86
|
-
transaction.add_opentelemetry_attributes(
|
|
87
|
-
Appsignal::OpenTelemetry::Messaging
|
|
88
|
-
.perform_attributes("delayed_job", :destination => queue_name(job))
|
|
89
|
-
)
|
|
140
|
+
job_data = active_job_data(job)
|
|
141
|
+
transaction = create_perform_transaction(job, job_data)
|
|
90
142
|
|
|
91
143
|
begin
|
|
92
144
|
Appsignal.instrument(
|
|
@@ -103,39 +155,127 @@ module Appsignal
|
|
|
103
155
|
transaction.set_error(error)
|
|
104
156
|
raise
|
|
105
157
|
ensure
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
158
|
+
# Delayed Job raises when a job's handler will not deserialize, and it
|
|
159
|
+
# raises again on every further attempt to read it. Reading the payload
|
|
160
|
+
# here without a guard therefore replaces the error the job already
|
|
161
|
+
# failed with, and skips the rest of this block, so the transaction is
|
|
162
|
+
# never completed and the failure is never reported.
|
|
163
|
+
begin
|
|
164
|
+
payload = job.payload_object
|
|
165
|
+
if payload.respond_to? :job_data
|
|
166
|
+
# ActiveJob
|
|
167
|
+
job_data = payload.job_data
|
|
168
|
+
transaction.set_action_if_nil("#{job_data["job_class"]}#perform")
|
|
169
|
+
transaction.add_function_parameters_if_nil(job_data.fetch("arguments", {}))
|
|
170
|
+
else
|
|
171
|
+
# Delayed Job
|
|
172
|
+
transaction.set_action_if_nil(action_name_from_payload(payload, job.name))
|
|
173
|
+
transaction.add_function_parameters_if_nil(extract_value(payload, :args, {}))
|
|
174
|
+
end
|
|
175
|
+
rescue => error
|
|
176
|
+
warn_unreadable_payload_once(error)
|
|
177
|
+
transaction.set_action_if_nil(action_name_without_payload(job))
|
|
116
178
|
end
|
|
117
179
|
|
|
118
|
-
transaction
|
|
119
|
-
:id => extract_value(job, :id, nil, true),
|
|
120
|
-
:queue => extract_value(job, :queue),
|
|
121
|
-
:priority => extract_value(job, :priority, 0),
|
|
122
|
-
:attempts => extract_value(job, :attempts, 0)
|
|
123
|
-
)
|
|
124
|
-
|
|
125
|
-
transaction.set_queue_start(extract_value(job, :run_at)&.to_i&.* 1_000)
|
|
180
|
+
add_job_metadata(transaction, job)
|
|
126
181
|
|
|
127
182
|
Appsignal::Transaction.complete_current!
|
|
128
183
|
end
|
|
129
184
|
end
|
|
130
185
|
|
|
186
|
+
# The transaction a performed job is reported under. Shared by the two
|
|
187
|
+
# callbacks that can report a job, so both describe it the same way. The
|
|
188
|
+
# job data is passed in rather than read here, because the payload it
|
|
189
|
+
# would be read from is the one a job that cannot be loaded failed on.
|
|
190
|
+
def self.create_perform_transaction(job, job_data)
|
|
191
|
+
transaction = Appsignal::Transaction.create(
|
|
192
|
+
Appsignal::Transaction::BACKGROUND_JOB,
|
|
193
|
+
:opentelemetry_context =>
|
|
194
|
+
Appsignal::OpenTelemetry.extract_active_job_context(job_data),
|
|
195
|
+
:opentelemetry_scope => ["appsignal-ruby/delayed_job", Appsignal::VERSION],
|
|
196
|
+
:opentelemetry_kind => :consumer,
|
|
197
|
+
:opentelemetry_relationship =>
|
|
198
|
+
Appsignal::OpenTelemetry.active_job_relationship(job_data)
|
|
199
|
+
)
|
|
200
|
+
transaction.add_opentelemetry_attributes(
|
|
201
|
+
Appsignal::OpenTelemetry::Messaging
|
|
202
|
+
.perform_attributes("delayed_job", :destination => queue_name(job))
|
|
203
|
+
)
|
|
204
|
+
transaction
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def self.add_job_metadata(transaction, job)
|
|
208
|
+
transaction.add_tags(
|
|
209
|
+
:id => extract_value(job, :id, nil, true),
|
|
210
|
+
:queue => extract_value(job, :queue),
|
|
211
|
+
:priority => extract_value(job, :priority, 0),
|
|
212
|
+
:attempts => extract_value(job, :attempts, 0)
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
transaction.set_queue_start(extract_value(job, :run_at)&.to_i&.* 1_000)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# The name Delayed Job derives from the raw handler when the payload will
|
|
219
|
+
# not deserialize. It reads the class name out of the handler with a
|
|
220
|
+
# regular expression, which covers the job class a deploy removed. That
|
|
221
|
+
# expression raises for a handler it does not match, and it is skipped
|
|
222
|
+
# altogether when the payload raised something Delayed Job does not wrap,
|
|
223
|
+
# so fall back to a name that says the failure came from inside Delayed
|
|
224
|
+
# Job. The failure is then reported under a name that can be found, and
|
|
225
|
+
# every job this happens to is grouped together.
|
|
226
|
+
def self.action_name_without_payload(job)
|
|
227
|
+
with_perform_suffix(job.name)
|
|
228
|
+
rescue
|
|
229
|
+
"DelayedJobInternal"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Guards the check-and-set below, so two threads that read an unreadable
|
|
233
|
+
# payload at the same time cannot both warn. A constant so it is created
|
|
234
|
+
# once at load time, because creating it lazily would race in turn.
|
|
235
|
+
WARN_ONCE_LOCK = Mutex.new
|
|
236
|
+
|
|
237
|
+
# A deploy that removes a job class leaves every job of that class unable
|
|
238
|
+
# to deserialize, so this can be reached once per job. Each of those jobs
|
|
239
|
+
# reports its own error to AppSignal, so the log only has to say once that
|
|
240
|
+
# it is happening.
|
|
241
|
+
def self.warn_unreadable_payload_once(error)
|
|
242
|
+
should_warn = WARN_ONCE_LOCK.synchronize do
|
|
243
|
+
next false if @warned_unreadable_payload
|
|
244
|
+
|
|
245
|
+
@warned_unreadable_payload = true
|
|
246
|
+
end
|
|
247
|
+
return unless should_warn
|
|
248
|
+
|
|
249
|
+
Appsignal.internal_logger.warn(
|
|
250
|
+
"Unable to read a Delayed Job job's payload: #{error.class}: " \
|
|
251
|
+
"#{error.message}. Jobs whose payload cannot be read are reported " \
|
|
252
|
+
"without parameters. They are named after the class in their raw " \
|
|
253
|
+
"handler, or DelayedJobInternal when that cannot be read either."
|
|
254
|
+
)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# @!visibility private
|
|
258
|
+
#
|
|
259
|
+
# Resets the warn-once state. Only used to keep test runs isolated.
|
|
260
|
+
def self.reset_unreadable_payload_warning!
|
|
261
|
+
WARN_ONCE_LOCK.synchronize { @warned_unreadable_payload = false }
|
|
262
|
+
end
|
|
263
|
+
|
|
131
264
|
def self.action_name_from_payload(payload, default_name)
|
|
132
265
|
# Attempt to find appsignal_name override
|
|
133
266
|
class_and_method_name = extract_value(payload, :appsignal_name, nil)
|
|
134
267
|
return class_and_method_name if class_and_method_name.is_a?(String)
|
|
135
|
-
return default_name if default_name.split("#").length == 2
|
|
136
|
-
return default_name if default_name.split(".").length == 2
|
|
137
268
|
|
|
138
|
-
|
|
269
|
+
with_perform_suffix(default_name)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# An action name is a class and a method. A name that already names both,
|
|
273
|
+
# separated either way, is left alone.
|
|
274
|
+
def self.with_perform_suffix(name)
|
|
275
|
+
return name if name.split("#").length == 2
|
|
276
|
+
return name if name.split(".").length == 2
|
|
277
|
+
|
|
278
|
+
"#{name}#perform"
|
|
139
279
|
end
|
|
140
280
|
|
|
141
281
|
# rubocop:disable Style/OptionalBooleanParameter
|
|
@@ -93,6 +93,35 @@ module Appsignal
|
|
|
93
93
|
Array(tags).include?(BULK_TAG)
|
|
94
94
|
end
|
|
95
95
|
|
|
96
|
+
# The class the Active Job adapter enqueues, with the serialized job data
|
|
97
|
+
# as its only argument. Que records the name in the job's `job_class`, and
|
|
98
|
+
# the name is fixed, because it is stored in every job record the adapter
|
|
99
|
+
# has ever written.
|
|
100
|
+
ACTIVE_JOB_WRAPPER = "ActiveJob::QueueAdapters::QueAdapter::JobWrapper"
|
|
101
|
+
|
|
102
|
+
# The trace context to continue: the Active Job layer when this is an
|
|
103
|
+
# Active Job job, the job's own tags otherwise. See `Appsignal::OpenTelemetry.extract_active_job_context`
|
|
104
|
+
# for why that layer wins.
|
|
105
|
+
# Que's tags are a carrier with a hard limit: five per job, shared with
|
|
106
|
+
# whatever the user puts there.
|
|
107
|
+
def extract_context(job_data, tags)
|
|
108
|
+
Appsignal::OpenTelemetry.extract_active_job_context(job_data) || extract(tags)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The serialized Active Job job data inside a Que job, or nil when this is
|
|
112
|
+
# not an Active Job job.
|
|
113
|
+
#
|
|
114
|
+
# Que reads a job's arguments out of JSONB with symbol keys and only turns
|
|
115
|
+
# them back into strings on the way into Active Job, so they are
|
|
116
|
+
# stringified here too. Only the outer keys need it: the headers below them
|
|
117
|
+
# are an array of pairs of strings, which Que leaves alone.
|
|
118
|
+
def active_job_data(attrs)
|
|
119
|
+
return unless attrs[:job_class] == ACTIVE_JOB_WRAPPER
|
|
120
|
+
|
|
121
|
+
job_data = Array(attrs[:args]).first
|
|
122
|
+
job_data.transform_keys(&:to_s) if job_data.is_a?(Hash)
|
|
123
|
+
end
|
|
124
|
+
|
|
96
125
|
def within_limits?(tags)
|
|
97
126
|
tags.length <= MAX_TAGS_COUNT && tags.all? { |tag| tag.length <= MAX_TAG_LENGTH }
|
|
98
127
|
end
|
|
@@ -104,21 +133,24 @@ module Appsignal
|
|
|
104
133
|
local_attrs = respond_to?(:que_attrs) ? que_attrs : attrs
|
|
105
134
|
tags = local_attrs.dig(:data, :tags)
|
|
106
135
|
|
|
107
|
-
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
136
|
+
job_data = QueTraceContext.active_job_data(local_attrs)
|
|
137
|
+
# A Que batch says so with a tag, and an Active Job batch says so in the
|
|
138
|
+
# job data, so both have to be asked. See
|
|
139
|
+
# `Appsignal::OpenTelemetry.active_job_relationship` for why a batch
|
|
140
|
+
# links back rather than parenting under the span that enqueued it.
|
|
141
|
+
relationship =
|
|
142
|
+
if QueTraceContext.bulk?(tags)
|
|
143
|
+
:link
|
|
144
|
+
else
|
|
145
|
+
Appsignal::OpenTelemetry.active_job_relationship(job_data)
|
|
146
|
+
end
|
|
115
147
|
|
|
116
148
|
# Read the incoming trace context off the job's tags so the transaction
|
|
117
149
|
# links back to the enqueuer. No-op outside collector mode.
|
|
118
150
|
transaction =
|
|
119
151
|
Appsignal::Transaction.create(
|
|
120
152
|
Appsignal::Transaction::BACKGROUND_JOB,
|
|
121
|
-
:opentelemetry_context => QueTraceContext.
|
|
153
|
+
:opentelemetry_context => QueTraceContext.extract_context(job_data, tags),
|
|
122
154
|
:opentelemetry_scope => ["appsignal-ruby/que", Appsignal::VERSION],
|
|
123
155
|
:opentelemetry_kind => :consumer,
|
|
124
156
|
:opentelemetry_relationship => relationship
|