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
@@ -12,22 +12,34 @@ module Appsignal
12
12
  ].freeze
13
13
 
14
14
  def log(message)
15
+ attributes = {}
16
+
15
17
  # If scheme is SQL-like, try to sanitize it, otherwise clear the body
16
18
  if SQL_CLASSES.include?(self.class.to_s)
17
19
  body_content = message.query
18
20
  body_format = Appsignal::EventFormatter::SQL_BODY_FORMAT
21
+ # The connection class names the engine it talks to, one of the four
22
+ # SQL_CLASSES above; a class this map does not recognise leaves the
23
+ # SQL sentinel to apply, same as it would for a fifth SQL_CLASSES
24
+ # entry this map has not been taught about.
25
+ db_system = Appsignal::OpenTelemetry::SqlDbSystem.name_for_data_mapper(self.class.to_s)
26
+ attributes["db.system.name"] = db_system if db_system
19
27
  else
20
28
  body_content = ""
21
29
  body_format = Appsignal::EventFormatter::DEFAULT
22
30
  end
23
31
 
24
- # Record event
32
+ # Record event. The query is an outgoing call to the database, so tag it
33
+ # as a client span (collector mode); no-op in agent mode.
25
34
  Appsignal::Transaction.current.record_event(
26
35
  "query.data_mapper",
27
36
  "DataMapper Query",
28
37
  body_content,
29
38
  message.duration,
30
- body_format
39
+ body_format,
40
+ :opentelemetry_kind => :client,
41
+ :opentelemetry_scope => ["appsignal-ruby/data_mapper", Appsignal::VERSION],
42
+ :opentelemetry_attributes => attributes
31
43
  )
32
44
  super
33
45
  end
@@ -11,18 +11,6 @@ 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
-
26
14
  lifecycle.around(:invoke_job) do |job, &block|
27
15
  invoke_with_instrumentation(job, block)
28
16
  end
@@ -37,6 +25,11 @@ module Appsignal
37
25
  # another job). An enqueue with no active transaction is a transparent
38
26
  # pass-through.
39
27
  def self.enqueue_with_instrumentation(job, block)
28
+ # Skip the enqueue event when enqueue instrumentation is disabled.
29
+ if Appsignal.config && !Appsignal.config[:enable_job_enqueue_instrumentation]
30
+ return block.call(job)
31
+ end
32
+
40
33
  # Under Active Job the enqueue is already recorded as an
41
34
  # `enqueue.active_job` event, so skip recording it again here.
42
35
  if Appsignal::Transaction.current? &&
@@ -44,7 +37,20 @@ module Appsignal
44
37
  return block.call(job)
45
38
  end
46
39
 
47
- Appsignal.instrument("enqueue.delayed_job", "enqueue #{enqueue_name(job)} job") do
40
+ Appsignal.instrument(
41
+ "enqueue.delayed_job",
42
+ "enqueue #{enqueue_name(job)} job",
43
+ :opentelemetry_kind => :producer,
44
+ :opentelemetry_scope => ["appsignal-ruby/delayed_job", Appsignal::VERSION]
45
+ ) do
46
+ # Describes this span as a job being enqueued. The messaging system
47
+ # is what the trace timeline reads to recognize background job work,
48
+ # and `delayed_job` is the value OpenTelemetry's own Delayed Job
49
+ # instrumentation uses.
50
+ Appsignal::Transaction.current.add_opentelemetry_attributes(
51
+ Appsignal::OpenTelemetry::Messaging
52
+ .enqueue_attributes("delayed_job", :destination => queue_name(job))
53
+ )
48
54
  block.call(job)
49
55
  end
50
56
  end
@@ -55,6 +61,12 @@ module Appsignal
55
61
  # `enqueue Class#method job` rather than the bare `enqueue Class job`. We
56
62
  # accept that inconsistency so the enqueue and perform events stay tied to
57
63
  # the same name for the rare job that sets it.
64
+ # The queue a job is on. Not every Delayed Job backend has queues, so a
65
+ # job that does not know its queue is described without one.
66
+ def self.queue_name(job)
67
+ job.queue if job.respond_to?(:queue)
68
+ end
69
+
58
70
  def self.enqueue_name(job)
59
71
  payload = job.payload_object
60
72
  appsignal_name = extract_value(payload, :appsignal_name, nil)
@@ -63,146 +75,67 @@ module Appsignal
63
75
  job.name
64
76
  end
65
77
 
66
- # Reports a job whose payload cannot be read, and does nothing at all for
67
- # a job that loads. That is what keeps every ordinary job instrumented by
68
- # the `:invoke_job` callback alone: no transaction is opened here, so an
69
- # ordinary job's reported duration and its reported error are exactly
70
- # what they were before this callback existed.
71
- #
72
- # Reading the payload here reads it a moment before the worker would have
73
- # read it anyway. Delayed Job memoizes a payload that loads, so a job
74
- # that is fine is deserialized once either way.
75
- #
76
- # Nothing is re-raised. The worker reads the same payload immediately
77
- # after this returns, raises the same error, and fails the job as it
78
- # always has. Raising here instead would move that error out of the
79
- # worker's own handling and into the loop that reserves jobs.
80
- def self.report_job_that_cannot_be_loaded(job)
81
- job.payload_object
82
- nil
83
- rescue Exception => error
84
- warn_unreadable_payload_once(error)
85
-
86
- transaction =
87
- Appsignal::Transaction.create(Appsignal::Transaction::BACKGROUND_JOB)
88
- transaction.set_action_if_nil(action_name_without_payload(job))
89
- transaction.set_error(error)
90
- add_job_metadata(transaction, job)
91
- Appsignal::Transaction.complete_current!
92
- end
93
-
94
78
  def self.invoke_with_instrumentation(job, block)
95
79
  transaction =
96
- Appsignal::Transaction.create(Appsignal::Transaction::BACKGROUND_JOB)
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
+ )
97
90
 
98
91
  begin
99
- Appsignal.instrument("perform_job.delayed_job") do
92
+ Appsignal.instrument(
93
+ "perform_job.delayed_job",
94
+ :opentelemetry_scope => ["appsignal-ruby/delayed_job", Appsignal::VERSION]
95
+ ) do
96
+ Appsignal::Transaction.current.add_opentelemetry_attributes(
97
+ Appsignal::OpenTelemetry::Messaging
98
+ .perform_attributes("delayed_job", :destination => queue_name(job))
99
+ )
100
100
  block.call(job)
101
101
  end
102
102
  rescue Exception => error
103
103
  transaction.set_error(error)
104
104
  raise
105
105
  ensure
106
- # Delayed Job raises when a job's handler will not deserialize, and it
107
- # raises again on every further attempt to read it. Reading the payload
108
- # here without a guard therefore replaces the error the job already
109
- # failed with, and skips the rest of this block, so the transaction is
110
- # never completed and the failure is never reported.
111
- begin
112
- payload = job.payload_object
113
- if payload.respond_to? :job_data
114
- # ActiveJob
115
- job_data = payload.job_data
116
- transaction.set_action_if_nil("#{job_data["job_class"]}#perform")
117
- transaction.add_params_if_nil(job_data.fetch("arguments", {}))
118
- else
119
- # Delayed Job
120
- transaction.set_action_if_nil(action_name_from_payload(payload, job.name))
121
- transaction.add_params_if_nil(extract_value(payload, :args, {}))
122
- end
123
- rescue => error
124
- warn_unreadable_payload_once(error)
125
- transaction.set_action_if_nil(action_name_without_payload(job))
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, {}))
126
116
  end
127
117
 
128
- add_job_metadata(transaction, job)
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
+ )
129
124
 
130
- Appsignal::Transaction.complete_current!
131
- end
132
- end
125
+ transaction.set_queue_start(extract_value(job, :run_at)&.to_i&.* 1_000)
133
126
 
134
- def self.add_job_metadata(transaction, job)
135
- transaction.add_tags(
136
- :id => extract_value(job, :id, nil, true),
137
- :queue => extract_value(job, :queue),
138
- :priority => extract_value(job, :priority, 0),
139
- :attempts => extract_value(job, :attempts, 0)
140
- )
141
-
142
- transaction.set_queue_start(extract_value(job, :run_at)&.to_i&.* 1_000)
143
- end
144
-
145
- # The name Delayed Job derives from the raw handler when the payload will
146
- # not deserialize. It reads the class name out of the handler with a
147
- # regular expression, which covers the job class a deploy removed. That
148
- # expression raises for a handler it does not match, and it is skipped
149
- # altogether when the payload raised something Delayed Job does not wrap,
150
- # so fall back to a name that says the failure came from inside Delayed
151
- # Job. The failure is then reported under a name that can be found, and
152
- # every job this happens to is grouped together.
153
- def self.action_name_without_payload(job)
154
- with_perform_suffix(job.name)
155
- rescue
156
- "DelayedJobInternal"
157
- end
158
-
159
- # Guards the check-and-set below, so two threads that read an unreadable
160
- # payload at the same time cannot both warn. A constant so it is created
161
- # once at load time, because creating it lazily would race in turn.
162
- WARN_ONCE_LOCK = Mutex.new
163
-
164
- # A deploy that removes a job class leaves every job of that class unable
165
- # to deserialize, so this can be reached once per job. Each of those jobs
166
- # reports its own error to AppSignal, so the log only has to say once that
167
- # it is happening.
168
- def self.warn_unreadable_payload_once(error)
169
- should_warn = WARN_ONCE_LOCK.synchronize do
170
- next false if @warned_unreadable_payload
171
-
172
- @warned_unreadable_payload = true
127
+ Appsignal::Transaction.complete_current!
173
128
  end
174
- return unless should_warn
175
-
176
- Appsignal.internal_logger.warn(
177
- "Unable to read a Delayed Job job's payload: #{error.class}: " \
178
- "#{error.message}. Jobs whose payload cannot be read are reported " \
179
- "without parameters. They are named after the class in their raw " \
180
- "handler, or DelayedJobInternal when that cannot be read either."
181
- )
182
- end
183
-
184
- # @!visibility private
185
- #
186
- # Resets the warn-once state. Only used to keep test runs isolated.
187
- def self.reset_unreadable_payload_warning!
188
- WARN_ONCE_LOCK.synchronize { @warned_unreadable_payload = false }
189
129
  end
190
130
 
191
131
  def self.action_name_from_payload(payload, default_name)
192
132
  # Attempt to find appsignal_name override
193
133
  class_and_method_name = extract_value(payload, :appsignal_name, nil)
194
134
  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
195
137
 
196
- with_perform_suffix(default_name)
197
- end
198
-
199
- # An action name is a class and a method. A name that already names both,
200
- # separated either way, is left alone.
201
- def self.with_perform_suffix(name)
202
- return name if name.split("#").length == 2
203
- return name if name.split(".").length == 2
204
-
205
- "#{name}#perform"
138
+ "#{default_name}#perform"
206
139
  end
207
140
 
208
141
  # rubocop:disable Style/OptionalBooleanParameter
@@ -4,24 +4,48 @@ module Appsignal
4
4
  module Integrations
5
5
  # @!visibility private
6
6
  module DryMonitorIntegration
7
+ # The event's formatter says what kind of work the event is, such as ROM
8
+ # reporting a SQL query as a dry-monitor `"sql"` event, and which library
9
+ # the instrumentation is for. Both are immutable once the span exists, so
10
+ # they have to be set here at event start.
11
+ #
12
+ # dry-monitor is a notification bus, so an event arriving over it is not
13
+ # necessarily dry-monitor's own work. A formatter that knows better says
14
+ # so; anything else is attributed to dry-monitor.
7
15
  def instrument(event_id, payload = {}, &block)
8
- Appsignal::Transaction.current.start_event
9
-
10
- super
11
- ensure
12
16
  name = "#{event_id}.dry"
13
- event_name, body, body_format = Appsignal::EventFormatter.format(name, payload)
17
+ # An event a dedicated integration already records is not recorded a
18
+ # second time here.
19
+ return super unless Appsignal::EventFormatter.record?(name)
20
+
21
+ begin
22
+ Appsignal::Transaction.current.start_event(
23
+ :opentelemetry_kind => Appsignal::EventFormatter.opentelemetry_kind(name),
24
+ :opentelemetry_scope =>
25
+ Appsignal::EventFormatter.opentelemetry_scope(name) ||
26
+ ["appsignal-ruby/dry_monitor", Appsignal::VERSION]
27
+ )
28
+
29
+ super
30
+ ensure
31
+ event_name, body, body_format = Appsignal::EventFormatter.format(name, payload)
32
+ # Set while the event's span is still open, so the attributes land
33
+ # on the event rather than on the transaction.
34
+ Appsignal::Transaction.current.add_opentelemetry_attributes(
35
+ Appsignal::EventFormatter.opentelemetry_attributes(name, payload)
36
+ )
14
37
 
15
- # dry-monitor reports an event under an id, such as `sql`, rather than
16
- # a name. A formatter names the event it knows about, and an event
17
- # without one is named after its id in the dry-monitor group. Either
18
- # way the name has a group, which is what an event is listed under.
19
- Appsignal::Transaction.current.finish_event(
20
- event_name || name,
21
- nil,
22
- body,
23
- body_format
24
- )
38
+ # dry-monitor reports an event under an id, such as `sql`, rather than
39
+ # a name. A formatter names the event it knows about, and an event
40
+ # without one is named after its id in the dry-monitor group. Either
41
+ # way the name has a group, which is what an event is listed under.
42
+ Appsignal::Transaction.current.finish_event(
43
+ event_name || name,
44
+ nil,
45
+ body,
46
+ body_format
47
+ )
48
+ end
25
49
  end
26
50
  end
27
51
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Appsignal
4
+ module Integrations
5
+ # Excon middleware that writes trace context onto the outgoing request, so
6
+ # the called service joins this trace. The integration on the connection
7
+ # records the span; this middleware only injects.
8
+ #
9
+ # @!visibility private
10
+ class ExconMiddleware < ::Excon::Middleware::Base
11
+ def request_call(datum)
12
+ datum[:headers] ||= {}
13
+ # Inject from whatever span is current. The connection's client span is
14
+ # open around the whole request, so the written `traceparent` reflects
15
+ # the Excon client event. No-op outside collector mode.
16
+ Appsignal::OpenTelemetry.inject_context(datum[:headers])
17
+ super
18
+ end
19
+ end
20
+ end
21
+ end
@@ -4,16 +4,20 @@ module Appsignal
4
4
  module Integrations
5
5
  # @!visibility private
6
6
  module ExconIntegration
7
+ # The method of a request. Excon defaults it to GET further down, so the
8
+ # same default is applied here.
9
+ def self.method_for(datum)
10
+ datum[:method] || :get
11
+ end
12
+
7
13
  # The title of the event, built the way the Net::HTTP integration builds
8
14
  # its own: the request method and where the request went, without the
9
15
  # path, so paths stay out of event titles.
10
16
  #
11
17
  # Excon splits a request's data between the connection it is made on and
12
- # the call that makes it, so both are read to build this. Excon defaults
13
- # the method to GET itself, so the same default is applied here.
18
+ # the call that makes it, so both are read to build this.
14
19
  def self.title_for(datum)
15
- method = (datum[:method] || :get).to_s.upcase
16
- "#{method} #{datum[:scheme]}://#{datum[:host]}"
20
+ "#{method_for(datum).to_s.upcase} #{datum[:scheme]}://#{datum[:host]}"
17
21
  end
18
22
 
19
23
  def request(params = {})
@@ -38,18 +42,51 @@ module Appsignal
38
42
  # A pipelined request is the exception to this method being the whole of
39
43
  # a request. It returns before the response is read, so its event covers
40
44
  # only the sending.
41
- title = ExconIntegration.title_for(data.merge(params))
45
+ datum = data.merge(params)
46
+
47
+ Appsignal.instrument(
48
+ "request.excon",
49
+ ExconIntegration.title_for(datum),
50
+ :opentelemetry_kind => :client,
51
+ :opentelemetry_scope => ["appsignal-ruby/excon", Appsignal::VERSION]
52
+ ) do
53
+ # Describes the span as an outgoing HTTP request. Together with the
54
+ # CLIENT kind, this is what the trace timeline reads to recognize it
55
+ # as one.
56
+ Appsignal::Transaction.current.add_opentelemetry_attributes(
57
+ Appsignal::OpenTelemetry::HttpClientRequest.attributes_for(
58
+ :method => ExconIntegration.method_for(datum),
59
+ :scheme => datum[:scheme],
60
+ :host => datum[:host],
61
+ :port => datum[:port],
62
+ :path => datum[:path]
63
+ )
64
+ )
65
+
66
+ response =
67
+ if Appsignal::Transaction.current?
68
+ # Excon retries a request, and follows a redirect, by calling this
69
+ # method again from inside the request it is retrying or
70
+ # following. Suppressing those means they count towards this event
71
+ # rather than becoming events of their own, so one request stays
72
+ # one event.
73
+ Appsignal::Transaction.current.suppress_http_client_events { super }
74
+ else
75
+ super
76
+ end
42
77
 
43
- Appsignal.instrument("request.excon", title) do
44
- if Appsignal::Transaction.current?
45
- # Excon retries a request, and follows a redirect, by calling this
46
- # method again from inside the request it is retrying or following.
47
- # Suppressing those means they count towards this event rather than
48
- # becoming events of their own, so one request stays one event.
49
- Appsignal::Transaction.current.suppress_http_client_events { super }
50
- else
51
- super
52
- end
78
+ # Describes the response on the same span as the request, which the
79
+ # semantic conventions ask for whenever one was received.
80
+ #
81
+ # A pipelined request returns the request data rather than a response,
82
+ # because its response has not been read yet, so there is no status to
83
+ # report for it.
84
+ Appsignal::Transaction.current.add_opentelemetry_attributes(
85
+ Appsignal::OpenTelemetry::HttpResponse.attributes_for(
86
+ response.respond_to?(:status) ? response.status : nil
87
+ )
88
+ )
89
+ response
53
90
  end
54
91
  end
55
92
  end
@@ -2,10 +2,11 @@
2
2
 
3
3
  module Appsignal
4
4
  module Integrations
5
- # Faraday middleware that records each request as a `request.faraday` event
6
- # and suppresses the downstream HTTP client's own instrumentation, so the
7
- # request is recorded once rather than as nested Faraday + Net::HTTP (or
8
- # Excon) client events.
5
+ # Faraday middleware that records each request as a `request.faraday` client
6
+ # event, writes trace context onto the outgoing request so the called service
7
+ # joins this trace, and suppresses the downstream HTTP client's own
8
+ # instrumentation, so the request is recorded once rather than as nested
9
+ # Faraday + Net::HTTP (or Excon) client events.
9
10
  #
10
11
  # @!visibility private
11
12
  class FaradayMiddleware < ::Faraday::Middleware
@@ -16,17 +17,50 @@ module Appsignal
16
17
  # Net::HTTP's (scheme and host only), keeping paths out of event titles.
17
18
  Appsignal.instrument(
18
19
  "request.faraday",
19
- "#{http_method} #{uri.scheme}://#{uri.host}"
20
+ "#{http_method} #{uri.scheme}://#{uri.host}",
21
+ :opentelemetry_kind => :client,
22
+ :opentelemetry_scope => ["appsignal-ruby/faraday", Appsignal::VERSION]
20
23
  ) do
24
+ # Describes the span as an outgoing HTTP request. Together with the
25
+ # CLIENT kind, this is what the trace timeline reads to recognize it
26
+ # as one.
27
+ Appsignal::Transaction.current.add_opentelemetry_attributes(
28
+ Appsignal::OpenTelemetry::HttpClientRequest.attributes_for(
29
+ :method => env[:method],
30
+ :scheme => uri.scheme,
31
+ :host => uri.host,
32
+ :port => uri.port,
33
+ :path => uri.path
34
+ )
35
+ )
36
+ # Write trace context onto the outgoing request so the called service
37
+ # joins this trace. Injected inside the instrument block, so the written
38
+ # `traceparent` reflects the Faraday client event's span. No-op outside
39
+ # collector mode. `env.request_headers` is the live outgoing header set
40
+ # and a valid carrier (it responds to `[]=`).
41
+ Appsignal::OpenTelemetry.inject_context(env.request_headers)
42
+
21
43
  # Faraday's default adapter is Net::HTTP, which AppSignal also
22
44
  # instruments. Suppress the adapter's own instrumentation so the
23
45
  # request appears once (as the Faraday event) rather than as nested
24
46
  # Faraday + Net::HTTP client events.
25
- if Appsignal::Transaction.current?
26
- Appsignal::Transaction.current.suppress_http_client_events { @app.call(env) }
27
- else
28
- @app.call(env)
29
- end
47
+ response =
48
+ if Appsignal::Transaction.current?
49
+ Appsignal::Transaction.current.suppress_http_client_events { @app.call(env) }
50
+ else
51
+ @app.call(env)
52
+ end
53
+
54
+ # Describes the response on the same span, which the semantic
55
+ # conventions ask for whenever one was received. The event is still
56
+ # open here, so it lands on the request's own span. The status is read
57
+ # off the environment rather than the returned response, because an
58
+ # adapter fills the response in later than it fills in the environment.
59
+ Appsignal::Transaction.current.add_opentelemetry_attributes(
60
+ Appsignal::OpenTelemetry::HttpResponse.attributes_for(env[:status])
61
+ )
62
+
63
+ response
30
64
  end
31
65
  end
32
66
  end
@@ -37,8 +71,9 @@ module Appsignal
37
71
  # the build path is the only way to instrument every connection automatically.
38
72
  #
39
73
  # Just before the adapter (the innermost handler, where the request is sent)
40
- # it inserts `FaradayMiddleware`, which records the `request.faraday` event
41
- # and suppresses the downstream client. Skipped if it's already present.
74
+ # it inserts `FaradayMiddleware`, which records the `request.faraday` event,
75
+ # injects trace context, and suppresses the downstream client. Skipped if it's
76
+ # already present.
42
77
  #
43
78
  # @!visibility private
44
79
  module FaradayRackBuilderPatch
@@ -9,7 +9,34 @@ module Appsignal
9
9
  parsed_request_uri = uri.is_a?(URI) ? uri : uri_module.parse(uri.to_s)
10
10
  request_uri = "#{parsed_request_uri.scheme}://#{parsed_request_uri.host}"
11
11
 
12
- Appsignal.instrument("request.http_rb", "#{verb.upcase} #{request_uri}", &block)
12
+ Appsignal.instrument(
13
+ "request.http_rb",
14
+ "#{verb.to_s.upcase} #{request_uri}",
15
+ :opentelemetry_kind => :client,
16
+ :opentelemetry_scope => ["appsignal-ruby/http_rb", Appsignal::VERSION]
17
+ ) do
18
+ # Describes the span as an outgoing HTTP request. Together with the
19
+ # CLIENT kind, this is what the trace timeline reads to recognize it
20
+ # as one.
21
+ Appsignal::Transaction.current.add_opentelemetry_attributes(
22
+ Appsignal::OpenTelemetry::HttpClientRequest.attributes_for(
23
+ :method => verb,
24
+ :scheme => parsed_request_uri.scheme,
25
+ :host => parsed_request_uri.host,
26
+ :port => parsed_request_uri.port,
27
+ :path => parsed_request_uri.path
28
+ )
29
+ )
30
+ # Describes the response on the same span, which the semantic
31
+ # conventions ask for whenever one was received. The event is still
32
+ # open here, so it lands on the request's own span. A request that
33
+ # followed redirects reports the status of the response it ended on.
34
+ block.call.tap do |response|
35
+ Appsignal::Transaction.current.add_opentelemetry_attributes(
36
+ Appsignal::OpenTelemetry::HttpResponse.attributes_for(response&.code)
37
+ )
38
+ end
39
+ end
13
40
  end
14
41
 
15
42
  # The event is recorded at the request boundary, so a redirected request
@@ -32,6 +59,21 @@ module Appsignal
32
59
  HttpIntegration.instrument(verb, uri) { super }
33
60
  end
34
61
  end
62
+
63
+ # Trace context has to ride on each outgoing hop's headers, so it's
64
+ # injected at `HTTP::Client#perform` -- the single send chokepoint in both
65
+ # http5 and http6, called once per request and once per redirect hop --
66
+ # where the live request headers are reachable. The event stays at the
67
+ # request boundary above, so a redirected request is still a single event;
68
+ # this only propagates context, and every hop carries it. No-op outside
69
+ # collector mode. `req.headers` is the live outgoing header set and a valid
70
+ # carrier (it responds to `[]=`).
71
+ module ContextInjection
72
+ def perform(req, options)
73
+ Appsignal::OpenTelemetry.inject_context(req.headers)
74
+ super
75
+ end
76
+ end
35
77
  end
36
78
  end
37
79
  end