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
@@ -13,8 +13,10 @@ module Appsignal
13
13
  config.appsignal = ActiveSupport::OrderedOptions.new
14
14
  config.appsignal.start_at = :on_load
15
15
 
16
- # Run after the Rails framework is loaded
17
- initializer "appsignal.configure_rails_initialization" do |app|
16
+ # Runs before `load_config_initializers` so that AppSignal has started
17
+ # when the app's initializers run, and errors they raise are reported.
18
+ initializer "appsignal.configure_rails_initialization",
19
+ :before => :load_config_initializers do |app|
18
20
  Appsignal::Integrations::Railtie.on_load(app)
19
21
  end
20
22
 
@@ -7,12 +7,14 @@ module Appsignal
7
7
  def perform
8
8
  # Read trace context off the job so the transaction links back to the
9
9
  # enqueuer. No-op outside collector mode.
10
+ job_data = ResqueHelpers.active_job_data(payload)
10
11
  transaction = Appsignal::Transaction.create(
11
12
  Appsignal::Transaction::BACKGROUND_JOB,
12
- :opentelemetry_context => Appsignal::OpenTelemetry.extract_job_context(payload),
13
+ :opentelemetry_context => ResqueHelpers.extract_context(payload, job_data),
13
14
  :opentelemetry_scope => ["appsignal-ruby/resque", Appsignal::VERSION],
14
15
  :opentelemetry_kind => :consumer,
15
- :opentelemetry_relationship => :both
16
+ :opentelemetry_relationship =>
17
+ Appsignal::OpenTelemetry.active_job_relationship(job_data)
16
18
  )
17
19
  # Describes this span as a job being performed. The messaging system is
18
20
  # what the trace timeline reads to recognize background job work, and
@@ -93,14 +95,35 @@ module Appsignal
93
95
 
94
96
  # @!visibility private
95
97
  class ResqueHelpers
98
+ # The class the Active Job adapter enqueues, with the serialized job data
99
+ # as its only argument.
100
+ ACTIVE_JOB_WRAPPER = "ActiveJob::QueueAdapters::ResqueAdapter::JobWrapper"
101
+
96
102
  def self.arguments(payload)
97
103
  case payload["class"]
98
- when "ActiveJob::QueueAdapters::ResqueAdapter::JobWrapper"
104
+ when ACTIVE_JOB_WRAPPER
99
105
  nil # Set in the ActiveJob integration
100
106
  else
101
107
  payload["args"]
102
108
  end
103
109
  end
110
+
111
+ # The serialized Active Job job data inside a Resque job, or nil when this
112
+ # is not an Active Job job.
113
+ def self.active_job_data(payload)
114
+ return unless payload["class"] == ACTIVE_JOB_WRAPPER
115
+
116
+ job_data = payload["args"]&.first
117
+ job_data if job_data.is_a?(Hash)
118
+ end
119
+
120
+ # The trace context to continue: the Active Job layer when this is an
121
+ # Active Job job, the Resque job itself otherwise. See `Appsignal::OpenTelemetry.extract_active_job_context`
122
+ # for why that layer wins.
123
+ def self.extract_context(payload, job_data)
124
+ Appsignal::OpenTelemetry.extract_active_job_context(job_data) ||
125
+ Appsignal::OpenTelemetry.extract_job_context(payload)
126
+ end
104
127
  end
105
128
  end
106
129
  end
@@ -77,14 +77,18 @@ module Appsignal
77
77
  # links back to the enqueuer. A batch carries messages from multiple
78
78
  # traces with no single parent, so only single messages link back.
79
79
  # No-op outside collector mode.
80
- context = ShoryukenTraceContext.extract(sqs_msg.message_attributes) unless batch
80
+ context = extract_context(sqs_msg, body) unless batch
81
81
 
82
82
  transaction = Appsignal::Transaction.create(
83
83
  Appsignal::Transaction::BACKGROUND_JOB,
84
84
  :opentelemetry_context => context,
85
85
  :opentelemetry_scope => ["appsignal-ruby/shoryuken", Appsignal::VERSION],
86
86
  :opentelemetry_kind => :consumer,
87
- :opentelemetry_relationship => :both
87
+ # A message batch is a batch on the receiving side, and carries no
88
+ # context to relate to at all, so the body it reads here is the single
89
+ # message's job data or nothing.
90
+ :opentelemetry_relationship =>
91
+ Appsignal::OpenTelemetry.active_job_relationship(batch ? nil : body)
88
92
  )
89
93
  # Describes this span as a job being performed. The messaging system is
90
94
  # what the trace timeline reads to recognize background job work.
@@ -127,6 +131,21 @@ module Appsignal
127
131
 
128
132
  private
129
133
 
134
+ # The trace context to continue: the Active Job layer when this is an
135
+ # Active Job job, the message's own attributes otherwise. See `Appsignal::OpenTelemetry.extract_active_job_context`
136
+ # for why that layer wins.
137
+ # SQS allows ten message attributes per message, shared with whatever the
138
+ # user puts there.
139
+ #
140
+ # The Active Job adapter registers a worker that parses the message body as
141
+ # JSON, so an Active Job job's body arrives here as its serialized job
142
+ # data. Nothing checks that it is one: a body with no readable trace
143
+ # context in it reads as "nothing here" on its own.
144
+ def extract_context(sqs_msg, body)
145
+ Appsignal::OpenTelemetry.extract_active_job_context(body) ||
146
+ ShoryukenTraceContext.extract(sqs_msg.message_attributes)
147
+ end
148
+
130
149
  def fetch_attributes(batch, sqs_msg)
131
150
  if batch
132
151
  # We can't instrument batched message separately, the `yield` will
@@ -176,12 +176,14 @@ module Appsignal
176
176
  action_name = formatted_action_name(item)
177
177
  # Read trace context off the job so the transaction links back to the
178
178
  # enqueuer. No-op outside collector mode.
179
+ job_data = active_job_data(item)
179
180
  transaction = Appsignal::Transaction.create(
180
181
  Appsignal::Transaction::BACKGROUND_JOB,
181
- :opentelemetry_context => Appsignal::OpenTelemetry.extract_job_context(item),
182
+ :opentelemetry_context => extract_context(item, job_data),
182
183
  :opentelemetry_scope => ["appsignal-ruby/sidekiq", Appsignal::VERSION],
183
184
  :opentelemetry_kind => :consumer,
184
- :opentelemetry_relationship => :both
185
+ :opentelemetry_relationship =>
186
+ Appsignal::OpenTelemetry.active_job_relationship(job_data)
185
187
  )
186
188
  transaction.add_opentelemetry_attributes(
187
189
  Appsignal::OpenTelemetry::Messaging
@@ -246,6 +248,25 @@ module Appsignal
246
248
 
247
249
  private
248
250
 
251
+ # The trace context to continue: the Active Job layer when this is an
252
+ # Active Job job, the Sidekiq job itself otherwise. See `Appsignal::OpenTelemetry.extract_active_job_context`
253
+ # for why that layer wins.
254
+ def extract_context(item, job_data)
255
+ Appsignal::OpenTelemetry.extract_active_job_context(job_data) ||
256
+ Appsignal::OpenTelemetry.extract_job_context(item)
257
+ end
258
+
259
+ # The serialized Active Job job data inside a Sidekiq job, or nil when this
260
+ # is not an Active Job job. Both Active Job adapters for Sidekiq, the one in
261
+ # Rails and the one in the Sidekiq gem, enqueue a wrapper class with the
262
+ # job data as its only argument and name the real job class in `wrapped`.
263
+ def active_job_data(item)
264
+ return unless item["wrapped"]
265
+
266
+ job_data = item["args"]&.first
267
+ job_data if job_data.is_a?(Hash)
268
+ end
269
+
249
270
  def increment_counter(key, value, tags = {})
250
271
  Appsignal.increment_counter "sidekiq_#{key}", value, tags
251
272
  end
@@ -30,22 +30,30 @@ module Appsignal
30
30
  # Set here, where the transaction is created, so they land on the
31
31
  # transaction's own span rather than on the event started below.
32
32
  #
33
- # Webmachine isn't Rack: the path, scheme and query string come off the
34
- # request's `URI` rather than from Rack's readers. The request's own
35
- # `query` is the parsed form, so the URI is where the string itself is.
33
+ # Webmachine isn't Rack: the path, scheme, query string, host and port
34
+ # come off the request's `URI` rather than from Rack's readers. The
35
+ # request's own `query` is the parsed form, so the URI is where the
36
+ # string itself is. Webmachine has no environment to read the protocol
37
+ # version from, so that one is left undescribed.
36
38
  transaction.add_opentelemetry_attributes(
37
39
  Appsignal::OpenTelemetry::HttpServerRequest.attributes_for(
38
40
  :method => request.method,
39
41
  :path => request.uri&.path,
40
42
  :scheme => request.uri&.scheme,
41
- :query => request.uri&.query
43
+ :query => request.uri&.query,
44
+ :host => request.uri&.host,
45
+ :port => request.uri&.port
42
46
  )
43
47
  )
44
48
  end
45
49
 
46
50
  begin
47
51
  transaction.add_query_parameters_if_nil { request.query }
48
- transaction.add_headers_if_nil { request.headers if request.respond_to?(:headers) }
52
+ # `Webmachine::Headers` names a header the way OpenTelemetry does, in
53
+ # lowercase and with dashes, so these are headers and nothing else.
54
+ transaction.add_request_headers_if_nil do
55
+ request.headers if request.respond_to?(:headers)
56
+ end
49
57
 
50
58
  Appsignal.instrument(
51
59
  "process_action.webmachine",
@@ -21,6 +21,15 @@ module Appsignal
21
21
  # collector filters it with the `filter_request_query_parameters` option, and
22
22
  # builds the request's query parameters out of it.
23
23
  #
24
+ # The host and the port describe where the client addressed the request. The
25
+ # conventions ask for the host the client used, so a caller reads it through
26
+ # the `Forwarded` and `X-Forwarded-Host` headers before falling back to the
27
+ # `Host` header and then to the server's own name. That is the order
28
+ # `Rack::Request#hostname` already follows. The port is only reported
29
+ # alongside a host, which is the condition the conventions put on it.
30
+ #
31
+ # The protocol version is the version out of `HTTP/1.1`, without the name.
32
+ #
24
33
  # Every value is optional, because reading any of them from the request can
25
34
  # fail. An attribute we have no value for is left out rather than sent
26
35
  # empty.
@@ -28,17 +37,42 @@ module Appsignal
28
37
  PATH_ATTRIBUTE = "url.path"
29
38
  SCHEME_ATTRIBUTE = "url.scheme"
30
39
  QUERY_ATTRIBUTE = "url.query"
40
+ ADDRESS_ATTRIBUTE = "server.address"
41
+ PORT_ATTRIBUTE = "server.port"
42
+ PROTOCOL_VERSION_ATTRIBUTE = "network.protocol.version"
31
43
 
32
44
  class << self
33
45
  # The attributes describing the given request, as a Hash to pass to
34
46
  # `add_opentelemetry_attributes`.
35
- def attributes_for(method:, path: nil, scheme: nil, query: nil)
47
+ def attributes_for( # rubocop:disable Metrics/ParameterLists
48
+ method:, path: nil, scheme: nil, query: nil, host: nil, port: nil, protocol: nil
49
+ )
36
50
  attributes = HttpMethod.attributes_for(method)
37
51
  attributes[PATH_ATTRIBUTE] = path.to_s unless path.to_s.empty?
38
52
  attributes[SCHEME_ATTRIBUTE] = scheme.to_s unless scheme.to_s.empty?
39
53
  attributes[QUERY_ATTRIBUTE] = query.to_s unless query.to_s.empty?
54
+
55
+ unless host.to_s.empty?
56
+ attributes[ADDRESS_ATTRIBUTE] = host.to_s
57
+ port = Integer(port, :exception => false)
58
+ attributes[PORT_ATTRIBUTE] = port if port
59
+ end
60
+
61
+ version = protocol_version_for(protocol)
62
+ attributes[PROTOCOL_VERSION_ATTRIBUTE] = version if version
63
+
40
64
  attributes
41
65
  end
66
+
67
+ private
68
+
69
+ # `network.protocol.name` is asked for alongside the version only when
70
+ # the protocol is not HTTP. A value shaped any other way than `HTTP/x`
71
+ # is one we cannot name, so it is left out entirely rather than sent as
72
+ # a version with no name to go with it.
73
+ def protocol_version_for(protocol)
74
+ protocol.to_s[%r{\AHTTP/(.+)\z}, 1]
75
+ end
42
76
  end
43
77
  end
44
78
  end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Appsignal
6
+ module OpenTelemetry
7
+ # @!visibility private
8
+ #
9
+ # Routes an OTLP exporter's requests through the proxy in the
10
+ # `http_proxy` config option.
11
+ #
12
+ # The exporters accept no proxy and expose no way to reach the connection
13
+ # they build. They do build it in one method, `http_connection`, which
14
+ # this module overrides to configure the connection it returns.
15
+ #
16
+ # This module is included into a subclass rather than prepended onto the
17
+ # exporter itself, so that an application using the OpenTelemetry gems for
18
+ # its own exporting is unaffected.
19
+ #
20
+ # That method is not part of the exporters' public API, so a later version
21
+ # can rename it, stop calling it, or return something other than a
22
+ # `Net::HTTP` from it. This module does not try to predict which of those
23
+ # happened by inspecting the method. It records whether it managed to
24
+ # configure a connection, and {#initialize} reports it when it did not, so
25
+ # a version we cannot proxy through is visible in the log rather than
26
+ # silently sending around the proxy.
27
+ module ProxiedExporter
28
+ def initialize(appsignal_http_proxy:, **kwargs)
29
+ @appsignal_http_proxy = appsignal_http_proxy
30
+ @appsignal_proxy_applied = false
31
+
32
+ # The exporters build their connection while they initialize, so by
33
+ # the time this returns the override below has either run or never
34
+ # will.
35
+ super(**kwargs)
36
+
37
+ return if @appsignal_proxy_applied
38
+
39
+ Appsignal.internal_logger.error(
40
+ "Not sending #{self.class.superclass} data through the proxy in " \
41
+ "the `http_proxy` option: this version of the OpenTelemetry " \
42
+ "exporters does not build its connection where AppSignal " \
43
+ "configures the proxy."
44
+ )
45
+ end
46
+
47
+ # Whether the proxy was applied to the connection this exporter sends
48
+ # through. False means the exporter sends straight to its endpoint.
49
+ def appsignal_proxy_applied?
50
+ @appsignal_proxy_applied
51
+ end
52
+
53
+ private
54
+
55
+ # Accepts and forwards whatever the exporter calls this with, because
56
+ # none of the arguments are read here. A version that adds an argument,
57
+ # positional or keyword, is passed straight through instead of raising
58
+ # on the way to `super`.
59
+ def http_connection(*args, **kwargs)
60
+ apply_appsignal_proxy(super)
61
+ end
62
+
63
+ # Point a connection at the proxy. Returns the connection either way, so
64
+ # an exporter whose connection cannot be proxied still sends its data.
65
+ def apply_appsignal_proxy(http)
66
+ return http unless http.respond_to?(:proxy_from_env=)
67
+
68
+ proxy = URI.parse(@appsignal_http_proxy)
69
+
70
+ # `Net::HTTP#proxy?` reads the address only when the connection is not
71
+ # taking its proxy from the environment, which it does by default.
72
+ http.proxy_from_env = false
73
+ http.proxy_address = proxy.host
74
+ http.proxy_port = proxy.port
75
+ http.proxy_user = proxy.user
76
+ http.proxy_pass = proxy.password
77
+
78
+ @appsignal_proxy_applied = true
79
+ http
80
+ end
81
+ end
82
+ end
83
+ end
@@ -8,12 +8,18 @@ require "appsignal/opentelemetry/http_method"
8
8
  require "appsignal/opentelemetry/http_response"
9
9
  require "appsignal/opentelemetry/http_server_request"
10
10
  require "appsignal/opentelemetry/messaging"
11
+ require "appsignal/opentelemetry/proxied_exporter"
11
12
  require "appsignal/opentelemetry/rendering"
12
13
  require "appsignal/opentelemetry/sql_db_system"
13
14
 
14
15
  module Appsignal
15
16
  # @!visibility private
16
17
  module OpenTelemetry
18
+ # The carrier key that marks an Active Job job as one of a batch. Not a W3C
19
+ # trace context header, and deliberately not named like one, so no
20
+ # propagator reads it as one.
21
+ ACTIVE_JOB_BATCH_HEADER = "appsignal-batch"
22
+
17
23
  class << self
18
24
  # Configure the global OpenTelemetry SDK to export OTLP/HTTP protobuf to
19
25
  # the collector endpoint in `config[:collector_endpoint]`.
@@ -55,14 +61,16 @@ module Appsignal
55
61
  # resource for the tracer provider to keep all three in sync.
56
62
  resource = ::OpenTelemetry::SDK::Resources::Resource.default.merge(build_resource(config))
57
63
 
64
+ span_exporter = build_exporter(
65
+ ::OpenTelemetry::Exporter::OTLP::Exporter,
66
+ config,
67
+ :endpoint => "#{endpoint}/v1/traces"
68
+ )
69
+
58
70
  ::OpenTelemetry::SDK.configure do |c|
59
71
  c.resource = resource
60
72
  c.add_span_processor(
61
- ::OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
62
- ::OpenTelemetry::Exporter::OTLP::Exporter.new(
63
- :endpoint => "#{endpoint}/v1/traces"
64
- )
65
- )
73
+ ::OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(span_exporter)
66
74
  )
67
75
  end
68
76
 
@@ -72,22 +80,26 @@ module Appsignal
72
80
  # `force_flush` is a no-op.
73
81
  ::OpenTelemetry.meter_provider =
74
82
  ::OpenTelemetry::SDK::Metrics::MeterProvider.new(:resource => resource)
83
+ metrics_exporter = build_exporter(
84
+ ::OpenTelemetry::Exporter::OTLP::Metrics::MetricsExporter,
85
+ config,
86
+ :endpoint => "#{endpoint}/v1/metrics"
87
+ )
75
88
  ::OpenTelemetry.meter_provider.add_metric_reader(
76
89
  ::OpenTelemetry::SDK::Metrics::Export::PeriodicMetricReader.new(
77
- :exporter => ::OpenTelemetry::Exporter::OTLP::Metrics::MetricsExporter.new(
78
- :endpoint => "#{endpoint}/v1/metrics"
79
- )
90
+ :exporter => metrics_exporter
80
91
  )
81
92
  )
82
93
 
94
+ logs_exporter = build_exporter(
95
+ ::OpenTelemetry::Exporter::OTLP::Logs::LogsExporter,
96
+ config,
97
+ :endpoint => "#{endpoint}/v1/logs"
98
+ )
83
99
  ::OpenTelemetry.logger_provider =
84
100
  ::OpenTelemetry::SDK::Logs::LoggerProvider.new(:resource => resource)
85
101
  ::OpenTelemetry.logger_provider.add_log_record_processor(
86
- ::OpenTelemetry::SDK::Logs::Export::BatchLogRecordProcessor.new(
87
- ::OpenTelemetry::Exporter::OTLP::Logs::LogsExporter.new(
88
- :endpoint => "#{endpoint}/v1/logs"
89
- )
90
- )
102
+ ::OpenTelemetry::SDK::Logs::Export::BatchLogRecordProcessor.new(logs_exporter)
91
103
  )
92
104
 
93
105
  @started = true
@@ -159,9 +171,8 @@ module Appsignal
159
171
  def extract_job_context(item)
160
172
  if_started do
161
173
  carrier = item
162
- nested = item["__otel_headers"]
163
- nested = nested.to_h if otel_header_pairs?(nested)
164
- carrier = item.merge(nested) if nested.is_a?(Hash)
174
+ nested = otel_headers_hash(item["__otel_headers"])
175
+ carrier = item.merge(nested) if nested
165
176
  # Extract onto an empty context rather than the default
166
177
  # `Context.current`, for the same reason as `extract_rack_context`:
167
178
  # a job with no injected trace context must not inherit an ambient
@@ -174,6 +185,107 @@ module Appsignal
174
185
  end
175
186
  end
176
187
 
188
+ # Read the trace context off the serialized Active Job job data that a
189
+ # queue adapter's job wraps, so the transaction the adapter creates links
190
+ # back to the enqueuer.
191
+ #
192
+ # Every adapter wraps the job data as the single argument of its own job
193
+ # wrapper, but each keeps it somewhere different, so finding the job data
194
+ # is the adapter integration's business and reading a context out of it is
195
+ # this method's.
196
+ #
197
+ # This is the layer every integration prefers. Active Job owns the job
198
+ # whichever adapter carries it, its carrier survives an adapter that has
199
+ # nowhere of its own to put a header, and it does not compete with the
200
+ # user's own data for a carrier with a hard limit on what fits.
201
+ #
202
+ # Returns `nil` when the SDK has not booted, when this is not Active Job
203
+ # job data, and when the job data carries no usable context. A caller reads
204
+ # that `nil` as "nothing here" and falls back to its own native carrier.
205
+ # That fallback matters twice over: it is where a job enqueued by a service
206
+ # that instruments only the adapter carries its context, and it is the only
207
+ # carrier a job that is not an Active Job job has at all.
208
+ def extract_active_job_context(job_data)
209
+ return unless job_data.is_a?(Hash)
210
+
211
+ if_started do
212
+ headers = otel_headers_hash(job_data["__otel_headers"])
213
+ next unless headers
214
+
215
+ # Extract onto an empty context, for the same reason as
216
+ # `extract_job_context` above.
217
+ context = ::OpenTelemetry.propagation.extract(
218
+ headers,
219
+ :context => ::OpenTelemetry::Context.empty
220
+ )
221
+ context if remote_span_context(context)
222
+ end
223
+ end
224
+
225
+ # Marks an outgoing Active Job carrier as belonging to a batch, so the
226
+ # integration that later performs the job can tell the two enqueue paths
227
+ # apart. Every job in a batch shares the one producer span, and a span can
228
+ # have only one parent, so a batch has to link back rather than parent
229
+ # under it -- and only the enqueue side knows it was a batch.
230
+ #
231
+ # The marker rides in the same carrier as the trace context.
232
+ # `propagation.extract` ignores a key it does not recognise, so it is
233
+ # invisible to every other reader of that carrier, including
234
+ # OpenTelemetry's own Active Job instrumentation.
235
+ #
236
+ # Does nothing to a carrier nothing was injected into. Without a context
237
+ # there is no producer span to link back to, so the marker would have
238
+ # nothing to say.
239
+ def mark_active_job_batch(headers)
240
+ return if headers.empty?
241
+
242
+ headers[ACTIVE_JOB_BATCH_HEADER] = "1"
243
+ end
244
+
245
+ # Whether Active Job job data says the job was enqueued as part of a batch.
246
+ # An integration reads this to choose between linking the performed job
247
+ # back to the enqueuer and also parenting it under them.
248
+ def active_job_batch?(job_data)
249
+ return false unless job_data.is_a?(Hash)
250
+
251
+ headers = otel_headers_hash(job_data["__otel_headers"])
252
+ return false unless headers
253
+
254
+ headers[ACTIVE_JOB_BATCH_HEADER] == "1"
255
+ end
256
+
257
+ # How a performed job should relate to the span that enqueued it.
258
+ #
259
+ # A job enqueued on its own is the only job its producer span produced, so
260
+ # it can be a child of that span as well as link to it. Every job in a
261
+ # batch shares one producer span, and a span can have only one parent, so
262
+ # parenting a batch would hang the whole batch off that single span. Only
263
+ # link those, which is what the OpenTelemetry messaging conventions ask
264
+ # for: they use links as the default, and allow the producer to be the
265
+ # parent only when it produced a single message.
266
+ def active_job_relationship(job_data)
267
+ active_job_batch?(job_data) ? :link : :both
268
+ end
269
+
270
+ # The remote parent's SpanContext from an incoming OTel context, or `nil`
271
+ # when there is no context or the span in it is invalid.
272
+ #
273
+ # `propagation.extract` returns a context whether or not the carrier held
274
+ # anything, so this is what tells "read a context" apart from "read
275
+ # nothing". A caller that can fall back to another carrier uses it to
276
+ # decide whether to, and a caller that parents or links a span uses it to
277
+ # decide between doing that and starting a plain root span.
278
+ #
279
+ # Only ever called with a context that came from the OpenTelemetry SDK, so
280
+ # it does not gate on the SDK having booted the way the extract methods do.
281
+ def remote_span_context(opentelemetry_context)
282
+ return unless opentelemetry_context
283
+
284
+ span_context =
285
+ ::OpenTelemetry::Trace.current_span(opentelemetry_context).context
286
+ span_context if span_context.valid?
287
+ end
288
+
177
289
  # Run `block` only when the OpenTelemetry SDK has booted (collector mode),
178
290
  # returning its result; a no-op returning `nil` otherwise. The block can
179
291
  # touch the OTel SDK freely -- it only runs when the SDK is loaded.
@@ -212,10 +324,12 @@ module Appsignal
212
324
 
213
325
  # Build the OpenTelemetry Resource that carries AppSignal config to the
214
326
  # collector. Attributes whose underlying option is nil or an empty array
215
- # are omitted so the collector applies its own defaults.
327
+ # are omitted so the collector applies its own defaults. The revision,
328
+ # service name and host name are the exception: they fall back to a
329
+ # value here, so they are always sent.
216
330
  def build_resource(config)
217
331
  revision = config[:revision].to_s.empty? ? "unknown" : config[:revision]
218
- service_name = config[:service_name].to_s.empty? ? "unknown" : config[:service_name]
332
+ service_name = config[:service_name].to_s.empty? ? "app" : config[:service_name]
219
333
  host_name = config[:hostname].to_s.empty? ? "unknown" : config[:hostname]
220
334
 
221
335
  attrs = {
@@ -223,6 +337,8 @@ module Appsignal
223
337
  "appsignal.config.environment" => config.env,
224
338
  "appsignal.config.push_api_key" => config[:push_api_key],
225
339
  "appsignal.config.revision" => revision,
340
+ "appsignal.config.app_path" => config.root_path&.to_s,
341
+ "appsignal.config.platform" => config[:platform],
226
342
  "appsignal.config.language_integration" => "ruby",
227
343
  "service.name" => service_name,
228
344
  "host.name" => host_name,
@@ -234,25 +350,67 @@ module Appsignal
234
350
  "appsignal.config.filter_request_session_data" => config[:filter_session_data],
235
351
  "appsignal.config.ignore_actions" => config[:ignore_actions],
236
352
  "appsignal.config.ignore_errors" => config[:ignore_errors],
353
+ "appsignal.config.ignore_logs" => config[:ignore_logs],
237
354
  "appsignal.config.ignore_namespaces" => config[:ignore_namespaces],
238
- "appsignal.config.response_headers" => config[:response_headers],
239
- "appsignal.config.request_headers" => config[:request_headers],
355
+ "appsignal.config.response_headers" =>
356
+ normalized_header_names(config[:response_headers]),
357
+ # The collector filters `http.request.header.*` attributes by their
358
+ # OpenTelemetry names, which is what `keep_request_headers` holds.
359
+ "appsignal.config.request_headers" =>
360
+ normalized_header_names(config[:keep_request_headers]),
240
361
  "appsignal.config.send_function_parameters" => config[:send_function_parameters],
241
362
  "appsignal.config.send_request_query_parameters" =>
242
363
  config[:send_request_query_parameters],
243
364
  "appsignal.config.send_request_payload" => config[:send_request_payload],
244
365
  "appsignal.config.send_request_session_data" => config[:send_session_data]
245
366
  }
246
- attrs.reject! { |_, v| v.nil? || (v.respond_to?(:empty?) && v.empty?) }
367
+ # An absent attribute leaves the collector its own default, which an empty
368
+ # allowlist cannot say, so an empty list is sent and an unset option is not.
369
+ attrs.reject! { |_, value| value.nil? || value == "" }
247
370
  ::OpenTelemetry::SDK::Resources::Resource.create(attrs)
248
371
  end
249
372
 
250
373
  private
251
374
 
375
+ def normalized_header_names(names)
376
+ names.map { |name| Appsignal::Utils::RequestHeaders.normalize(name) }
377
+ end
378
+
379
+ # Build one OTLP exporter, applying the `ca_file_path` and `http_proxy`
380
+ # options to the requests it sends. The certificate file is a keyword
381
+ # argument the exporters accept; the proxy is not, so an exporter that
382
+ # needs one is a subclass that applies it to its own connection.
383
+ def build_exporter(base, config, **kwargs)
384
+ certificate_file = config[:ca_file_path].to_s
385
+ kwargs[:certificate_file] = certificate_file unless certificate_file.empty?
386
+
387
+ http_proxy = config[:http_proxy].to_s
388
+ return base.new(**kwargs) if http_proxy.empty?
389
+
390
+ proxied_exporter_class(base).new(:appsignal_http_proxy => http_proxy, **kwargs)
391
+ end
392
+
393
+ # A subclass of an OTLP exporter that routes its requests through a
394
+ # proxy. Built here rather than declared, because the exporter gems are
395
+ # only loaded once collector mode is configured.
396
+ def proxied_exporter_class(base)
397
+ Class.new(base) { include ProxiedExporter }
398
+ end
399
+
400
+ # A `__otel_headers` value as a hash carrier, or `nil` when there is no
401
+ # usable one. Active Job puts the headers through its argument serializer,
402
+ # which turns the hash into an array of `[key, value]` pairs, so both
403
+ # shapes arrive. Anything else, including a malformed array, gives `nil`
404
+ # rather than raising on `to_h` inside a job perform.
405
+ def otel_headers_hash(value)
406
+ return value if value.is_a?(Hash)
407
+ return value.to_h if otel_header_pairs?(value)
408
+
409
+ nil
410
+ end
411
+
252
412
  # Whether a `__otel_headers` value is the array-of-`[key, value]`-pairs
253
- # shape produced by ActiveJob's argument serializer, so it can be turned
254
- # into a hash carrier. Anything else (including a malformed array) is left
255
- # alone rather than raising on `to_h` inside a job perform.
413
+ # shape produced by ActiveJob's argument serializer.
256
414
  def otel_header_pairs?(value)
257
415
  value.is_a?(Array) && value.all? { |pair| pair.is_a?(Array) && pair.size == 2 }
258
416
  end
@@ -98,7 +98,10 @@ module Appsignal
98
98
  :method => Appsignal::Rack::Utils.request_method_from(request),
99
99
  :path => Appsignal::Rack::Utils.request_value_from(request, :path),
100
100
  :scheme => Appsignal::Rack::Utils.request_value_from(request, :scheme),
101
- :query => Appsignal::Rack::Utils.request_value_from(request, :query_string)
101
+ :query => Appsignal::Rack::Utils.request_value_from(request, :query_string),
102
+ :host => Appsignal::Rack::Utils.request_value_from(request, :hostname),
103
+ :port => Appsignal::Rack::Utils.request_value_from(request, :port),
104
+ :protocol => Appsignal::Rack::Utils.request_env_value_from(request, "SERVER_PROTOCOL")
102
105
  )
103
106
  )
104
107
  end
@@ -81,7 +81,11 @@ module Appsignal
81
81
  :method => Appsignal::Rack::Utils.request_method_from(request),
82
82
  :path => Appsignal::Rack::Utils.request_value_from(request, :path),
83
83
  :scheme => Appsignal::Rack::Utils.request_value_from(request, :scheme),
84
- :query => Appsignal::Rack::Utils.request_value_from(request, :query_string)
84
+ :query => Appsignal::Rack::Utils.request_value_from(request, :query_string),
85
+ :host => Appsignal::Rack::Utils.request_value_from(request, :hostname),
86
+ :port => Appsignal::Rack::Utils.request_value_from(request, :port),
87
+ :protocol =>
88
+ Appsignal::Rack::Utils.request_env_value_from(request, "SERVER_PROTOCOL")
85
89
  )
86
90
  )
87
91
  transaction.start_event(
@@ -141,7 +145,10 @@ module Appsignal
141
145
  self.class.safe_execution("Appsignal::Rack::EventHandler#on_finish") do
142
146
  transaction.finish_event("process_request.rack", "callback: on_finish", "")
143
147
  transaction.add_request_payload_if_nil { request.params }
144
- transaction.add_headers_if_nil { request.env }
148
+ headers, environment =
149
+ Appsignal::Utils::RequestHeaders.split_lazily { request.env }
150
+ transaction.add_request_headers_if_nil(&headers)
151
+ transaction.add_request_environment_if_nil(&environment)
145
152
  transaction.add_session_data_if_nil do
146
153
  request.session if request.respond_to?(:session)
147
154
  end