appsignal 4.10.4-java → 5.0.0-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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +108 -0
  3. data/Rakefile +195 -4
  4. data/appsignal.gemspec +8 -0
  5. data/build_matrix.yml +12 -0
  6. data/ext/appsignal_extension.c +14 -0
  7. data/lib/appsignal/backends.rb +55 -0
  8. data/lib/appsignal/cli/diagnose.rb +2 -10
  9. data/lib/appsignal/config.rb +377 -13
  10. data/lib/appsignal/demo.rb +12 -10
  11. data/lib/appsignal/event_formatter/action_view/render_formatter.rb +34 -22
  12. data/lib/appsignal/event_formatter/active_job/perform_formatter.rb +35 -0
  13. data/lib/appsignal/event_formatter/active_record/sql_formatter.rb +19 -0
  14. data/lib/appsignal/event_formatter/elastic_search/search_formatter.rb +27 -0
  15. data/lib/appsignal/event_formatter/recorded_elsewhere.rb +17 -0
  16. data/lib/appsignal/event_formatter/rom/sql_formatter.rb +24 -0
  17. data/lib/appsignal/event_formatter/sequel/sql_formatter.rb +5 -0
  18. data/lib/appsignal/event_formatter/view_component/render_formatter.rb +21 -10
  19. data/lib/appsignal/event_formatter.rb +78 -0
  20. data/lib/appsignal/extension.rb +4 -0
  21. data/lib/appsignal/helpers/instrumentation.rb +324 -20
  22. data/lib/appsignal/helpers/metrics.rb +3 -24
  23. data/lib/appsignal/hooks/action_cable.rb +26 -8
  24. data/lib/appsignal/hooks/active_job.rb +184 -47
  25. data/lib/appsignal/hooks/at_exit.rb +4 -1
  26. data/lib/appsignal/hooks/excon.rb +20 -0
  27. data/lib/appsignal/hooks/faraday.rb +16 -0
  28. data/lib/appsignal/hooks/http.rb +5 -0
  29. data/lib/appsignal/hooks/resque.rb +1 -1
  30. data/lib/appsignal/hooks/sequel.rb +32 -2
  31. data/lib/appsignal/hooks/shoryuken.rb +3 -3
  32. data/lib/appsignal/hooks/sidekiq.rb +1 -1
  33. data/lib/appsignal/integrations/action_cable.rb +5 -2
  34. data/lib/appsignal/integrations/active_support_notifications.rb +59 -19
  35. data/lib/appsignal/integrations/data_mapper.rb +14 -2
  36. data/lib/appsignal/integrations/delayed_job_plugin.rb +81 -8
  37. data/lib/appsignal/integrations/dry_monitor.rb +39 -15
  38. data/lib/appsignal/integrations/excon/appsignal_middleware.rb +21 -0
  39. data/lib/appsignal/integrations/excon.rb +52 -15
  40. data/lib/appsignal/integrations/faraday.rb +47 -12
  41. data/lib/appsignal/integrations/http.rb +43 -1
  42. data/lib/appsignal/integrations/mongo_ruby_driver.rb +73 -4
  43. data/lib/appsignal/integrations/net_http.rb +31 -2
  44. data/lib/appsignal/integrations/puma.rb +4 -1
  45. data/lib/appsignal/integrations/que.rb +256 -37
  46. data/lib/appsignal/integrations/railtie.rb +4 -1
  47. data/lib/appsignal/integrations/rake.rb +9 -3
  48. data/lib/appsignal/integrations/redis.rb +22 -1
  49. data/lib/appsignal/integrations/redis_client.rb +22 -1
  50. data/lib/appsignal/integrations/resque.rb +81 -11
  51. data/lib/appsignal/integrations/shoryuken.rb +159 -12
  52. data/lib/appsignal/integrations/sidekiq.rb +94 -16
  53. data/lib/appsignal/integrations/webmachine.rb +56 -5
  54. data/lib/appsignal/loaders/padrino.rb +2 -1
  55. data/lib/appsignal/logger/extension_backend.rb +24 -0
  56. data/lib/appsignal/logger/opentelemetry_backend.rb +66 -0
  57. data/lib/appsignal/logger.rb +13 -9
  58. data/lib/appsignal/metrics/extension_backend.rb +47 -0
  59. data/lib/appsignal/metrics/opentelemetry_backend.rb +89 -0
  60. data/lib/appsignal/opentelemetry/attributes.rb +31 -0
  61. data/lib/appsignal/opentelemetry/dependencies.rb +35 -0
  62. data/lib/appsignal/opentelemetry/error_type.rb +37 -0
  63. data/lib/appsignal/opentelemetry/http_client_request.rb +83 -0
  64. data/lib/appsignal/opentelemetry/http_method.rb +59 -0
  65. data/lib/appsignal/opentelemetry/http_response.rb +30 -0
  66. data/lib/appsignal/opentelemetry/http_server_request.rb +79 -0
  67. data/lib/appsignal/opentelemetry/messaging.rb +82 -0
  68. data/lib/appsignal/opentelemetry/proxied_exporter.rb +83 -0
  69. data/lib/appsignal/opentelemetry/rendering.rb +29 -0
  70. data/lib/appsignal/opentelemetry/sql_db_system.rb +89 -0
  71. data/lib/appsignal/opentelemetry.rb +495 -0
  72. data/lib/appsignal/rack/abstract_middleware.rb +66 -4
  73. data/lib/appsignal/rack/body_wrapper.rb +18 -5
  74. data/lib/appsignal/rack/event_handler.rb +44 -4
  75. data/lib/appsignal/rack/grape_middleware.rb +1 -0
  76. data/lib/appsignal/rack/hanami_middleware.rb +2 -1
  77. data/lib/appsignal/rack/instrumentation_middleware.rb +1 -0
  78. data/lib/appsignal/rack/rails_instrumentation.rb +1 -0
  79. data/lib/appsignal/rack/sinatra_instrumentation.rb +1 -0
  80. data/lib/appsignal/rack.rb +68 -12
  81. data/lib/appsignal/sample_data.rb +4 -0
  82. data/lib/appsignal/transaction/base_backend.rb +128 -0
  83. data/lib/appsignal/transaction/extension_backend.rb +229 -0
  84. data/lib/appsignal/transaction/opentelemetry_backend.rb +847 -0
  85. data/lib/appsignal/transaction.rb +714 -164
  86. data/lib/appsignal/utils/request_headers.rb +78 -0
  87. data/lib/appsignal/utils/stdout_and_logger_message.rb +9 -0
  88. data/lib/appsignal/utils.rb +1 -0
  89. data/lib/appsignal/version.rb +1 -1
  90. data/lib/appsignal.rb +10 -0
  91. data/sig/appsignal.rbi +630 -37
  92. data/sig/appsignal.rbs +582 -27
  93. metadata +25 -1
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Appsignal
4
+ module OpenTelemetry
5
+ # @!visibility private
6
+ #
7
+ # Maps a SQL library's own name for the engine it is talking to onto the
8
+ # `db.system.name` semantic conventions value for that engine.
9
+ #
10
+ # Each library names engines differently, and none of them matches the
11
+ # semantic conventions spelling, so every SQL integration needs a lookup.
12
+ # Kept as one map per library, each keyed on that library's own
13
+ # vocabulary, rather than one shared map: ActiveRecord's `ADAPTER_NAME`
14
+ # strings, Sequel's `database_type` symbols, and DataMapper's
15
+ # `DataObjects` connection class names do not collide today, but nothing
16
+ # about that is enforced by combining them. A wrong value passed into the
17
+ # wrong library's lookup would silently return another library's answer
18
+ # instead of nil, and a library's own gaps would be invisible next to two
19
+ # other libraries' complete entries. ROM is the exception: its payload
20
+ # already carries Sequel's own `database_type` symbol, so it is
21
+ # legitimate for it to share Sequel's map, not just convenient.
22
+ #
23
+ # A name none of these maps recognise returns `nil`, so the caller's own
24
+ # `other_sql` fallback applies -- exactly what already happens for a
25
+ # library this gem has no mapping for at all.
26
+ #
27
+ # This intentionally follows the older semantic conventions value set:
28
+ # SQL Server maps to `mssql`, not the current registry's
29
+ # `microsoft.sql_server`, and Oracle maps to `oracle`, not `oracle.db`.
30
+ # That is what the AppSignal collector's sanitizer recognizes today;
31
+ # emitting the newer values would silently turn sanitization off for
32
+ # those engines' queries.
33
+ module SqlDbSystem
34
+ # ActiveRecord's `ADAPTER_NAME`. Rails bundles the Postgres, Mysql2,
35
+ # SQLite and (7.1+) Trilogy adapters; SQL Server and Oracle come from
36
+ # the separate `activerecord-sqlserver-adapter` and
37
+ # `activerecord-oracle_enhanced-adapter` gems, which declare
38
+ # `ADAPTER_NAME` the same way.
39
+ ACTIVE_RECORD = {
40
+ "PostgreSQL" => "postgresql",
41
+ "Mysql2" => "mysql",
42
+ "Trilogy" => "mysql",
43
+ "SQLite" => "sqlite",
44
+ "SQLServer" => "mssql",
45
+ "OracleEnhanced" => "oracle"
46
+ }.freeze
47
+
48
+ # Sequel's `database_type`. ROM's dry-monitor payload reuses this
49
+ # symbol directly, so `name_for_sequel` is also ROM's lookup.
50
+ SEQUEL = {
51
+ :postgres => "postgresql",
52
+ :mysql => "mysql",
53
+ :sqlite => "sqlite",
54
+ :mssql => "mssql",
55
+ :oracle => "oracle"
56
+ }.freeze
57
+
58
+ # DataMapper's `DataObjects` connection classes.
59
+ DATA_MAPPER = {
60
+ "DataObjects::Postgres::Connection" => "postgresql",
61
+ "DataObjects::Mysql::Connection" => "mysql",
62
+ "DataObjects::Sqlite3::Connection" => "sqlite",
63
+ "DataObjects::SqlServer::Connection" => "mssql"
64
+ }.freeze
65
+
66
+ class << self
67
+ # The `db.system.name` value for an ActiveRecord connection's
68
+ # `adapter_name`, or `nil` when the adapter is not one this map
69
+ # recognises.
70
+ def name_for_active_record(adapter_name)
71
+ ACTIVE_RECORD[adapter_name]
72
+ end
73
+
74
+ # The `db.system.name` value for Sequel's `database_type`, or `nil`
75
+ # when it is not one this map recognises. Also the lookup ROM's
76
+ # formatter uses, since ROM reports this same symbol.
77
+ def name_for_sequel(database_type)
78
+ SEQUEL[database_type]
79
+ end
80
+
81
+ # The `db.system.name` value for DataMapper's connection class name,
82
+ # or `nil` when it is not one this map recognises.
83
+ def name_for_data_mapper(connection_class_name)
84
+ DATA_MAPPER[connection_class_name]
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,495 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "appsignal/opentelemetry/attributes"
4
+ require "appsignal/opentelemetry/dependencies"
5
+ require "appsignal/opentelemetry/error_type"
6
+ require "appsignal/opentelemetry/http_client_request"
7
+ require "appsignal/opentelemetry/http_method"
8
+ require "appsignal/opentelemetry/http_response"
9
+ require "appsignal/opentelemetry/http_server_request"
10
+ require "appsignal/opentelemetry/messaging"
11
+ require "appsignal/opentelemetry/proxied_exporter"
12
+ require "appsignal/opentelemetry/rendering"
13
+ require "appsignal/opentelemetry/sql_db_system"
14
+
15
+ module Appsignal
16
+ # @!visibility private
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
+
23
+ class << self
24
+ # Configure the global OpenTelemetry SDK to export OTLP/HTTP protobuf to
25
+ # the collector endpoint in `config[:collector_endpoint]`.
26
+ #
27
+ # The SDK and exporter gems are required lazily, so an application not in
28
+ # collector mode does not pay the load cost. Sets `@started`, which
29
+ # {.started?} reads to decide whether to route through the OTel backends.
30
+ def configure(config)
31
+ # The OTel Ruby SDK exposes no programmatic knob for the default
32
+ # aggregation temporality; this env var is the only way to set
33
+ # it. We pick `:delta` to match the Python integration. (Note:
34
+ # the Ruby SDK keeps `UpDownCounter` cumulative regardless of
35
+ # this preference, per the OTel spec.)
36
+ ENV["OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE"] ||= "delta"
37
+
38
+ # With the metrics and logs SDK gems loaded, `SDK.configure` below
39
+ # auto-installs a metrics reader and a log processor from these env vars,
40
+ # each with its own background thread. Both providers are replaced right
41
+ # after, which would leave those threads running and unreachable by any
42
+ # shutdown. Set unconditionally, so a user-set "otlp" cannot slip past
43
+ # and reintroduce them.
44
+ ENV["OTEL_METRICS_EXPORTER"] = "none"
45
+ ENV["OTEL_LOGS_EXPORTER"] = "none"
46
+
47
+ require_sdk_gems
48
+
49
+ # The OpenTelemetry gems are optional and installed by the user (not
50
+ # declared in the gemspec). If they're present but older than the
51
+ # versions we support, fall back to the agent rather than booting an
52
+ # SDK that may misbehave (e.g. a metrics SDK without fork hooks).
53
+ return unless required_gem_versions_met?
54
+
55
+ endpoint = config[:collector_endpoint].to_s.sub(%r{/+\z}, "")
56
+ # Merge with the SDK's default resource so all three signal types
57
+ # carry the same `telemetry.sdk.*` and `process.*` attributes that
58
+ # `SDK.configure` would have added on its own. `MeterProvider` and
59
+ # `LoggerProvider` take a `resource:` kwarg that replaces (not
60
+ # merges), so we do the merge ourselves and use the same merged
61
+ # resource for the tracer provider to keep all three in sync.
62
+ resource = ::OpenTelemetry::SDK::Resources::Resource.default.merge(build_resource(config))
63
+
64
+ span_exporter = build_exporter(
65
+ ::OpenTelemetry::Exporter::OTLP::Exporter,
66
+ config,
67
+ :endpoint => "#{endpoint}/v1/traces"
68
+ )
69
+
70
+ ::OpenTelemetry::SDK.configure do |c|
71
+ c.resource = resource
72
+ c.add_span_processor(
73
+ ::OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(span_exporter)
74
+ )
75
+ end
76
+
77
+ # Wrap the OTLP MetricsExporter in a PeriodicMetricReader so that
78
+ # `MeterProvider#force_flush` actually triggers an export. The OTLP
79
+ # exporter itself is also a MetricReader but its inherited
80
+ # `force_flush` is a no-op.
81
+ ::OpenTelemetry.meter_provider =
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
+ )
88
+ ::OpenTelemetry.meter_provider.add_metric_reader(
89
+ ::OpenTelemetry::SDK::Metrics::Export::PeriodicMetricReader.new(
90
+ :exporter => metrics_exporter
91
+ )
92
+ )
93
+
94
+ logs_exporter = build_exporter(
95
+ ::OpenTelemetry::Exporter::OTLP::Logs::LogsExporter,
96
+ config,
97
+ :endpoint => "#{endpoint}/v1/logs"
98
+ )
99
+ ::OpenTelemetry.logger_provider =
100
+ ::OpenTelemetry::SDK::Logs::LoggerProvider.new(:resource => resource)
101
+ ::OpenTelemetry.logger_provider.add_log_record_processor(
102
+ ::OpenTelemetry::SDK::Logs::Export::BatchLogRecordProcessor.new(logs_exporter)
103
+ )
104
+
105
+ @started = true
106
+ rescue LoadError => e
107
+ @started = false
108
+ Appsignal::Utils::StdoutAndLoggerMessage.error(
109
+ "Cannot configure OpenTelemetry SDK for collector mode: #{e.class}: #{e.message}"
110
+ )
111
+ rescue => e
112
+ @started = false
113
+ Appsignal::Utils::StdoutAndLoggerMessage.error(
114
+ "Error configuring OpenTelemetry SDK for collector mode: " \
115
+ "#{e.class}: #{e.message}\n#{e.backtrace&.join("\n")}"
116
+ )
117
+ end
118
+
119
+ # Whether {.configure} has successfully booted the OpenTelemetry SDK
120
+ # for this process. Returns `false` before {.configure} runs and
121
+ # `false` if it ran but raised.
122
+ def started?
123
+ defined?(@started) ? @started : false
124
+ end
125
+
126
+ # Write the current trace context onto an outgoing carrier (HTTP request,
127
+ # job hash, ...) with the configured propagator, so a downstream service
128
+ # joins the same trace.
129
+ #
130
+ # A no-op unless the SDK has booted. The carrier is injected from whatever
131
+ # span is current at call time, which inside an `Appsignal.instrument`
132
+ # block is the event's own span.
133
+ def inject_context(carrier)
134
+ if_started do
135
+ ::OpenTelemetry.propagation.inject(carrier)
136
+ end
137
+ end
138
+
139
+ # Read the trace context off an incoming Rack request env using the
140
+ # globally configured propagator, so an AppSignal transaction created for
141
+ # the request can continue the upstream trace. Returns an
142
+ # `OpenTelemetry::Context` (its current span is the remote parent), or
143
+ # `nil` when the SDK has not booted -- outside collector mode there is
144
+ # nothing to continue. `rack_env_getter` reads the `HTTP_*`-mangled header
145
+ # names Rack puts in the env.
146
+ def extract_rack_context(env)
147
+ if_started do
148
+ # Extract onto an empty context, not `Context.current`. The W3C
149
+ # extractor returns its base context unchanged when the carrier has no
150
+ # `traceparent`, so a request with no incoming context would inherit
151
+ # whatever span is ambient on the fiber. Starting empty means "no
152
+ # carrier" yields no parent, and the transaction starts its own trace.
153
+ ::OpenTelemetry.propagation.extract(
154
+ env,
155
+ :context => ::OpenTelemetry::Context.empty,
156
+ :getter => ::OpenTelemetry::Common::Propagation.rack_env_getter
157
+ )
158
+ end
159
+ end
160
+
161
+ # Read the trace context off an incoming background job hash, so the
162
+ # transaction can link back to the enqueuer. Returns an
163
+ # `OpenTelemetry::Context`, or `nil` when the SDK has not booted.
164
+ #
165
+ # Reads both carriers a job can arrive with: top-level `traceparent` and
166
+ # `tracestate` keys, as OpenTelemetry's Sidekiq instrumentation injects
167
+ # them, and a nested `__otel_headers`, as its Active Job one does. Active
168
+ # Job puts that through its argument serializer, so it can arrive as an
169
+ # array of pairs rather than a hash. The nested keys win, being the more
170
+ # specific layer.
171
+ def extract_job_context(item)
172
+ if_started do
173
+ carrier = item
174
+ nested = otel_headers_hash(item["__otel_headers"])
175
+ carrier = item.merge(nested) if nested
176
+ # Extract onto an empty context rather than the default
177
+ # `Context.current`, for the same reason as `extract_rack_context`:
178
+ # a job with no injected trace context must not inherit an ambient
179
+ # span left on the fiber. Otherwise the job's transaction would link
180
+ # back to an unrelated leaked span instead of standing on its own.
181
+ ::OpenTelemetry.propagation.extract(
182
+ carrier,
183
+ :context => ::OpenTelemetry::Context.empty
184
+ )
185
+ end
186
+ end
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
+
289
+ # Run `block` only when the OpenTelemetry SDK has booted (collector mode),
290
+ # returning its result; a no-op returning `nil` otherwise. The block can
291
+ # touch the OTel SDK freely -- it only runs when the SDK is loaded.
292
+ #
293
+ # This is the gate every integration's OTel-specific work goes through, so
294
+ # integration-specific carrier/getter/setter logic lives in the
295
+ # integration rather than as a bespoke helper here.
296
+ def if_started
297
+ return unless started?
298
+
299
+ yield
300
+ end
301
+
302
+ # @!visibility private
303
+ #
304
+ # Test-only. Drops the started flag so subsequent tests start from a
305
+ # clean slate; does not touch the global `::OpenTelemetry` providers.
306
+ def reset!
307
+ @started = false
308
+ end
309
+
310
+ # Flush and shut down the OpenTelemetry SDK providers booted by
311
+ # {.configure}. Called from `Appsignal.stop` so buffered
312
+ # metrics/logs/spans don't get dropped on exit.
313
+ def shutdown
314
+ return unless started?
315
+
316
+ ::OpenTelemetry.tracer_provider&.shutdown
317
+ ::OpenTelemetry.meter_provider&.shutdown
318
+ ::OpenTelemetry.logger_provider&.shutdown
319
+ rescue => e
320
+ Appsignal.internal_logger.error(
321
+ "Error shutting down OpenTelemetry SDK: #{e.class}: #{e.message}"
322
+ )
323
+ end
324
+
325
+ # Build the OpenTelemetry Resource that carries AppSignal config to the
326
+ # collector. Attributes whose underlying option is nil or an empty array
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.
330
+ def build_resource(config)
331
+ revision = config[:revision].to_s.empty? ? "unknown" : config[:revision]
332
+ service_name = config[:service_name].to_s.empty? ? "app" : config[:service_name]
333
+ host_name = config[:hostname].to_s.empty? ? "unknown" : config[:hostname]
334
+
335
+ attrs = {
336
+ "appsignal.config.name" => config[:name],
337
+ "appsignal.config.environment" => config.env,
338
+ "appsignal.config.push_api_key" => config[:push_api_key],
339
+ "appsignal.config.revision" => revision,
340
+ "appsignal.config.app_path" => config.root_path&.to_s,
341
+ "appsignal.config.platform" => config[:platform],
342
+ "appsignal.config.language_integration" => "ruby",
343
+ "service.name" => service_name,
344
+ "host.name" => host_name,
345
+ "appsignal.config.filter_attributes" => config[:filter_attributes],
346
+ "appsignal.config.filter_function_parameters" => config[:filter_function_parameters],
347
+ "appsignal.config.filter_request_query_parameters" =>
348
+ config[:filter_request_query_parameters],
349
+ "appsignal.config.filter_request_payload" => config[:filter_request_payload],
350
+ "appsignal.config.filter_request_session_data" => config[:filter_session_data],
351
+ "appsignal.config.ignore_actions" => config[:ignore_actions],
352
+ "appsignal.config.ignore_errors" => config[:ignore_errors],
353
+ "appsignal.config.ignore_logs" => config[:ignore_logs],
354
+ "appsignal.config.ignore_namespaces" => config[:ignore_namespaces],
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]),
361
+ "appsignal.config.send_function_parameters" => config[:send_function_parameters],
362
+ "appsignal.config.send_request_query_parameters" =>
363
+ config[:send_request_query_parameters],
364
+ "appsignal.config.send_request_payload" => config[:send_request_payload],
365
+ "appsignal.config.send_request_session_data" => config[:send_session_data]
366
+ }
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 == "" }
370
+ ::OpenTelemetry::SDK::Resources::Resource.create(attrs)
371
+ end
372
+
373
+ private
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
+
412
+ # Whether a `__otel_headers` value is the array-of-`[key, value]`-pairs
413
+ # shape produced by ActiveJob's argument serializer.
414
+ def otel_header_pairs?(value)
415
+ value.is_a?(Array) && value.all? { |pair| pair.is_a?(Array) && pair.size == 2 }
416
+ end
417
+
418
+ # The optional OpenTelemetry gems, required lazily so users not in
419
+ # collector mode don't pay the load cost. A missing gem raises LoadError,
420
+ # caught by {.configure}.
421
+ def require_sdk_gems
422
+ require "opentelemetry/sdk"
423
+ require "opentelemetry-common"
424
+ require "opentelemetry/exporter/otlp"
425
+ require "opentelemetry-metrics-sdk"
426
+ require "opentelemetry-exporter-otlp-metrics"
427
+ require "opentelemetry-logs-sdk"
428
+ require "opentelemetry-exporter-otlp-logs"
429
+ end
430
+
431
+ # Checks the installed OpenTelemetry gem versions against {REQUIRED_GEMS}.
432
+ # On a shortfall, warns and flags the SDK as not started so the caller
433
+ # falls back to the agent; returns whether all requirements are met.
434
+ def required_gem_versions_met?
435
+ incompatible = incompatible_gems
436
+ return true if incompatible.nil?
437
+
438
+ @started = false
439
+ Appsignal::Utils::StdoutAndLoggerMessage.warning(
440
+ collector_gems_warning(incompatible)
441
+ )
442
+ false
443
+ end
444
+
445
+ # Builds the warning shown when the OpenTelemetry gems collector mode needs
446
+ # are not all installed at a supported version. `incompatible` describes
447
+ # the gems that are installed but at a version we do not support.
448
+ #
449
+ # The message always recommends the `appsignal-opentelemetry` gem, which
450
+ # installs the whole set. It only lists gems when some are installed at an
451
+ # incompatible version, because that usually means a constraint in the
452
+ # bundle that the gem cannot override on its own.
453
+ def collector_gems_warning(incompatible)
454
+ message =
455
+ "AppSignal collector mode requires a set of OpenTelemetry gems. " \
456
+ "Add the `appsignal-opentelemetry` gem to your bundle to install " \
457
+ "them. The AppSignal agent will be used instead."
458
+
459
+ unless incompatible.empty?
460
+ message += "\n\nThese installed OpenTelemetry gems are not compatible " \
461
+ "with this AppSignal version. Update them or remove a version " \
462
+ "constraint:\n"
463
+ message += incompatible.map { |line| "- #{line}" }.join("\n")
464
+ end
465
+
466
+ message
467
+ end
468
+
469
+ # Checks the installed OpenTelemetry gems against {REQUIRED_GEMS}. Returns
470
+ # `nil` when every required gem is installed at a supported version.
471
+ # Otherwise returns the descriptions of gems that are installed but at a
472
+ # version we do not support. That list is empty when the only problem is
473
+ # that some required gems are not installed at all.
474
+ def incompatible_gems
475
+ missing = false
476
+ incompatible = []
477
+
478
+ REQUIRED_GEMS.each do |name, constraints|
479
+ spec = Gem.loaded_specs[name]
480
+ requirement = Gem::Requirement.new(*constraints)
481
+
482
+ if spec.nil?
483
+ missing = true
484
+ elsif !requirement.satisfied_by?(spec.version)
485
+ incompatible << "#{name} #{spec.version} (requires #{requirement})"
486
+ end
487
+ end
488
+
489
+ return nil if !missing && incompatible.empty?
490
+
491
+ incompatible
492
+ end
493
+ end
494
+ end
495
+ end