appsignal 4.10.3-java → 5.0.0.rc.1-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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -50
  3. data/README.md +1 -2
  4. data/Rakefile +87 -0
  5. data/appsignal.gemspec +8 -7
  6. data/build_matrix.yml +14 -7
  7. data/ext/agent.rb +27 -27
  8. data/ext/appsignal_extension.c +14 -0
  9. data/lib/appsignal/backends.rb +55 -0
  10. data/lib/appsignal/cli/demo.rb +0 -5
  11. data/lib/appsignal/cli/diagnose.rb +38 -6
  12. data/lib/appsignal/cli/helpers.rb +0 -45
  13. data/lib/appsignal/config.rb +140 -1
  14. data/lib/appsignal/demo.rb +1 -1
  15. data/lib/appsignal/event_formatter/action_view/render_formatter.rb +34 -22
  16. data/lib/appsignal/event_formatter/active_job/perform_formatter.rb +35 -0
  17. data/lib/appsignal/event_formatter/active_record/sql_formatter.rb +19 -0
  18. data/lib/appsignal/event_formatter/elastic_search/search_formatter.rb +27 -0
  19. data/lib/appsignal/event_formatter/recorded_elsewhere.rb +17 -0
  20. data/lib/appsignal/event_formatter/rom/sql_formatter.rb +24 -0
  21. data/lib/appsignal/event_formatter/sequel/sql_formatter.rb +5 -0
  22. data/lib/appsignal/event_formatter/view_component/render_formatter.rb +21 -10
  23. data/lib/appsignal/event_formatter.rb +78 -0
  24. data/lib/appsignal/extension.rb +4 -0
  25. data/lib/appsignal/helpers/instrumentation.rb +236 -20
  26. data/lib/appsignal/helpers/metrics.rb +3 -24
  27. data/lib/appsignal/hooks/action_cable.rb +18 -6
  28. data/lib/appsignal/hooks/active_job.rb +128 -179
  29. data/lib/appsignal/hooks/at_exit.rb +4 -1
  30. data/lib/appsignal/hooks/excon.rb +20 -0
  31. data/lib/appsignal/hooks/faraday.rb +16 -0
  32. data/lib/appsignal/hooks/http.rb +5 -0
  33. data/lib/appsignal/hooks/resque.rb +1 -1
  34. data/lib/appsignal/hooks/sequel.rb +32 -2
  35. data/lib/appsignal/hooks/shoryuken.rb +3 -3
  36. data/lib/appsignal/hooks/sidekiq.rb +1 -1
  37. data/lib/appsignal/integrations/action_cable.rb +5 -2
  38. data/lib/appsignal/integrations/active_support_notifications.rb +59 -19
  39. data/lib/appsignal/integrations/data_mapper.rb +14 -2
  40. data/lib/appsignal/integrations/delayed_job_plugin.rb +64 -131
  41. data/lib/appsignal/integrations/dry_monitor.rb +39 -15
  42. data/lib/appsignal/integrations/excon/appsignal_middleware.rb +21 -0
  43. data/lib/appsignal/integrations/excon.rb +52 -15
  44. data/lib/appsignal/integrations/faraday.rb +47 -12
  45. data/lib/appsignal/integrations/http.rb +43 -1
  46. data/lib/appsignal/integrations/mongo_ruby_driver.rb +73 -4
  47. data/lib/appsignal/integrations/net_http.rb +31 -2
  48. data/lib/appsignal/integrations/puma.rb +4 -1
  49. data/lib/appsignal/integrations/que.rb +224 -37
  50. data/lib/appsignal/integrations/railtie.rb +4 -1
  51. data/lib/appsignal/integrations/rake.rb +9 -3
  52. data/lib/appsignal/integrations/redis.rb +22 -1
  53. data/lib/appsignal/integrations/redis_client.rb +22 -1
  54. data/lib/appsignal/integrations/resque.rb +57 -10
  55. data/lib/appsignal/integrations/shoryuken.rb +140 -12
  56. data/lib/appsignal/integrations/sidekiq.rb +73 -16
  57. data/lib/appsignal/integrations/webmachine.rb +47 -4
  58. data/lib/appsignal/loaders/padrino.rb +2 -1
  59. data/lib/appsignal/logger/extension_backend.rb +24 -0
  60. data/lib/appsignal/logger/opentelemetry_backend.rb +66 -0
  61. data/lib/appsignal/logger.rb +13 -9
  62. data/lib/appsignal/metrics/extension_backend.rb +47 -0
  63. data/lib/appsignal/metrics/opentelemetry_backend.rb +89 -0
  64. data/lib/appsignal/opentelemetry/attributes.rb +31 -0
  65. data/lib/appsignal/opentelemetry/dependencies.rb +35 -0
  66. data/lib/appsignal/opentelemetry/error_type.rb +37 -0
  67. data/lib/appsignal/opentelemetry/http_client_request.rb +83 -0
  68. data/lib/appsignal/opentelemetry/http_method.rb +59 -0
  69. data/lib/appsignal/opentelemetry/http_response.rb +30 -0
  70. data/lib/appsignal/opentelemetry/http_server_request.rb +45 -0
  71. data/lib/appsignal/opentelemetry/messaging.rb +82 -0
  72. data/lib/appsignal/opentelemetry/rendering.rb +29 -0
  73. data/lib/appsignal/opentelemetry/sql_db_system.rb +89 -0
  74. data/lib/appsignal/opentelemetry.rb +337 -0
  75. data/lib/appsignal/rack/abstract_middleware.rb +63 -4
  76. data/lib/appsignal/rack/body_wrapper.rb +18 -5
  77. data/lib/appsignal/rack/event_handler.rb +36 -3
  78. data/lib/appsignal/rack/grape_middleware.rb +8 -37
  79. data/lib/appsignal/rack/hanami_middleware.rb +2 -1
  80. data/lib/appsignal/rack/instrumentation_middleware.rb +1 -0
  81. data/lib/appsignal/rack/rails_instrumentation.rb +1 -0
  82. data/lib/appsignal/rack/sinatra_instrumentation.rb +1 -0
  83. data/lib/appsignal/rack.rb +39 -11
  84. data/lib/appsignal/sample_data.rb +4 -0
  85. data/lib/appsignal/transaction/base_backend.rb +107 -0
  86. data/lib/appsignal/transaction/extension_backend.rb +203 -0
  87. data/lib/appsignal/transaction/opentelemetry_backend.rb +796 -0
  88. data/lib/appsignal/transaction.rb +537 -144
  89. data/lib/appsignal/utils/stdout_and_logger_message.rb +9 -0
  90. data/lib/appsignal/version.rb +1 -1
  91. data/lib/appsignal.rb +9 -0
  92. data/sig/appsignal.rbi +426 -37
  93. data/sig/appsignal.rbs +386 -27
  94. metadata +25 -6
@@ -6,6 +6,22 @@ module Appsignal
6
6
  class ActiveJobHook < Appsignal::Hooks::Hook
7
7
  register :active_job
8
8
 
9
+ # This integration records the enqueue itself, as a producer event that
10
+ # also injects trace context, and Active Job's own `enqueue.active_job`
11
+ # notification fires nested inside it. Claim the event so that the
12
+ # generic notification paths leave it alone.
13
+ #
14
+ # Claimed here, when this file is required, rather than in `install`.
15
+ # `install` only runs when Active Job instrumentation is turned on, but
16
+ # a customer who turns it off does not want to see the native
17
+ # notification reported instead. This call does not touch any
18
+ # `ActiveJob` constant, so it is safe to run even when the library is
19
+ # not present.
20
+ Appsignal::EventFormatter.register(
21
+ "enqueue.active_job",
22
+ Appsignal::EventFormatter::RecordedElsewhere
23
+ )
24
+
9
25
  def self.version_7_1_or_higher?
10
26
  @version_7_1_or_higher ||=
11
27
  if dependencies_present?
@@ -21,59 +37,6 @@ module Appsignal
21
37
  defined?(::ActiveJob)
22
38
  end
23
39
 
24
- # The parameters of the Active Job method this hook wraps to record a
25
- # bulk enqueue. The wrapper reads the array of jobs out of the second
26
- # one, so it is only safe to wrap a method that still takes these.
27
- EXPECTED_INSTRUMENT_ENQUEUE_ALL_PARAMETERS = [
28
- [:req, :queue_adapter],
29
- [:req, :jobs]
30
- ].freeze
31
-
32
- # Whether Active Job records a bulk enqueue through a method at all. It
33
- # only does so from version 7.1 on. Checking for the method, rather than
34
- # for the version, keeps us from wrapping one on a version that has no
35
- # bulk enqueue path to instrument.
36
- def self.instrument_enqueue_all_defined?
37
- ::ActiveJob.singleton_class.private_method_defined?(:instrument_enqueue_all)
38
- end
39
-
40
- # Whether that method still takes the arguments the wrapper reads.
41
- #
42
- # The method is private, so a later version of Active Job can change
43
- # what it takes. Wrapping one that takes something else raises an
44
- # ArgumentError inside every `ActiveJob.perform_all_later` call an
45
- # application makes, which breaks enqueuing and not just its
46
- # instrumentation. Refusing to wrap it is therefore the safe direction,
47
- # whatever the refusal costs us.
48
- def self.instrument_enqueue_all_parameters_match?
49
- instrument_enqueue_all_parameters == EXPECTED_INSTRUMENT_ENQUEUE_ALL_PARAMETERS
50
- end
51
-
52
- # Active Job's own definition of the method, looked up past the wrapper
53
- # this hook prepends. Installing twice would otherwise find that wrapper
54
- # rather than the method it wraps, and so check it against itself. Only
55
- # the wrapper is skipped, so a version of Active Job that defines the
56
- # method somewhere else is still found.
57
- def self.instrument_enqueue_all_method
58
- method = ::ActiveJob.singleton_class.instance_method(:instrument_enqueue_all)
59
- method = method.super_method while method&.owner == ActiveJobBulkEnqueueInstrumentation
60
- method
61
- end
62
-
63
- def self.instrument_enqueue_all_parameters
64
- instrument_enqueue_all_method&.parameters
65
- end
66
-
67
- # Names both sides of the mismatch, so a log line is enough to tell what
68
- # Active Job changed and what the wrapper was written against.
69
- def self.instrument_enqueue_all_mismatch_message
70
- "Not instrumenting Active Job bulk enqueues: " \
71
- "`ActiveJob.instrument_enqueue_all` takes " \
72
- "#{instrument_enqueue_all_parameters.inspect} in this version of " \
73
- "Active Job, where AppSignal expects " \
74
- "#{EXPECTED_INSTRUMENT_ENQUEUE_ALL_PARAMETERS.inspect}."
75
- end
76
-
77
40
  def dependencies_present?
78
41
  self.class.dependencies_present? && Appsignal.config &&
79
42
  Appsignal.config[:instrument_active_job]
@@ -83,34 +46,11 @@ module Appsignal
83
46
  ActiveSupport.on_load(:active_job) do
84
47
  ::ActiveJob::Base
85
48
  .extend ::Appsignal::Hooks::ActiveJobHook::ActiveJobClassInstrumentation
49
+ # Carry W3C trace context across the enqueue/perform boundary in
50
+ # collector mode (no-ops otherwise). The patches are cheap and
51
+ # mode-gated inside their method bodies, so install them unconditionally.
86
52
  ::ActiveJob::Base
87
- .prepend ::Appsignal::Hooks::ActiveJobHook::ActiveJobEnqueueInstrumentation
88
-
89
- # Wrap the method Active Job records a bulk enqueue through, but only
90
- # when it is still the method the wrapper knows how to read. When it
91
- # is not, the batch goes unrecorded: Rails' own notification stays
92
- # suppressed, because a worse event is not worth reporting in place
93
- # of the one we set out to report.
94
- if !Appsignal::Hooks::ActiveJobHook.instrument_enqueue_all_defined?
95
- # Nothing to instrument on a version with no bulk enqueue path, so
96
- # there is nothing to report either.
97
- Appsignal.internal_logger.debug(
98
- "Not instrumenting Active Job bulk enqueues: this version of " \
99
- "Active Job does not record them through " \
100
- "`ActiveJob.instrument_enqueue_all`."
101
- )
102
- elsif !Appsignal::Hooks::ActiveJobHook.instrument_enqueue_all_parameters_match?
103
- # A bulk enqueue path exists, but not one that can be wrapped
104
- # without breaking `ActiveJob.perform_all_later` for the whole
105
- # application. Report that at a level someone will see, because a
106
- # batch that used to be recorded no longer is.
107
- Appsignal.internal_logger.warn(
108
- Appsignal::Hooks::ActiveJobHook.instrument_enqueue_all_mismatch_message
109
- )
110
- else
111
- ::ActiveJob.singleton_class
112
- .prepend ::Appsignal::Hooks::ActiveJobHook::ActiveJobBulkEnqueueInstrumentation
113
- end
53
+ .prepend ::Appsignal::Hooks::ActiveJobHook::ActiveJobTraceContext
114
54
 
115
55
  next unless Appsignal::Hooks::ActiveJobHook.version_7_1_or_higher?
116
56
 
@@ -123,103 +63,6 @@ module Appsignal
123
63
  end
124
64
  end
125
65
 
126
- # Records an `enqueue.active_job` event when a job is enqueued, so the
127
- # enqueue shows up on the active transaction's timeline (e.g. when
128
- # enqueuing from within a web request or another job).
129
- #
130
- # Wrapping `enqueue` ourselves -- rather than relying on Rails' native
131
- # `enqueue.active_job` notification, which the AppSignal notifications
132
- # path now suppresses -- gives us a single event we own. Like all
133
- # AppSignal events, this only records when there's an active transaction;
134
- # an enqueue with no transaction is a transparent pass-through.
135
- #
136
- # @!visibility private
137
- module ActiveJobEnqueueInstrumentation
138
- def enqueue(*, **)
139
- # Skip recording the event when enqueue events are suppressed. That is
140
- # the case when enqueue instrumentation is disabled, and it keeps this
141
- # integration consistent with the standalone adapters (Sidekiq, ...),
142
- # which already gate their own enqueue event on this check.
143
- if Appsignal::Transaction.current? &&
144
- Appsignal::Transaction.current.job_enqueue_events_suppressed?
145
- return super
146
- end
147
-
148
- Appsignal.instrument("enqueue.active_job", "enqueue #{self.class.name} job") do
149
- # Active Job enqueues through an adapter (Sidekiq, Resque, ...) that
150
- # has its own enqueue instrumentation. Suppress it so the enqueue is
151
- # recorded once, as this event, rather than as nested Active Job +
152
- # adapter events.
153
- if Appsignal::Transaction.current?
154
- Appsignal::Transaction.current.suppress_job_enqueue_events { super }
155
- else
156
- super
157
- end
158
- end
159
- end
160
- end
161
-
162
- # Records an `enqueue_all.active_job` event when a batch of jobs is
163
- # enqueued with `ActiveJob.perform_all_later`, so the batch shows up on the
164
- # active transaction's timeline as one event.
165
- #
166
- # This wraps `instrument_enqueue_all` rather than `perform_all_later`, for
167
- # two reasons. It is the method that records the batch, so it is called
168
- # once for each queue adapter the batch spans, which is the same event
169
- # count as the native notification it replaces. And it runs inside
170
- # `perform_all_later`, after Active Job has split off the jobs it defers
171
- # until the database transaction commits, so each of those halves is
172
- # recorded when it is really enqueued.
173
- #
174
- # @!visibility private
175
- module ActiveJobBulkEnqueueInstrumentation
176
- private
177
-
178
- def instrument_enqueue_all(_queue_adapter, jobs)
179
- # Skip recording the event when enqueue events are suppressed, which is
180
- # also the case when enqueue instrumentation is disabled. Same check as
181
- # the single-job path above.
182
- if Appsignal::Transaction.current? &&
183
- Appsignal::Transaction.current.job_enqueue_events_suppressed?
184
- return super
185
- end
186
-
187
- Appsignal.instrument("enqueue_all.active_job", bulk_enqueue_title(jobs)) do
188
- # A bulk enqueue does not go through `ActiveJob::Base#enqueue`, so
189
- # nothing has suppressed the adapter (Sidekiq, Resque, ...) yet, and
190
- # its own enqueue instrumentation would record an event for every job
191
- # in the batch. Suppress it so the batch is recorded once, as this
192
- # event.
193
- if Appsignal::Transaction.current?
194
- Appsignal::Transaction.current.suppress_job_enqueue_events { super }
195
- else
196
- super
197
- end
198
- end
199
- end
200
-
201
- # The batch's job class, when every job in it has the same one. Active
202
- # Job groups the jobs it enqueues by queue adapter rather than by class,
203
- # so a batch can mix classes, and then there is no one class to name.
204
- def bulk_enqueue_title(jobs)
205
- job_class = shared_across(jobs) { |job| job.class.name }
206
- return "bulk enqueue jobs" unless job_class
207
-
208
- "bulk enqueue #{job_class} jobs"
209
- end
210
-
211
- # The one value every job in the batch shares, or nil when they differ
212
- # or the batch is empty. Stops at the first job that disagrees, because
213
- # a batch is as large as the caller made it and a single mismatch is
214
- # enough to know.
215
- def shared_across(jobs)
216
- return if jobs.empty?
217
-
218
- first = yield(jobs.first)
219
- jobs.all? { |job| yield(job) == first } ? first : nil
220
- end
221
- end
222
-
223
66
  module ActiveJobClassInstrumentation
224
67
  def execute(job)
225
68
  enqueued_at = job["enqueued_at"]
@@ -241,12 +84,39 @@ module Appsignal
241
84
  # We don't have a separate integration for this QueueAdapter like
242
85
  # we do for Sidekiq.
243
86
  #
87
+ # Read the trace context off the job so the transaction links back
88
+ # to the enqueuer (no-op outside collector mode). Only here, in the
89
+ # standalone branch: when a wrapper integration (e.g. Sidekiq)
90
+ # created the transaction, it already extracted, so we must not
91
+ # extract a second time.
92
+ #
244
93
  # Prefer job_id from provider, instead of ActiveJob's internal ID.
245
- Appsignal::Transaction.create(Appsignal::Transaction::BACKGROUND_JOB)
94
+ Appsignal::Transaction.create(
95
+ Appsignal::Transaction::BACKGROUND_JOB,
96
+ :opentelemetry_context => Appsignal::OpenTelemetry.extract_job_context(job),
97
+ :opentelemetry_scope => ["appsignal-ruby/active_job", Appsignal::VERSION],
98
+ :opentelemetry_kind => :consumer,
99
+ :opentelemetry_relationship => :both
100
+ )
246
101
  end
247
102
 
103
+ unless has_wrapper_transaction
104
+ # Describes this span as a job being performed. The messaging
105
+ # system is what the trace timeline reads to recognize background
106
+ # job work, and `active_job` is the value OpenTelemetry's own Active
107
+ # Job instrumentation uses.
108
+ #
109
+ # Only set when this hook created the transaction. When an adapter
110
+ # integration created it, that adapter already named itself, and its
111
+ # answer is the more specific one.
112
+ transaction.add_opentelemetry_attributes(
113
+ Appsignal::OpenTelemetry::Messaging
114
+ .perform_attributes("active_job", :destination => job["queue_name"])
115
+ )
116
+ end
117
+
248
118
  begin
249
- transaction.add_params_if_nil(job["arguments"])
119
+ transaction.add_function_parameters_if_nil(job["arguments"])
250
120
 
251
121
  transaction_tags = ActiveJobHelpers.transaction_tags_for(job)
252
122
  transaction.add_tags(transaction_tags)
@@ -302,6 +172,85 @@ module Appsignal
302
172
  end
303
173
  end
304
174
 
175
+ # Reads and writes W3C trace context on the ActiveJob enqueue/perform
176
+ # boundary, wire-compatible with OpenTelemetry's ActiveJob instrumentation.
177
+ # All of this no-ops outside collector mode.
178
+ #
179
+ # Context rides on the job under `__otel_headers`, the same carrier OTel
180
+ # uses. Stock `serialize`/`deserialize` only carry a fixed key set, so --
181
+ # like OTel -- we patch both plus an accessor to round-trip it. The on-wire
182
+ # value is run through ActiveJob's argument serializer (an array of
183
+ # `[key, value]` pairs), matching OTel byte-for-byte so an AppSignal- and an
184
+ # OTel-instrumented service read each other's jobs.
185
+ module ActiveJobTraceContext
186
+ # Inject on enqueue from inside a producer event, so the job carries this
187
+ # transaction's context and the perform later links back. Mirrors the
188
+ # Sidekiq client middleware: an AppSignal event (a producer span in
189
+ # collector mode), not a direct SDK span. `Appsignal.instrument` is a
190
+ # transparent pass-through when there's no active transaction, and
191
+ # `inject_context` no-ops outside collector mode.
192
+ def enqueue(*, **)
193
+ # When enqueue instrumentation is disabled, drop the trace context
194
+ # along with the event. Without an enqueue event there is no producer
195
+ # span, so the context we would write is that of whatever span is
196
+ # current, such as the surrounding web request. The job that performs
197
+ # later would then link back to a span that is not a producer.
198
+ return super if Appsignal.config && !Appsignal.config[:enable_job_enqueue_instrumentation]
199
+
200
+ # Another enqueue integration is already recording this enqueue, so
201
+ # don't record it a second time.
202
+ if Appsignal::Transaction.current? &&
203
+ Appsignal::Transaction.current.job_enqueue_events_suppressed?
204
+ return super
205
+ end
206
+
207
+ Appsignal.instrument(
208
+ "enqueue.active_job",
209
+ "enqueue #{self.class.name} job",
210
+ :opentelemetry_kind => :producer,
211
+ :opentelemetry_scope => ["appsignal-ruby/active_job", Appsignal::VERSION]
212
+ ) do
213
+ Appsignal::Transaction.current.add_opentelemetry_attributes(
214
+ Appsignal::OpenTelemetry::Messaging
215
+ .enqueue_attributes("active_job", :destination => queue_name)
216
+ )
217
+ Appsignal::OpenTelemetry.inject_context(__otel_headers)
218
+ # Active Job enqueues through an adapter (Sidekiq, Resque, ...) that
219
+ # has its own enqueue instrumentation. Suppress it so the enqueue is
220
+ # recorded once, as this event, rather than as nested Active Job +
221
+ # adapter events.
222
+ if Appsignal::Transaction.current?
223
+ Appsignal::Transaction.current.suppress_job_enqueue_events { super }
224
+ else
225
+ super
226
+ end
227
+ end
228
+ end
229
+
230
+ def serialize
231
+ super.tap do |data|
232
+ Appsignal::OpenTelemetry.if_started do
233
+ next if __otel_headers.empty?
234
+
235
+ data["__otel_headers"] = ::ActiveJob::Arguments.serialize(__otel_headers)
236
+ end
237
+ end
238
+ end
239
+
240
+ def deserialize(job_data)
241
+ super
242
+ serialized = job_data["__otel_headers"]
243
+ @__otel_headers =
244
+ serialized ? ::ActiveJob::Arguments.deserialize(serialized).to_h : {}
245
+ end
246
+
247
+ def __otel_headers
248
+ @__otel_headers ||= {}
249
+ end
250
+
251
+ attr_writer :__otel_headers
252
+ end
253
+
305
254
  module ActiveJobHelpers
306
255
  ACTION_MAILER_CLASSES = [
307
256
  "ActionMailer::DeliveryJob",
@@ -38,7 +38,10 @@ module Appsignal
38
38
 
39
39
  report_error = true
40
40
 
41
- Appsignal.report_error(error) do |transaction|
41
+ Appsignal.report_error(
42
+ error,
43
+ :opentelemetry_scope => ["appsignal-ruby/at_exit", Appsignal::VERSION]
44
+ ) do |transaction|
42
45
  transaction.set_namespace("unhandled")
43
46
  end
44
47
  ensure
@@ -12,15 +12,35 @@ module Appsignal
12
12
 
13
13
  def install
14
14
  require "appsignal/integrations/excon"
15
+ require "appsignal/integrations/excon/appsignal_middleware"
15
16
  # Instrument the request at the connection, rather than by registering
16
17
  # AppSignal as Excon's instrumentor. An instrumentor is told about a
17
18
  # request in pieces, none of which covers the wait for the response, and
18
19
  # there is only room for one of them, so registering ours would replace
19
20
  # any the application set up itself.
20
21
  ::Excon::Connection.prepend Appsignal::Integrations::ExconIntegration
22
+ install_middleware
21
23
 
22
24
  Appsignal::Environment.report_enabled("excon")
23
25
  end
26
+
27
+ private
28
+
29
+ # Trace context is written onto the outgoing request by a middleware,
30
+ # because that is where Excon exposes the request's headers.
31
+ #
32
+ # Insert it just before the Mock middleware, the innermost one, where the
33
+ # response is produced. That way it runs before the request is sent.
34
+ # Appending to the end would place it after Mock, which short-circuits the
35
+ # chain before reaching it.
36
+ def install_middleware
37
+ middlewares = ::Excon.defaults[:middlewares].dup
38
+ return if middlewares.include?(Appsignal::Integrations::ExconMiddleware)
39
+
40
+ index = middlewares.index(::Excon::Middleware::Mock) || middlewares.length
41
+ middlewares.insert(index, Appsignal::Integrations::ExconMiddleware)
42
+ ::Excon.defaults[:middlewares] = middlewares
43
+ end
24
44
  end
25
45
  end
26
46
  end
@@ -6,6 +6,22 @@ module Appsignal
6
6
  class FaradayHook < Appsignal::Hooks::Hook
7
7
  register :faraday
8
8
 
9
+ # This integration records the request itself, so Faraday's own
10
+ # instrumentation middleware would report the same work again as a
11
+ # `request.faraday` notification. Claim it so the generic notification
12
+ # paths leave it alone.
13
+ #
14
+ # Claimed here, when this file is required, rather than in `install`.
15
+ # `install` only runs when Faraday instrumentation is turned on, but a
16
+ # customer who turns it off does not want to see the native
17
+ # notification reported instead. This call does not touch any
18
+ # `Faraday` constant, so it is safe to run even when the library is not
19
+ # present.
20
+ Appsignal::EventFormatter.register(
21
+ "request.faraday",
22
+ Appsignal::EventFormatter::RecordedElsewhere
23
+ )
24
+
9
25
  def dependencies_present?
10
26
  defined?(::Faraday) && Appsignal.config && Appsignal.config[:instrument_faraday]
11
27
  end
@@ -32,6 +32,11 @@ module Appsignal
32
32
  if defined?(HTTP::Session)
33
33
  HTTP::Session.prepend Appsignal::Integrations::HttpIntegration::KeywordOptions
34
34
  end
35
+ # Propagate trace context onto every outgoing hop (redirects included) at
36
+ # `Client#perform`, where the live request headers are reachable. Kept
37
+ # separate from the request-boundary event above: it only injects context
38
+ # and no-ops outside collector mode.
39
+ HTTP::Client.prepend Appsignal::Integrations::HttpIntegration::ContextInjection
35
40
 
36
41
  Appsignal::Environment.report_enabled("http_rb")
37
42
  end
@@ -15,7 +15,7 @@ module Appsignal
15
15
  Resque::Job.prepend Appsignal::Integrations::ResqueIntegration
16
16
 
17
17
  # Resque enqueues through the `Resque.push` singleton method, so prepend
18
- # onto its singleton class to record the enqueue event.
18
+ # onto its singleton class to write the trace context onto outgoing jobs.
19
19
  Resque.singleton_class.prepend Appsignal::Integrations::ResquePushIntegration
20
20
  end
21
21
  end
@@ -10,8 +10,13 @@ module Appsignal
10
10
  "sql.sequel",
11
11
  nil,
12
12
  sql,
13
- Appsignal::EventFormatter::SQL_BODY_FORMAT
13
+ Appsignal::EventFormatter::SQL_BODY_FORMAT,
14
+ :opentelemetry_kind => :client,
15
+ :opentelemetry_scope => ["appsignal-ruby/sequel", Appsignal::VERSION]
14
16
  ) do
17
+ Appsignal::Transaction.current.add_opentelemetry_attributes(
18
+ Appsignal::Hooks::SequelHook.sequel_db_attributes(self)
19
+ )
15
20
  super
16
21
  end
17
22
  end
@@ -25,8 +30,13 @@ module Appsignal
25
30
  "sql.sequel",
26
31
  nil,
27
32
  sql,
28
- Appsignal::EventFormatter::SQL_BODY_FORMAT
33
+ Appsignal::EventFormatter::SQL_BODY_FORMAT,
34
+ :opentelemetry_kind => :client,
35
+ :opentelemetry_scope => ["appsignal-ruby/sequel", Appsignal::VERSION]
29
36
  ) do
37
+ Appsignal::Transaction.current.add_opentelemetry_attributes(
38
+ Appsignal::Hooks::SequelHook.sequel_db_attributes(self)
39
+ )
30
40
  super
31
41
  end
32
42
  end
@@ -35,6 +45,26 @@ module Appsignal
35
45
  class SequelHook < Appsignal::Hooks::Hook
36
46
  register :sequel
37
47
 
48
+ # The query's `Sequel::Database` names both the engine it talks to and
49
+ # the database it is connected to, neither of which the sql.sequel
50
+ # formatter can see -- it only gets the query text. Shared by both
51
+ # extensions above, whichever one a given Sequel version registers.
52
+ #
53
+ # @!visibility private
54
+ def self.sequel_db_attributes(database)
55
+ attributes = {}
56
+
57
+ name = Appsignal::OpenTelemetry::SqlDbSystem.name_for_sequel(database.database_type)
58
+ attributes["db.system.name"] = name if name
59
+
60
+ # `opts[:database]` is Sequel's own option key for the database to
61
+ # connect to, so it doubles as the database's name.
62
+ namespace = database.opts[:database].to_s
63
+ attributes["db.namespace"] = namespace unless namespace.empty?
64
+
65
+ attributes
66
+ end
67
+
38
68
  def dependencies_present?
39
69
  defined?(::Sequel::Database) &&
40
70
  Appsignal.config &&
@@ -19,9 +19,9 @@ module Appsignal
19
19
  end
20
20
 
21
21
  # Servers enqueue jobs too, so they need the client middleware that
22
- # records the enqueue event. Shoryuken only yields `configure_client`
23
- # outside the server, so register it here as well for enqueues from
24
- # within a worker.
22
+ # writes the trace context onto outgoing messages. Shoryuken only
23
+ # yields `configure_client` outside the server, so register it here as
24
+ # well for enqueues from within a worker.
25
25
  config.client_middleware do |chain|
26
26
  chain.add Appsignal::Integrations::ShoryukenClientMiddleware
27
27
  end
@@ -45,7 +45,7 @@ module Appsignal
45
45
  end
46
46
 
47
47
  # Servers enqueue jobs too, so they need the client middleware that
48
- # records the enqueue event.
48
+ # writes the trace context onto outgoing jobs.
49
49
  config.client_middleware do |chain|
50
50
  chain.add Appsignal::Integrations::SidekiqClientMiddleware
51
51
  end
@@ -11,7 +11,10 @@ module Appsignal
11
11
  request_id = request.request_id || SecureRandom.uuid
12
12
  env[Appsignal::Hooks::ActionCableHook::REQUEST_ID] ||= request_id
13
13
 
14
- transaction = Appsignal::Transaction.create(Appsignal::Transaction::ACTION_CABLE)
14
+ transaction = Appsignal::Transaction.create(
15
+ Appsignal::Transaction::ACTION_CABLE,
16
+ :opentelemetry_scope => ["appsignal-ruby/action_cable", Appsignal::VERSION]
17
+ )
15
18
 
16
19
  begin
17
20
  super
@@ -20,7 +23,7 @@ module Appsignal
20
23
  raise exception
21
24
  ensure
22
25
  transaction.set_action_if_nil("#{self.class}##{args.first["action"]}")
23
- transaction.add_params_if_nil(args.first)
26
+ transaction.add_request_payload_if_nil(args.first)
24
27
  transaction.add_session_data { request.session.to_h if request.respond_to? :session }
25
28
  transaction.set_metadata("path", request.path)
26
29
  transaction.set_metadata("method", "websocket")
@@ -7,29 +7,52 @@ module Appsignal
7
7
  class << self
8
8
  BANG = "!"
9
9
 
10
- # Events a dedicated AppSignal integration already records, so the
11
- # generic notifications path must not record them a second time. The
12
- # ActiveJob hook owns `enqueue.active_job` and `enqueue_all.active_job`.
13
- # It records its own event for a single enqueue, and one event for a
14
- # whole batch, with Rails' native notification nested inside. The
15
- # Faraday integration owns `request.faraday`.
16
- SUPPRESSED_EVENT_NAMES = [
17
- "enqueue.active_job",
18
- "enqueue_all.active_job",
19
- "request.faraday"
20
- ].freeze
21
-
22
10
  def start_event(name)
23
11
  return unless record_event?(name)
24
12
 
25
- Appsignal::Transaction.current.start_event
13
+ # The event's formatter says what kind of work the event is, such as
14
+ # a SQL query being an outgoing call to a database, and can name the
15
+ # library the instrumentation is for. Both are immutable once the
16
+ # span exists, so they have to be set here at event start.
17
+ #
18
+ # A formatter that names no library leaves the scope to be derived
19
+ # from the event name, which is right for everything Rails reports.
20
+ Appsignal::Transaction.current.start_event(
21
+ :opentelemetry_kind => Appsignal::EventFormatter.opentelemetry_kind(name),
22
+ :opentelemetry_scope =>
23
+ Appsignal::EventFormatter.opentelemetry_scope(name) || scope_for(name)
24
+ )
25
+ end
26
+
27
+ # ActiveSupport::Notifications bridges many Rails components through this
28
+ # one path (`sql.active_record`, `render_template.action_view`, ...), so
29
+ # derive the instrumentation scope from the event name's group: the part
30
+ # after the last dot. That gives each Rails component its own scope
31
+ # (`appsignal-ruby/active_record`, `appsignal-ruby/action_view`, ...)
32
+ # rather than lumping them under one. A name without a group falls back
33
+ # to the default scope in the backend.
34
+ def scope_for(name)
35
+ # Only names with a group (a dot) map to a component scope. A name
36
+ # without one has no component to attribute it to, so it falls back to
37
+ # the default scope in the backend (returning nil here).
38
+ parts = name.to_s.split(".")
39
+ return if parts.length < 2 || parts.last.empty?
40
+
41
+ ["appsignal-ruby/#{parts.last}", Appsignal::VERSION]
26
42
  end
27
43
 
28
44
  def finish_event(name, payload = {})
29
45
  return unless record_event?(name)
30
46
 
31
47
  title, body, body_format = Appsignal::EventFormatter.format(name, payload)
32
- Appsignal::Transaction.current.finish_event(
48
+ transaction = Appsignal::Transaction.current
49
+ # Set while the event's span is still open, so the attributes land on
50
+ # the event rather than on the transaction.
51
+ transaction.add_opentelemetry_attributes(
52
+ Appsignal::EventFormatter.opentelemetry_attributes(name, payload)
53
+ )
54
+ record_error_type(transaction, payload)
55
+ transaction.finish_event(
33
56
  name.to_s,
34
57
  title,
35
58
  body,
@@ -37,12 +60,29 @@ module Appsignal
37
60
  )
38
61
  end
39
62
 
40
- # Events starting with a bang are internal to Rails; suppressed events
41
- # are recorded by a dedicated integration instead. Both `start_event`
42
- # and `finish_event` gate on this so the event stack stays balanced.
63
+ # Says what kind of failure ended the event, which the OpenTelemetry
64
+ # semantic conventions ask for on a span whose operation failed.
65
+ #
66
+ # ActiveSupport puts the exception in the payload when the instrumented
67
+ # block raised, and it does so before it hands control to any of the
68
+ # paths this integration hooks into. So the failure is readable here and
69
+ # there is nothing to rescue, whether the event was reported through a
70
+ # block or through a `start` and `finish` pair.
71
+ def record_error_type(transaction, payload)
72
+ error = payload[:exception_object]
73
+ return unless error
74
+
75
+ transaction.add_opentelemetry_attributes(
76
+ Appsignal::OpenTelemetry::ErrorType.attributes_for(error.class.name)
77
+ )
78
+ end
79
+
80
+ # Events starting with a bang are internal to Rails. An event that the
81
+ # registry says a dedicated integration records is not recorded again
82
+ # here. Both `start_event` and `finish_event` gate on this so the event
83
+ # stack stays balanced.
43
84
  def record_event?(name)
44
- name = name.to_s
45
- name[0] != BANG && !SUPPRESSED_EVENT_NAMES.include?(name)
85
+ name.to_s[0] != BANG && Appsignal::EventFormatter.record?(name)
46
86
  end
47
87
  end
48
88