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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +140 -0
  3. data/README.md +2 -1
  4. data/Rakefile +110 -6
  5. data/appsignal.gemspec +7 -0
  6. data/build_matrix.yml +7 -2
  7. data/ext/agent.rb +27 -27
  8. data/lib/appsignal/cli/demo.rb +5 -0
  9. data/lib/appsignal/cli/diagnose.rb +8 -48
  10. data/lib/appsignal/cli/helpers.rb +45 -0
  11. data/lib/appsignal/config.rb +262 -37
  12. data/lib/appsignal/demo.rb +11 -9
  13. data/lib/appsignal/helpers/instrumentation.rb +88 -0
  14. data/lib/appsignal/hooks/action_cable.rb +8 -2
  15. data/lib/appsignal/hooks/active_job.rb +189 -1
  16. data/lib/appsignal/integrations/delayed_job_plugin.rb +172 -32
  17. data/lib/appsignal/integrations/que.rb +41 -9
  18. data/lib/appsignal/integrations/railtie.rb +4 -2
  19. data/lib/appsignal/integrations/resque.rb +26 -3
  20. data/lib/appsignal/integrations/shoryuken.rb +21 -2
  21. data/lib/appsignal/integrations/sidekiq.rb +23 -2
  22. data/lib/appsignal/integrations/webmachine.rb +13 -5
  23. data/lib/appsignal/opentelemetry/http_server_request.rb +35 -1
  24. data/lib/appsignal/opentelemetry/proxied_exporter.rb +83 -0
  25. data/lib/appsignal/opentelemetry.rb +182 -24
  26. data/lib/appsignal/rack/abstract_middleware.rb +4 -1
  27. data/lib/appsignal/rack/event_handler.rb +9 -2
  28. data/lib/appsignal/rack/grape_middleware.rb +37 -7
  29. data/lib/appsignal/rack.rb +29 -1
  30. data/lib/appsignal/transaction/base_backend.rb +21 -0
  31. data/lib/appsignal/transaction/extension_backend.rb +26 -0
  32. data/lib/appsignal/transaction/opentelemetry_backend.rb +90 -39
  33. data/lib/appsignal/transaction.rb +189 -32
  34. data/lib/appsignal/utils/request_headers.rb +78 -0
  35. data/lib/appsignal/utils.rb +1 -0
  36. data/lib/appsignal/version.rb +1 -1
  37. data/lib/appsignal.rb +1 -0
  38. data/sig/appsignal.rbi +205 -1
  39. data/sig/appsignal.rbs +196 -0
  40. 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
- transaction.add_headers_if_nil { request.env }
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
- transaction.add_headers_if_nil { request.env }
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 => :both
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
- transaction =
80
- Appsignal::Transaction.create(
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
- payload = job.payload_object
107
- if payload.respond_to? :job_data
108
- # ActiveJob
109
- job_data = payload.job_data
110
- transaction.set_action_if_nil("#{job_data["job_class"]}#perform")
111
- transaction.add_function_parameters_if_nil(job_data.fetch("arguments", {}))
112
- else
113
- # Delayed Job
114
- transaction.set_action_if_nil(action_name_from_payload(payload, job.name))
115
- transaction.add_function_parameters_if_nil(extract_value(payload, :args, {}))
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.add_tags(
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
- "#{default_name}#perform"
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
- # A job enqueued on its own is the only job its producer span produced, so
108
- # it can be a child of that span as well as link to it. Every job in a
109
- # batch shares one producer span, and a span can only have one parent, so
110
- # parenting a batch would hang the whole batch off that single span. Only
111
- # link those, which is what the OpenTelemetry messaging conventions ask
112
- # for: they use links as the default, and allow the producer to be the
113
- # parent only when it produced a single message.
114
- relationship = QueTraceContext.bulk?(tags) ? :link : :both
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.extract(tags),
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