appsignal 4.10.1-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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +19 -0
  3. data/Rakefile +87 -0
  4. data/appsignal.gemspec +8 -0
  5. data/build_matrix.yml +14 -1
  6. data/ext/appsignal_extension.c +14 -0
  7. data/lib/appsignal/backends.rb +55 -0
  8. data/lib/appsignal/config.rb +140 -1
  9. data/lib/appsignal/demo.rb +1 -1
  10. data/lib/appsignal/event_formatter/action_view/render_formatter.rb +34 -22
  11. data/lib/appsignal/event_formatter/active_job/perform_formatter.rb +35 -0
  12. data/lib/appsignal/event_formatter/active_record/sql_formatter.rb +19 -0
  13. data/lib/appsignal/event_formatter/elastic_search/search_formatter.rb +27 -0
  14. data/lib/appsignal/event_formatter/recorded_elsewhere.rb +17 -0
  15. data/lib/appsignal/event_formatter/rom/sql_formatter.rb +24 -0
  16. data/lib/appsignal/event_formatter/sequel/sql_formatter.rb +5 -0
  17. data/lib/appsignal/event_formatter/view_component/render_formatter.rb +21 -10
  18. data/lib/appsignal/event_formatter.rb +78 -0
  19. data/lib/appsignal/extension.rb +4 -0
  20. data/lib/appsignal/helpers/instrumentation.rb +236 -20
  21. data/lib/appsignal/helpers/metrics.rb +3 -24
  22. data/lib/appsignal/hooks/action_cable.rb +18 -6
  23. data/lib/appsignal/hooks/active_job.rb +128 -39
  24. data/lib/appsignal/hooks/at_exit.rb +4 -1
  25. data/lib/appsignal/hooks/excon.rb +20 -0
  26. data/lib/appsignal/hooks/faraday.rb +16 -0
  27. data/lib/appsignal/hooks/http.rb +5 -0
  28. data/lib/appsignal/hooks/resque.rb +1 -1
  29. data/lib/appsignal/hooks/sequel.rb +32 -2
  30. data/lib/appsignal/hooks/shoryuken.rb +3 -3
  31. data/lib/appsignal/hooks/sidekiq.rb +1 -1
  32. data/lib/appsignal/integrations/action_cable.rb +5 -2
  33. data/lib/appsignal/integrations/active_support_notifications.rb +59 -14
  34. data/lib/appsignal/integrations/data_mapper.rb +14 -2
  35. data/lib/appsignal/integrations/delayed_job_plugin.rb +45 -5
  36. data/lib/appsignal/integrations/dry_monitor.rb +39 -15
  37. data/lib/appsignal/integrations/excon/appsignal_middleware.rb +21 -0
  38. data/lib/appsignal/integrations/excon.rb +52 -15
  39. data/lib/appsignal/integrations/faraday.rb +47 -12
  40. data/lib/appsignal/integrations/http.rb +43 -1
  41. data/lib/appsignal/integrations/mongo_ruby_driver.rb +73 -4
  42. data/lib/appsignal/integrations/net_http.rb +31 -2
  43. data/lib/appsignal/integrations/puma.rb +4 -1
  44. data/lib/appsignal/integrations/que.rb +224 -37
  45. data/lib/appsignal/integrations/railtie.rb +4 -1
  46. data/lib/appsignal/integrations/rake.rb +9 -3
  47. data/lib/appsignal/integrations/redis.rb +22 -1
  48. data/lib/appsignal/integrations/redis_client.rb +22 -1
  49. data/lib/appsignal/integrations/resque.rb +57 -10
  50. data/lib/appsignal/integrations/shoryuken.rb +140 -12
  51. data/lib/appsignal/integrations/sidekiq.rb +73 -16
  52. data/lib/appsignal/integrations/webmachine.rb +47 -4
  53. data/lib/appsignal/loaders/padrino.rb +2 -1
  54. data/lib/appsignal/logger/extension_backend.rb +24 -0
  55. data/lib/appsignal/logger/opentelemetry_backend.rb +66 -0
  56. data/lib/appsignal/logger.rb +13 -9
  57. data/lib/appsignal/metrics/extension_backend.rb +47 -0
  58. data/lib/appsignal/metrics/opentelemetry_backend.rb +89 -0
  59. data/lib/appsignal/opentelemetry/attributes.rb +31 -0
  60. data/lib/appsignal/opentelemetry/dependencies.rb +35 -0
  61. data/lib/appsignal/opentelemetry/error_type.rb +37 -0
  62. data/lib/appsignal/opentelemetry/http_client_request.rb +83 -0
  63. data/lib/appsignal/opentelemetry/http_method.rb +59 -0
  64. data/lib/appsignal/opentelemetry/http_response.rb +30 -0
  65. data/lib/appsignal/opentelemetry/http_server_request.rb +45 -0
  66. data/lib/appsignal/opentelemetry/messaging.rb +82 -0
  67. data/lib/appsignal/opentelemetry/rendering.rb +29 -0
  68. data/lib/appsignal/opentelemetry/sql_db_system.rb +89 -0
  69. data/lib/appsignal/opentelemetry.rb +337 -0
  70. data/lib/appsignal/rack/abstract_middleware.rb +63 -4
  71. data/lib/appsignal/rack/body_wrapper.rb +18 -5
  72. data/lib/appsignal/rack/event_handler.rb +36 -3
  73. data/lib/appsignal/rack/grape_middleware.rb +1 -0
  74. data/lib/appsignal/rack/hanami_middleware.rb +2 -1
  75. data/lib/appsignal/rack/instrumentation_middleware.rb +1 -0
  76. data/lib/appsignal/rack/rails_instrumentation.rb +1 -0
  77. data/lib/appsignal/rack/sinatra_instrumentation.rb +1 -0
  78. data/lib/appsignal/rack.rb +39 -11
  79. data/lib/appsignal/sample_data.rb +4 -0
  80. data/lib/appsignal/transaction/base_backend.rb +107 -0
  81. data/lib/appsignal/transaction/extension_backend.rb +203 -0
  82. data/lib/appsignal/transaction/opentelemetry_backend.rb +796 -0
  83. data/lib/appsignal/transaction.rb +537 -144
  84. data/lib/appsignal/utils/stdout_and_logger_message.rb +9 -0
  85. data/lib/appsignal/version.rb +1 -1
  86. data/lib/appsignal.rb +9 -0
  87. data/sig/appsignal.rbi +426 -37
  88. data/sig/appsignal.rbs +386 -27
  89. metadata +23 -1
@@ -0,0 +1,796 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "socket"
5
+
6
+ module Appsignal
7
+ class Transaction
8
+ # @!visibility private
9
+ #
10
+ # The transaction backend used in collector mode. Emits an OpenTelemetry
11
+ # root span for the transaction, a child span per instrumented event, and
12
+ # queue timing as a metric. Errors and breadcrumbs attach to whichever span
13
+ # is current when they happen.
14
+ class OpenTelemetryBackend < BaseBackend
15
+ TRACER_NAME = "appsignal-ruby"
16
+
17
+ # Guards the process-wide warn-once state, which transactions touch
18
+ # concurrently on threaded servers. A constant so it is created once at
19
+ # load time rather than lazily (which would race).
20
+ WARN_ONCE_LOCK = Mutex.new
21
+
22
+ class << self
23
+ # Logs the block's message the first time it sees `key`, then stays quiet
24
+ # for that key for the rest of the process. Used for warnings that would
25
+ # otherwise repeat on every transaction. The message is built lazily, so
26
+ # a deduplicated call skips building it. The check-and-set is locked so
27
+ # concurrent transactions cannot both warn.
28
+ def warn_once(key)
29
+ first_time = WARN_ONCE_LOCK.synchronize do
30
+ next false if warned_keys.key?(key)
31
+
32
+ warned_keys[key] = true
33
+ end
34
+ Appsignal.internal_logger.warn(yield) if first_time
35
+ end
36
+
37
+ # @!visibility private
38
+ # Resets the warn-once state. Only used to keep test runs isolated.
39
+ def clear_warned!
40
+ WARN_ONCE_LOCK.synchronize { warned_keys.clear }
41
+ end
42
+
43
+ private
44
+
45
+ def warned_keys
46
+ @warned_keys ||= {}
47
+ end
48
+ end
49
+
50
+ # Collector treats SERVER/CONSUMER spans as subtrace roots; SERVER is the
51
+ # safe default when no kind is given (a transaction is almost always an
52
+ # external-triggered unit of work).
53
+ DEFAULT_SPAN_KIND = :server
54
+
55
+ # The span kinds a transaction may take. An unknown value would raise
56
+ # inside OpenTelemetry span creation, so it falls back to the default.
57
+ SPAN_KINDS = [:server, :consumer, :producer, :internal].freeze
58
+
59
+ # How the transaction's span relates to an incoming OpenTelemetry context
60
+ # when none is given. A web request continues the upstream trace, so
61
+ # parenting is the safe default.
62
+ DEFAULT_RELATIONSHIP = :parent
63
+
64
+ # How the transaction's span may relate to an incoming context. An unknown
65
+ # value would silently behave like `:none`, so it falls back to the
66
+ # default instead.
67
+ RELATIONSHIPS = [:parent, :link, :both, :none].freeze
68
+
69
+ # The collector expects "web"/"background"; the agent's processor converts
70
+ # these internal namespaces in agent mode, but nothing does in collector
71
+ # mode. Other namespaces pass through unchanged.
72
+ DISPLAY_NAMESPACE = {
73
+ "http_request" => "web",
74
+ "background_job" => "background"
75
+ }.freeze
76
+
77
+ # Placeholder name an event span carries between `start_event` and
78
+ # `finish_event`. `finish_event` overwrites it with the AS::N event name,
79
+ # so it only surfaces when `complete` has to drain a span that was started
80
+ # but never finished. It is deliberately an obvious placeholder rather
81
+ # than a plausible event name, so such a span reads as the unfinished
82
+ # event it is and is not mistaken for a real one.
83
+ EVENT_SPAN_PLACEHOLDER_NAME = "[unfinished transaction event]"
84
+
85
+ # Sentinel value the AppSignal collector recognizes as "a SQL system
86
+ # we don't know the specific dialect of" — sufficient to trigger SQL
87
+ # sanitization on `db.query.text`.
88
+ SQL_DB_SYSTEM = "other_sql"
89
+
90
+ # Epoch-ms floor (~year 2000) below which a queue start is ignored. Mirrors
91
+ # the agent's `set_queue_start` (ext `transaction.rs`), which only records a
92
+ # queue duration when `queue_start_ms > 946_681_200_000`.
93
+ QUEUE_START_MIN = 946_681_200_000
94
+
95
+ # One open event on the event stack. Holds the OpenTelemetry span and the
96
+ # context token attached for it, plus the allocation bookkeeping for the
97
+ # event. `allocation_start` is the allocation counter when the event began
98
+ # (nil when allocation tracking is off), and `child_allocation_count`
99
+ # accumulates the full allocation counts of the event's finished children,
100
+ # so the event's own allocations are `full - child_allocation_count`.
101
+ # `db_system_name_set` tracks whether the event itself already set
102
+ # `db.system.name` (through `set_attributes`) before it finishes, so the
103
+ # SQL sentinel written at finish only fills in a value that is missing.
104
+ EventFrame = Struct.new(
105
+ :span, :token, :allocation_start, :child_allocation_count,
106
+ :db_system_name_set
107
+ )
108
+
109
+ def initialize( # rubocop:disable Metrics/ParameterLists
110
+ transaction_id,
111
+ namespace,
112
+ opentelemetry_context: nil,
113
+ opentelemetry_scope: nil,
114
+ opentelemetry_kind: nil,
115
+ opentelemetry_relationship: nil
116
+ )
117
+ super()
118
+ @transaction_id = transaction_id
119
+ @namespace = namespace
120
+ @scope = opentelemetry_scope
121
+ @completed = false
122
+ @event_stack = []
123
+ @breadcrumb_count = 0
124
+ @queue_start = nil
125
+ @start_time = Time.now
126
+ @action_set = false
127
+ @action = nil
128
+ @allocation_start = current_allocation_count
129
+ @root_child_allocation_count = 0
130
+
131
+ kind = validated_option(
132
+ opentelemetry_kind, SPAN_KINDS, DEFAULT_SPAN_KIND, "opentelemetry_kind"
133
+ )
134
+ relationship = validated_option(
135
+ opentelemetry_relationship, RELATIONSHIPS, DEFAULT_RELATIONSHIP,
136
+ "opentelemetry_relationship"
137
+ )
138
+ @span = start_transaction_span(namespace, kind, relationship, opentelemetry_context)
139
+ @context_token = ::OpenTelemetry::Context.attach(
140
+ ::OpenTelemetry::Trace.context_with_span(@span)
141
+ )
142
+
143
+ # Transaction#initialize sets the namespace directly without calling
144
+ # set_namespace, so emit the attribute from here.
145
+ @span.set_attribute("appsignal.namespace", display_namespace(namespace)) if namespace
146
+ end
147
+
148
+ # `opentelemetry_kind` (e.g. `:client` for an outgoing HTTP request) is set
149
+ # at span creation because OTel span kind is immutable afterwards. `nil`
150
+ # leaves the SDK default (INTERNAL).
151
+ def start_event(opentelemetry_kind: nil, opentelemetry_scope: nil)
152
+ span = tracer_for(opentelemetry_scope)
153
+ .start_span(EVENT_SPAN_PLACEHOLDER_NAME, :kind => opentelemetry_kind)
154
+ token = ::OpenTelemetry::Context.attach(
155
+ ::OpenTelemetry::Trace.context_with_span(span)
156
+ )
157
+ push_event(span, token)
158
+ end
159
+
160
+ def finish_event(name, title, body, body_format)
161
+ return if @event_stack.empty?
162
+
163
+ frame = @event_stack.pop
164
+ write_event_span_name(frame.span, name, title)
165
+ write_event_body_attributes(frame.span, body, body_format, frame.db_system_name_set)
166
+ write_event_allocation_count(frame)
167
+ ::OpenTelemetry::Context.detach(frame.token)
168
+ frame.span.finish
169
+ end
170
+
171
+ # `opentelemetry_kind` is set at span creation (kind is immutable in OTel),
172
+ # mirroring `start_event`. `nil` leaves the SDK default (INTERNAL).
173
+ def record_event( # rubocop:disable Metrics/ParameterLists
174
+ name, title, body, body_format, duration,
175
+ opentelemetry_kind: nil, opentelemetry_scope: nil, opentelemetry_attributes: nil
176
+ )
177
+ start_time = Time.now - (duration / 1_000_000_000.0)
178
+ span = tracer_for(opentelemetry_scope).start_span(
179
+ EVENT_SPAN_PLACEHOLDER_NAME,
180
+ :start_timestamp => start_time,
181
+ :kind => opentelemetry_kind
182
+ )
183
+ write_event_span_name(span, name, title)
184
+ # A recorded event has no window between start and finish, so this is
185
+ # its only chance to attach attributes -- there is no event frame for
186
+ # a later `set_attributes` call to note a `db.system.name` on.
187
+ formatted_attributes = Appsignal::OpenTelemetry::Attributes.format(
188
+ opentelemetry_attributes || {}
189
+ )
190
+ span.add_attributes(formatted_attributes) unless formatted_attributes.empty?
191
+ write_event_body_attributes(
192
+ span, body, body_format, named_db_system?(formatted_attributes)
193
+ )
194
+ # A recorded event has no start hook, so we never measured its
195
+ # allocations. We deliberately set no allocation attribute rather than a
196
+ # misleading zero. Its allocations instead fall into the enclosing
197
+ # event's own count, matching agent mode.
198
+ span.finish
199
+ end
200
+
201
+ def set_action(action)
202
+ # The collector reads the action from `appsignal.action_name`, not the
203
+ # span name. Set the name too so the OTel-native trace stays readable;
204
+ # the collector treats the span name as authoritative for display.
205
+ @span.name = action
206
+ @span.set_attribute("appsignal.action_name", action)
207
+ @action = action
208
+ @action_set = true
209
+ end
210
+
211
+ def set_namespace(namespace)
212
+ # Only the attribute can change here: SpanKind is fixed at span
213
+ # creation (immutable in OTel) from the initial namespace. A later
214
+ # namespace override updates `appsignal.namespace` but not the kind --
215
+ # the collector uses the attribute for the namespace and the kind only
216
+ # to pick the subtrace root, so this is fine for the rare late change.
217
+ @namespace = namespace
218
+ @span.set_attribute("appsignal.namespace", display_namespace(namespace))
219
+ end
220
+
221
+ # Queue start has no OTel-native home, so surface it two ways: an
222
+ # `appsignal.queue_start` event on the root span (per-trace timeline) and,
223
+ # at completion, a `transaction_queue_duration` metric (the aggregate
224
+ # graph). Like the agent, we record the delta and never shift span timing.
225
+ #
226
+ # The queue start time becomes the event's timestamp (events carry their
227
+ # own time), so it is not duplicated as an attribute.
228
+ def set_queue_start(start)
229
+ return unless start && start > QUEUE_START_MIN
230
+
231
+ @queue_start = start
232
+ @span.add_event(
233
+ "appsignal.queue_start",
234
+ :timestamp => Time.at(start / 1000.0)
235
+ )
236
+ end
237
+
238
+ # Transaction metadata (request path, method, ...) has no dedicated OTel
239
+ # attribute, but it is the same shape as tags and the collector/trace UI
240
+ # already surface `appsignal.tag.*`, so emit metadata as a tag.
241
+ def set_metadata(key, value)
242
+ @span.set_attribute("appsignal.tag.#{key}", value)
243
+ end
244
+
245
+ # Sets OpenTelemetry attributes on AppSignal's current span -- the open
246
+ # event span, or the root span when no event is open. This is how an
247
+ # integration describes what it instrumented in OpenTelemetry's own terms,
248
+ # such as `db.system.name` on a database query.
249
+ #
250
+ # Never the OTel current span, which may belong to another
251
+ # instrumentation. Values are coerced to the primitives OTLP accepts.
252
+ def set_attributes(attributes)
253
+ formatted = Appsignal::OpenTelemetry::Attributes.format(attributes)
254
+ # Note on the open event frame, if there is one, that this event
255
+ # already named a real `db.system.name`, so `write_event_body_attributes`
256
+ # knows not to overwrite it with the SQL sentinel when the event
257
+ # finishes.
258
+ if named_db_system?(formatted) && (frame = @event_stack.last)
259
+ frame.db_system_name_set = true
260
+ end
261
+ current_span.add_attributes(formatted)
262
+ end
263
+
264
+ # The collector keeps the request payload, the function parameters and the
265
+ # query parameters as separate attributes, so each gets its own bucket.
266
+ # Legacy `params` has no channel of its own, so it maps to the request
267
+ # payload (the web/server default), matching how the span kind defaults to
268
+ # `:server`.
269
+ PARAMS_MAPPING = {
270
+ :params => :request_payload,
271
+ :request_payload => :request_payload,
272
+ :function_parameters => :function_parameters,
273
+ :query_parameters => :query_parameters
274
+ }.freeze
275
+
276
+ def params_mapping
277
+ PARAMS_MAPPING
278
+ end
279
+
280
+ # Routes each sample-data category to the attribute the collector reads.
281
+ # The params arrive on one of three channels: `request_payload` (web),
282
+ # `function_parameters` (jobs) and `query_parameters` (a request's query
283
+ # string), each its own attribute. The other JSON-blob
284
+ # categories (session, custom data) are serialized as JSON; `environment`
285
+ # becomes request-header attributes; tags fan out to `appsignal.tag.*`.
286
+ # Unknown keys pass through as `appsignal.<key>` JSON so nothing is lost.
287
+ # Breadcrumbs never reach here (the backend emits them as span events);
288
+ # causes ride on the exception event (see #set_error).
289
+ def set_sample_data(key, data)
290
+ case key
291
+ when "request_payload"
292
+ @span.set_attribute("appsignal.request.payload", JSON.generate(data))
293
+ when "function_parameters"
294
+ @span.set_attribute("appsignal.function.parameters", JSON.generate(data))
295
+ when "query_parameters"
296
+ @span.set_attribute("appsignal.request.query_parameters", JSON.generate(data))
297
+ when "session_data"
298
+ @span.set_attribute("appsignal.request.session_data", JSON.generate(data))
299
+ when "custom_data"
300
+ @span.set_attribute("appsignal.custom_data", JSON.generate(data))
301
+ when "environment"
302
+ write_request_headers(data)
303
+ when "tags"
304
+ write_tags(data)
305
+ else
306
+ @span.set_attribute("appsignal.#{key}", JSON.generate(data))
307
+ end
308
+ end
309
+
310
+ # Records the error as an `exception` event on AppSignal's current span --
311
+ # the open event span, or the root -- so it attaches to the operation that
312
+ # raised it. `appsignal.alert_this_error` tells the collector to report it
313
+ # even on a child span.
314
+ #
315
+ # Causes ride on one `appsignal.error_causes` JSON attribute, whose keys
316
+ # match the processor's `ErrorSubCause`. Separate cause events would each
317
+ # become their own incident. Each cause carries only the part of its
318
+ # backtrace that is not shared (see `trim_shared_tail`).
319
+ def set_error(class_name, message, backtrace, causes, _root_cause_missing)
320
+ span = current_span
321
+ error_lines = Array(backtrace)
322
+
323
+ attributes = {
324
+ "exception.type" => class_name,
325
+ "exception.message" => message.to_s,
326
+ "exception.stacktrace" => error_lines.join("\n"),
327
+ "appsignal.alert_this_error" => true
328
+ }
329
+
330
+ unless causes.empty?
331
+ attributes["appsignal.error_causes"] = JSON.generate(
332
+ causes.map do |cause|
333
+ lines, lines_omitted = trim_shared_tail(Array(cause[:backtrace]), error_lines)
334
+
335
+ cause_attributes = {
336
+ "name" => cause[:name],
337
+ "message" => cause[:message],
338
+ "lines" => lines
339
+ }
340
+ cause_attributes["lines_omitted"] = lines_omitted if lines_omitted.positive?
341
+ cause_attributes
342
+ end
343
+ )
344
+ end
345
+
346
+ span.add_event("exception", :attributes => attributes)
347
+ # `error.type` is what the semantic conventions read to tell what kind of
348
+ # failure ended the operation. It is an attribute of the span, unlike the
349
+ # `exception.type` above, which is an attribute of the exception event.
350
+ # When a span collects more than one error the last one wins, because a
351
+ # span can only say one thing here.
352
+ span.add_attributes(Appsignal::OpenTelemetry::ErrorType.attributes_for(class_name))
353
+ span.status = ::OpenTelemetry::Trace::Status.error
354
+ end
355
+
356
+ # Emits a breadcrumb as an `appsignal.breadcrumb` span event on AppSignal's
357
+ # current span -- the open event span, falling back to the root -- rather
358
+ # than the OTel current span, which may belong to another instrumentation.
359
+ #
360
+ # Emitted immediately, because by completion the event span has finished
361
+ # and the SDK drops events added to an ended span. The breadcrumb's time
362
+ # becomes the event's timestamp, and the metadata Hash is a JSON string,
363
+ # because event attributes are flat.
364
+ #
365
+ # Capped at `BREADCRUMB_LIMIT` per transaction, keeping the first N where
366
+ # agent mode keeps the last N: a streamed event cannot be retracted.
367
+ def add_breadcrumb(breadcrumb)
368
+ return if @breadcrumb_count >= Appsignal::Transaction::BREADCRUMB_LIMIT
369
+
370
+ @breadcrumb_count += 1
371
+ current_span.add_event(
372
+ "appsignal.breadcrumb",
373
+ :timestamp => Time.at(breadcrumb[:time]),
374
+ :attributes => {
375
+ "category" => breadcrumb[:category],
376
+ "action" => breadcrumb[:action],
377
+ "message" => breadcrumb[:message],
378
+ "metadata" => JSON.generate(breadcrumb[:metadata] || {})
379
+ }
380
+ )
381
+ end
382
+
383
+ # Returns `true` so `Transaction#complete` runs `sample_data`, flushing the
384
+ # params/session/custom-data/tags/etc. onto the still-open root span before
385
+ # `complete` finishes it. The OTel SDK makes its own sampling decision; the
386
+ # gem always populates the span.
387
+ def finish
388
+ true
389
+ end
390
+
391
+ def complete
392
+ # `teardown` sets `@completed`, so this guard also makes the body
393
+ # idempotent across a double `complete`, and skips it on `discard`.
394
+ unless @completed
395
+ # Aggregate metrics are only emitted for a transaction that set an
396
+ # action to group by. An actionless transaction is never reported in
397
+ # agent mode, so it must contribute to no aggregate here either.
398
+ emit_queue_duration_metric if @action_set
399
+ report_allocation_count
400
+ ignore_subtrace_without_action
401
+ end
402
+ teardown
403
+ end
404
+
405
+ # Discarding does not mean "don't send" as it does in agent mode. The root
406
+ # span is still finished and exported, flagged with
407
+ # `appsignal.ignore_subtrace` so the collector drops the whole subtrace.
408
+ # The flag has to be written before the span finishes, because attributes
409
+ # set on an ended span are dropped. Tearing the span down here also
410
+ # detaches the context, so a discarded transaction cannot leave its root
411
+ # span current on the thread.
412
+ def discard
413
+ return if @completed
414
+
415
+ @span&.set_attribute("appsignal.ignore_subtrace", true)
416
+ teardown
417
+ end
418
+
419
+ # Each error is recorded eagerly as its own `exception` event on the span
420
+ # current when it was added, so a trace holds many errors and the
421
+ # Transaction never duplicates itself -- which is why `duplicate` is left
422
+ # unimplemented (see BaseBackend).
423
+ def supports_multiple_errors?
424
+ true
425
+ end
426
+
427
+ # Returned so `Transaction#to_h` (`JSON.parse(@backend.to_json)`) yields an
428
+ # empty Hash. Collector mode asserts on emitted spans, not `to_h`.
429
+ def to_json # rubocop:disable Lint/ToJSON
430
+ "{}"
431
+ end
432
+
433
+ private
434
+
435
+ # Detaches the OTel context and finishes the root span. Idempotent: the
436
+ # Transaction can complete directly and again via a cleanup path, and
437
+ # re-detaching/re-finishing an ended span would error.
438
+ def teardown
439
+ return if @completed
440
+
441
+ @completed = true
442
+ # Release any event span left unfinished by an aborted flow, so the
443
+ # root context can detach in LIFO order.
444
+ until @event_stack.empty?
445
+ frame = @event_stack.pop
446
+ ::OpenTelemetry::Context.detach(frame.token)
447
+ frame.span.finish
448
+ end
449
+ ::OpenTelemetry::Context.detach(@context_token) if @context_token
450
+ @span&.finish
451
+ end
452
+
453
+ # A transaction that never set an action has nothing to group by, and agent
454
+ # mode does not report one at all. Collector mode cannot represent "no
455
+ # action", so the subtrace is flagged for the collector to drop instead,
456
+ # the same way `discard` does. The flag has to be set before `teardown`
457
+ # finishes the span, because attributes set on an ended span are dropped.
458
+ def ignore_subtrace_without_action
459
+ return if @action_set
460
+
461
+ @span&.set_attribute("appsignal.ignore_subtrace", true)
462
+ end
463
+
464
+ # Emits the queue duration as a distribution metric in both the
465
+ # per-namespace and per-namespace-and-host series the queue-time graph
466
+ # reads. Nothing downstream fans these out, so emit both ourselves.
467
+ def emit_queue_duration_metric
468
+ return unless @queue_start
469
+
470
+ duration_ms = (@start_time.to_f * 1000) - @queue_start
471
+ return if duration_ms.negative?
472
+
473
+ namespace = display_namespace(@namespace)
474
+ Appsignal::Metrics::OpenTelemetryBackend.add_distribution_value(
475
+ "transaction_queue_duration", duration_ms, :namespace => namespace
476
+ )
477
+ Appsignal::Metrics::OpenTelemetryBackend.add_distribution_value(
478
+ "transaction_queue_duration", duration_ms,
479
+ :namespace => namespace, :hostname => hostname
480
+ )
481
+ end
482
+
483
+ # Sets the transaction's allocation counts on the root span, and, when the
484
+ # transaction has an action to group by, emits the total as a counter
485
+ # metric. The counter is read once so the attributes and the metric share
486
+ # the same value.
487
+ #
488
+ # The root's total is `appsignal.transaction_allocation_count`, named apart
489
+ # from an event's `appsignal.allocation_count` because it resets per
490
+ # transaction: it is the whole that a span's `self_allocation_count` is a
491
+ # part of, including across a distributed trace.
492
+ #
493
+ # The metric is emitted in both the per-namespace and
494
+ # per-namespace-and-action series the allocation graph reads, as a counter,
495
+ # never host-tagged. Nothing downstream fans these out.
496
+ def report_allocation_count
497
+ return unless @allocation_start
498
+
499
+ count = Appsignal::Extension.allocation_count - @allocation_start
500
+ return if allocation_count_reversed?(count)
501
+
502
+ @span&.set_attribute("appsignal.transaction_allocation_count", count)
503
+ @span&.set_attribute(
504
+ "appsignal.self_allocation_count",
505
+ count - @root_child_allocation_count
506
+ )
507
+
508
+ return unless @action_set && count.positive?
509
+
510
+ namespace = display_namespace(@namespace)
511
+ Appsignal::Metrics::OpenTelemetryBackend.increment_counter(
512
+ "transaction_allocation_count", count, :namespace => namespace
513
+ )
514
+ Appsignal::Metrics::OpenTelemetryBackend.increment_counter(
515
+ "transaction_allocation_count", count,
516
+ :namespace => namespace, :action => @action
517
+ )
518
+ end
519
+
520
+ # Sets a finished event's allocation counts and rolls its full count up to
521
+ # its parent. `appsignal.allocation_count` covers the event's whole
522
+ # subtree; `appsignal.self_allocation_count` excludes its children, so
523
+ # allocations can be attributed to a layer without walking the span tree.
524
+ #
525
+ # Only the immediate parent is updated, because each event's full count
526
+ # already includes its whole subtree. Nothing is set when allocation
527
+ # tracking is off.
528
+ def write_event_allocation_count(frame)
529
+ return unless frame.allocation_start
530
+
531
+ full = Appsignal::Extension.allocation_count - frame.allocation_start
532
+ return if allocation_count_reversed?(full)
533
+
534
+ self_count = full - frame.child_allocation_count
535
+ # Roll the full count up to the parent so it can compute its own self.
536
+ # A top-level event has no parent event; its full count belongs to the
537
+ # transaction, so credit the root accumulator instead.
538
+ if (parent = @event_stack.last)
539
+ parent.child_allocation_count += full
540
+ else
541
+ @root_child_allocation_count += full
542
+ end
543
+ frame.span.set_attribute("appsignal.allocation_count", full)
544
+ frame.span.set_attribute("appsignal.self_allocation_count", self_count)
545
+ end
546
+
547
+ # The allocation counter is thread-local and only ever increases, so a
548
+ # negative delta means the transaction or event finished on a different
549
+ # thread than it started on. The count is then meaningless, so warn and
550
+ # tell the caller to drop it rather than report a wrong value.
551
+ def allocation_count_reversed?(delta)
552
+ return false unless delta.negative?
553
+
554
+ Appsignal.internal_logger.warn(
555
+ "Not reporting an allocation count in transaction " \
556
+ "'#{@transaction_id}'. The thread-local allocation counter decreased " \
557
+ "between the start and finish, which happens when the work starts and " \
558
+ "finishes on different threads."
559
+ )
560
+ true
561
+ end
562
+
563
+ # The thread's cumulative object allocation count, or nil when allocation
564
+ # tracking is off. Callers snapshot this at a start boundary and subtract
565
+ # it from a later read to get the allocations made in between; a nil
566
+ # snapshot disables allocation reporting for that transaction or event.
567
+ def current_allocation_count
568
+ Appsignal::Extension.allocation_count if allocation_tracking?
569
+ end
570
+
571
+ # Allocation tracking runs only when enabled by config and not on JRuby,
572
+ # matching the condition under which `Appsignal.start` installs the
573
+ # allocation event hook that feeds the counter.
574
+ def allocation_tracking?
575
+ return false unless Appsignal.config&.[](:enable_allocation_tracking)
576
+
577
+ !Appsignal::System.jruby?
578
+ end
579
+
580
+ def hostname
581
+ Appsignal.config&.[](:hostname) || Socket.gethostname
582
+ end
583
+
584
+ # Resolve the tracer for an instrumentation scope. `scope` is a
585
+ # `[name, version]` pair supplied by the integration that created the
586
+ # span, or nil. A nil scope, a nil/blank name, or a nil version each fall
587
+ # back to the default AppSignal scope, so every span always carries a
588
+ # scope (the collector drops scope-less spans). The tracer provider caches
589
+ # tracers by `(name, version)`, so this resolves rather than rebuilds.
590
+ def tracer_for(scope)
591
+ name, version = scope
592
+ if name.nil? || name.to_s.empty?
593
+ # A nil scope or one with a blank name is unusable, so fall back to
594
+ # the default scope entirely rather than pairing the default name with
595
+ # a stray version.
596
+ name = TRACER_NAME
597
+ version = Appsignal::VERSION
598
+ else
599
+ version ||= Appsignal::VERSION
600
+ end
601
+ ::OpenTelemetry.tracer_provider.tracer(name, version)
602
+ end
603
+
604
+ # The open event span, or the root span when no event is open. Not the OTel
605
+ # current span, which may belong to another instrumentation.
606
+ def current_span
607
+ @event_stack.last&.span || @span
608
+ end
609
+
610
+ # Pushes an open event onto the stack, snapshotting the allocation counter
611
+ # so `finish_event` can measure the event's allocations as the delta since.
612
+ def push_event(span, token)
613
+ @event_stack.push(EventFrame.new(span, token, current_allocation_count, 0))
614
+ end
615
+
616
+ def placeholder_span_name(namespace)
617
+ "appsignal.transaction #{namespace}"
618
+ end
619
+
620
+ # Open the transaction's span, relating it to any incoming trace context
621
+ # by the requested relationship:
622
+ #
623
+ # - `:parent`: parent under the remote span so the transaction continues
624
+ # the upstream trace.
625
+ # - `:link`: start a fresh trace linked back to the remote span. The
626
+ # transaction is its own unit of work decoupled from the caller, so it
627
+ # gets its own trace, with a link recording the causal relationship.
628
+ # - `:both`: parent under the remote span and also link back to it, so the
629
+ # transaction continues the trace and keeps the explicit link.
630
+ # - `:none`: a plain root span that ignores any incoming context.
631
+ #
632
+ # With no context or an invalid remote span, every relationship falls back
633
+ # to a plain root span, since there is nothing to parent or link to.
634
+ def start_transaction_span(namespace, kind, relationship, opentelemetry_context)
635
+ name = placeholder_span_name(namespace)
636
+ remote = remote_span_context(opentelemetry_context)
637
+ tracer = tracer_for(@scope)
638
+
639
+ # With no incoming context (or an invalid remote span) there is nothing
640
+ # to parent or link to, so any relationship is just a plain root span.
641
+ return tracer.start_root_span(name, :kind => kind) unless remote
642
+
643
+ # `:parent` and `:both` continue the trace under the remote span;
644
+ # `:link` and `:both` record a link back to it; `:none` does neither.
645
+ parent = opentelemetry_context if [:parent, :both].include?(relationship)
646
+ links = [::OpenTelemetry::Trace::Link.new(remote)] if [:link, :both].include?(relationship)
647
+
648
+ if parent
649
+ tracer.start_span(name, :with_parent => parent, :kind => kind, :links => links)
650
+ else
651
+ tracer.start_root_span(name, :kind => kind, :links => links)
652
+ end
653
+ end
654
+
655
+ # Returns the given option when it is one of the allowed values, the
656
+ # default when it is nil, or the default with a warning when it is an
657
+ # unknown value. Keeps an unexpected `opentelemetry_kind` from raising
658
+ # inside span creation, and an unexpected `opentelemetry_relationship`
659
+ # from silently dropping the incoming context.
660
+ def validated_option(value, allowed, default, name)
661
+ return default if value.nil?
662
+ return value if allowed.include?(value)
663
+
664
+ # A bad value is usually a static mistake passed on every transaction,
665
+ # so warn once per process to avoid flooding the log. Dedup on the
666
+ # option and value, and build the message -- including walking the
667
+ # stack for the caller location -- only when actually warning.
668
+ self.class.warn_once("#{name}: #{value.inspect}") do
669
+ "Unknown #{name} #{value.inspect} passed at #{option_caller_location}, " \
670
+ "falling back to #{default.inspect}. " \
671
+ "Expected one of: #{allowed.map(&:inspect).join(", ")}."
672
+ end
673
+ default
674
+ end
675
+
676
+ # The first caller frame outside the gem: where the invalid value was
677
+ # passed to `Transaction.create`, `Appsignal.monitor`, etc. Falls back to
678
+ # the immediate caller if every frame is inside the gem. Only walks the
679
+ # stack when a warning is actually emitted (see `validated_option`).
680
+ def option_caller_location
681
+ frames = caller
682
+ frames.find { |frame| !frame.include?("/lib/appsignal/") } || frames.first
683
+ end
684
+
685
+ # The remote parent's SpanContext from an incoming OTel context, or nil
686
+ # when there is no context or the remote span is invalid -- in which case
687
+ # callers fall back to a plain root span.
688
+ def remote_span_context(opentelemetry_context)
689
+ return unless opentelemetry_context
690
+
691
+ context = ::OpenTelemetry::Trace.current_span(opentelemetry_context).context
692
+ context if context.valid?
693
+ end
694
+
695
+ def display_namespace(namespace)
696
+ DISPLAY_NAMESPACE.fetch(namespace, namespace)
697
+ end
698
+
699
+ # The transaction's "environment" sample data is a Rack/CGI env allowlist
700
+ # mixing true HTTP headers (HTTP_*, plus CONTENT_LENGTH/CONTENT_TYPE) with
701
+ # non-header CGI vars (REQUEST_METHOD, REQUEST_PATH, PATH_INFO, SERVER_*).
702
+ # Only the true headers map to the OTel `http.request.header.*` convention
703
+ # the collector and trace UI read, so emit those (normalized to lowercase,
704
+ # dashed header names) and drop everything else.
705
+ def write_request_headers(headers)
706
+ headers.each do |key, value|
707
+ name = otel_header_name(key)
708
+ @span.set_attribute("http.request.header.#{name}", value.to_s) if name
709
+ end
710
+ end
711
+
712
+ def otel_header_name(env_key)
713
+ if env_key.start_with?("HTTP_")
714
+ env_key.delete_prefix("HTTP_").downcase.tr("_", "-")
715
+ elsif env_key.start_with?("CONTENT_")
716
+ env_key.downcase.tr("_", "-")
717
+ end
718
+ end
719
+
720
+ # Each tag becomes its own `appsignal.tag.<key>` attribute, which the
721
+ # collector hoists and the trace UI lists under "Tags". `sanitized_tags`
722
+ # already restricts values to String/Symbol/Integer/boolean; OTel
723
+ # attribute values must be primitives, so coerce the Symbol case to a
724
+ # string (the only non-primitive that survives sanitization).
725
+ def write_tags(tags)
726
+ tags.each do |key, value|
727
+ value = value.to_s if value.is_a?(Symbol)
728
+ @span.set_attribute("appsignal.tag.#{key}", value)
729
+ end
730
+ end
731
+
732
+ # Returns the leading lines of a cause's backtrace that the reported
733
+ # error's backtrace does not already end with, and how many trailing lines
734
+ # were dropped to get there.
735
+ #
736
+ # A cause shares its trailing frames with the error it led to, and those
737
+ # are already sent in `exception.stacktrace`. Repeating them for every
738
+ # cause makes `appsignal.error_causes` too long for the collector to read.
739
+ #
740
+ # If every line is shared, one is kept, because a cause with no lines
741
+ # leaves the UI nothing to show. That kept line does not count as dropped.
742
+ def trim_shared_tail(cause_lines, error_lines)
743
+ shared = 0
744
+ while shared < cause_lines.length && shared < error_lines.length &&
745
+ cause_lines[-1 - shared] == error_lines[-1 - shared]
746
+ shared += 1
747
+ end
748
+
749
+ return [cause_lines, 0] if shared.zero?
750
+
751
+ kept = [cause_lines.length - shared, 1].max
752
+ [cause_lines.first(kept), cause_lines.length - kept]
753
+ end
754
+
755
+ # The OTel span name is what the collector surfaces as the event's
756
+ # label in the trace UI. The AS::N `name` (e.g. "sql.active_record")
757
+ # always leads the span name so it stays visible. When a formatter
758
+ # supplied a human-readable `title` (e.g. "User Load", "GET
759
+ # https://example.com"), it follows in parentheses, giving
760
+ # "sql.active_record (User Load)". Some integrations pass the event
761
+ # name as the title as well; in that case the name is not repeated.
762
+ def write_event_span_name(span, name, title)
763
+ has_title = title && !title.empty? && title != name
764
+ span.name = has_title ? "#{name} (#{title})" : name
765
+ end
766
+
767
+ def write_event_body_attributes(span, body, body_format, db_system_name_set)
768
+ has_body = !body.to_s.empty?
769
+
770
+ if body_format == Appsignal::EventFormatter::SQL_BODY_FORMAT
771
+ # Name the datastore whether or not there is a query to record with it.
772
+ # The semantic conventions require the attribute on every database
773
+ # span, and a SQL event with nothing in its body is still a SQL event.
774
+ # Only fall back to the sentinel when nothing set a real engine name
775
+ # earlier in the event, so an integration's own `db.system.name`
776
+ # always wins over it.
777
+ span.set_attribute("db.system.name", SQL_DB_SYSTEM) unless db_system_name_set
778
+ span.set_attribute("db.query.text", body) if has_body
779
+ elsif has_body
780
+ span.set_attribute("appsignal.body", body)
781
+ end
782
+ end
783
+
784
+ # Whether a formatted attributes hash names a real `db.system.name`,
785
+ # as opposed to merely having the key. `Attributes.format` coerces an
786
+ # explicit `nil` (or any other non-primitive) to `""`, so the key can
787
+ # be present with a blank value. A blank value must not count as set:
788
+ # it would block the SQL sentinel the same way a real value should,
789
+ # but leave the span with nothing the collector's sanitizer
790
+ # recognizes, instead of the sentinel that keeps sanitization on.
791
+ def named_db_system?(formatted_attributes)
792
+ !formatted_attributes["db.system.name"].to_s.empty?
793
+ end
794
+ end
795
+ end
796
+ end