instana 2.7.2 → 2.9.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 75c9fe7c6b744ebafc10428dbbfdcfe046fdcfc80b31227b29b4a18ade7ec5dd
4
- data.tar.gz: 94b10d48f251f49157203534306399fed404adf619318cdebb0ff1864a74fc41
3
+ metadata.gz: 52139723659935eb43ab59f60e5c0f7305d860d5457c9545d395f9e4576fc604
4
+ data.tar.gz: 223dcdbe22af35ceed817411b850114f9cf3e7eb5fce3b3fc1b4f5ffa29c00e1
5
5
  SHA512:
6
- metadata.gz: 56ff7ee602ef57a060bc1e467e11dc5466469433c54abc6457bb1959c4f5abf82d491b5db272b4cf45bea72335c28a0b9213357c44bf90543c123d424743f9f7
7
- data.tar.gz: 811b2bd02833ec5127fdfeee050cab1ce3c1410e120ecd4452c41207b4c7a4fbc11f390ba8a62181f7a56fd3cd073392ca5115a68bfeb3a200f84ad5812a8ba7
6
+ metadata.gz: 0743a4a453182c90abb623189d4a6e5105cd03e3823b3b6cf9ee5269c80f14652d21bfc5481c914457a79a8a52633c718cfd1581d219122327996cb67c2b222f
7
+ data.tar.gz: 767f9eb2f714e83e045f27086061ef11ab3511574b8636c87740595f871a0051ccc977341e0e523f174d4f4d5a4575fbcd3d8b87ed1ab26b347198fbdb3d4d94
@@ -17,18 +17,18 @@ module Instana
17
17
  @last_minor_count = 0
18
18
  end
19
19
 
20
- def report
20
+ def report(poll_rate = 1)
21
21
  stats = ::GC.stat
22
22
  total_time = ::GC::Profiler.total_time * 1000
23
23
 
24
24
  ::GC::Profiler.clear
25
25
 
26
26
  payload = {
27
- totalTime: total_time,
27
+ totalTime: total_time / poll_rate,
28
28
  heap_live: stats[:heap_live_slots] || stats[:heap_live_num],
29
29
  heap_free: stats[:heap_free_slots] || stats[:heap_free_num],
30
- minorGcs: stats[:minor_gc_count] - @last_minor_count,
31
- majorGcs: stats[:major_gc_count] - @last_major_count
30
+ minorGcs: (stats[:minor_gc_count] - @last_minor_count) / poll_rate.to_f,
31
+ majorGcs: (stats[:major_gc_count] - @last_major_count) / poll_rate.to_f
32
32
  }
33
33
 
34
34
  @last_major_count = stats[:major_gc_count]
@@ -1,5 +1,7 @@
1
1
  # (c) Copyright IBM Corp. 2021
2
2
  # (c) Copyright Instana Inc. 2021
3
+ require 'opentelemetry/exporter/otlp'
4
+ require_relative '../exporter/otlp/converter_factory'
3
5
 
4
6
  module Instana
5
7
  module Backend
@@ -22,7 +24,7 @@ module Instana
22
24
  @timer_class = timer_class
23
25
  @nonce = Time.now
24
26
  @processor = processor
25
-
27
+ initialize_otlp_exporter
26
28
  # Initialize timers with default 1 second interval
27
29
  @metrics_timer = @timer_class.new(execution_interval: 1, run_now: true) { report_metrics_to_backend }
28
30
  @traces_timer = @timer_class.new(execution_interval: 1, run_now: true) { report_traces_to_backend }
@@ -36,6 +38,8 @@ module Instana
36
38
  if new_version.nil?
37
39
  @metrics_timer&.shutdown
38
40
  @traces_timer&.shutdown
41
+ @otlp_exporter&.shutdown
42
+ @otlp_exporter = nil
39
43
  else
40
44
  # Read poll_rate from discovery payload - it's nested under plugin.ruby.poll_rate
41
45
  discovery = @discovery.value
@@ -90,10 +94,22 @@ module Instana
90
94
  path = format(TRACES_DATA_URL, discovery['pid'])
91
95
 
92
96
  @processor.send do |spans|
93
- response = @client.send_request('POST', path, spans)
97
+ success = false
98
+ if @otlp_exporter
99
+ converted_spans = spans.map do |span|
100
+ ::Instana::Exporter::Otlp::ConverterFactory.create(span).convert
101
+ end
102
+ result_code = @otlp_exporter.export(converted_spans)
103
+ Instana.logger.debug("Using OTLP Exporter to export result code: #{result_code}")
104
+ success = result_code == OpenTelemetry::SDK::Trace::Export::SUCCESS
105
+ else
106
+ response = @client.send_request('POST', path, spans)
107
+ Instana.logger.debug("Using Instana Native Exporter to export result code: #{response}")
108
+ success = response&.ok?
109
+ end
94
110
 
95
- unless response.ok?
96
- @logger.warn("Failed to send `#{spans.count}` spans to `#{path}`. Response: #{response.code} - #{response.body}")
111
+ unless success
112
+ @logger.warn("Failed to send `#{spans.count}` spans to `#{path}`.")
97
113
  trigger_rediscovery
98
114
  break
99
115
  end
@@ -145,7 +161,8 @@ module Instana
145
161
  end
146
162
 
147
163
  if ::Instana.config[:metrics][:gc][:enabled]
148
- payload[:gc] = GCSnapshot.instance.report
164
+ poll_rate = discovery&.dig('plugin', 'ruby', 'poll_rate') || 1
165
+ payload[:gc] = GCSnapshot.instance.report(poll_rate)
149
166
  end
150
167
 
151
168
  if ::Instana.config[:metrics][:thread][:enabled]
@@ -159,6 +176,44 @@ module Instana
159
176
  @discovery.swap { nil }
160
177
  ::Instana.agent.announce
161
178
  end
179
+
180
+ def initialize_otlp_exporter
181
+ config = ::Instana.config[:otlp]
182
+ unless config[:enabled]
183
+ @otlp_exporter = nil
184
+ return
185
+ end
186
+
187
+ endpoint = resolve_otlp_endpoint(config[:endpoint], config[:config_source])
188
+ opts = { endpoint: endpoint, timeout: config[:timeout] / 1000.0 }
189
+ opts[:compression] = config[:compression] if config[:compression]
190
+ opts[:headers] = config[:headers] if config[:headers]&.any?
191
+ opts[:certificate_file] = config[:certificate] if config[:certificate]
192
+ opts[:client_certificate_file] = config[:client_certificate] if config[:client_certificate]
193
+ opts[:client_key_file] = config[:client_key] if config[:client_key]
194
+
195
+ @otlp_exporter = OpenTelemetry::Exporter::OTLP::Exporter.new(**opts)
196
+ rescue StandardError => e
197
+ @logger.error("Failed to initialize OTLP exporter: #{e.message}")
198
+ @otlp_exporter = nil
199
+ end
200
+
201
+ # Derive the OTLP endpoint from the discovered agent host when no explicit
202
+ # endpoint has been configured (config_source == 'default').
203
+ OTLP_DEFAULT_PORT = 4318
204
+ OTLP_TRACES_PATH = '/v1/traces'.freeze
205
+
206
+ def resolve_otlp_endpoint(endpoint, config_source)
207
+ return endpoint unless config_source == 'default'
208
+
209
+ # Use the host that was discovered by HostAgentLookup (same host the
210
+ # metrics/traces client is already talking to) and append the standard
211
+ # OTLP HTTP port and traces path.
212
+ agent_host = @client&.host
213
+ return endpoint unless agent_host
214
+
215
+ "http://#{agent_host}:#{OTLP_DEFAULT_PORT}#{OTLP_TRACES_PATH}"
216
+ end
162
217
  end
163
218
  end
164
219
  end
@@ -5,16 +5,20 @@ require 'yaml'
5
5
 
6
6
  module Instana
7
7
  class Config
8
- def initialize(logger: ::Instana.logger, agent_host: ENV['INSTANA_AGENT_HOST'], agent_port: ENV['INSTANA_AGENT_PORT'])
8
+
9
+ LEGACY_TRACING_KEY = 'com.instana.tracing'.freeze
10
+ TRACING_KEY = 'tracing'.freeze
11
+
12
+ def initialize(logger: ::Instana.logger, agent_host: ENV.fetch('INSTANA_AGENT_HOST', nil), agent_port: ENV.fetch('INSTANA_AGENT_PORT', nil)) # rubocop:disable Metrics/MethodLength
9
13
  @config = {}
10
14
  if agent_host
11
- logger.debug "Using custom agent host location specified in INSTANA_AGENT_HOST (#{ENV['INSTANA_AGENT_HOST']})"
15
+ logger.debug "Using custom agent host location specified in INSTANA_AGENT_HOST (#{agent_host})"
12
16
  @config[:agent_host] = agent_host
13
17
  else
14
18
  @config[:agent_host] = '127.0.0.1'
15
19
  end
16
20
  if agent_port
17
- logger.debug "Using custom agent port specified in INSTANA_AGENT_PORT (#{ENV['INSTANA_AGENT_PORT']})"
21
+ logger.debug "Using custom agent port specified in INSTANA_AGENT_PORT (#{agent_port})"
18
22
  @config[:agent_port] = agent_port
19
23
  else
20
24
  @config[:agent_port] = 42699
@@ -30,7 +34,7 @@ module Instana
30
34
  @config[:tracing] = { :enabled => true }
31
35
 
32
36
  # Enable/disable tracing exit spans as root spans
33
- @config[:allow_exit_as_root] = ENV['INSTANA_ALLOW_EXIT_AS_ROOT'] == '1'
37
+ @config[:allow_exit_as_root] = ENV.fetch('INSTANA_ALLOW_EXIT_AS_ROOT', nil) == '1'
34
38
 
35
39
  # Enable/Disable logging
36
40
  @config[:logging] = { :enabled => true }
@@ -49,6 +53,20 @@ module Instana
49
53
  # @config[:back_trace] = { stack_trace_level: nil }
50
54
  read_span_stack_config
51
55
 
56
+ # OTLP exporter configuration (default: disabled)
57
+ @config[:otlp] = {
58
+ enabled: false,
59
+ endpoint: 'http://localhost:4318/v1/traces',
60
+ timeout: 10_000,
61
+ compression: nil,
62
+ headers: {},
63
+ certificate: nil,
64
+ client_key: nil,
65
+ client_certificate: nil,
66
+ config_source: 'default'
67
+ }
68
+ read_otlp_config
69
+
52
70
  # By default, collected SQL will be sanitized to remove potentially sensitive bind params such as:
53
71
  # > SELECT "blocks".* FROM "blocks" WHERE "blocks"."name" = "Mr. Smith"
54
72
  #
@@ -60,7 +78,11 @@ module Instana
60
78
  @config[:sanitize_sql] = true
61
79
 
62
80
  # W3C Trace Context Support
63
- @config[:w3c_trace_correlation] = ENV['INSTANA_DISABLE_W3C_TRACE_CORRELATION'].nil?
81
+ @config[:w3c_trace_correlation] = ENV.fetch('INSTANA_DISABLE_W3C_TRACE_CORRELATION', nil).nil?
82
+
83
+ # HTTP exit span 4xx error classification (opt-in, default: off)
84
+ # Priority: env vars > agent config > default (false / [])
85
+ read_http_exit_config
64
86
 
65
87
  @config[:post_fork_proc] = proc { ::Instana.agent.spawn_background_thread }
66
88
 
@@ -94,7 +116,7 @@ module Instana
94
116
  # Priority: Environment variables > YAML file > Agent discovery > Defaults
95
117
  def read_span_stack_config
96
118
  # Try environment variables first
97
- if ENV['INSTANA_STACK_TRACE'] || ENV['INSTANA_STACK_TRACE_LENGTH']
119
+ if ENV.fetch('INSTANA_STACK_TRACE', nil) || ENV.fetch('INSTANA_STACK_TRACE_LENGTH', nil)
98
120
  read_span_stack_config_from_env
99
121
  @config[:back_trace_technologies] = {}
100
122
  return
@@ -122,6 +144,10 @@ module Instana
122
144
 
123
145
  # Read stack trace configuration from agent if not already set from YAML or env
124
146
  read_span_stack_config_from_agent(tracing_config) if should_read_from_agent?(:back_trace)
147
+ # Read OTLP configuration from agent if not already set from YAML or env
148
+ read_otlp_config_from_agent(tracing_config) if should_read_from_agent?(:otlp)
149
+ # Read HTTP exit 4xx classification from agent if env vars did not already set it
150
+ read_http_exit_classification_from_agent(tracing_config) unless env_http_exit_classification_set?
125
151
  # Read span filtering configuration from agent
126
152
  ::Instana.span_filtering_config&.read_config_from_agent(discovery)
127
153
  rescue => e
@@ -143,17 +169,17 @@ module Instana
143
169
  # Read stack trace configuration from YAML file
144
170
  # Returns hash with :global and :technologies keys or nil if not found
145
171
  def read_span_stack_config_from_yaml
146
- config_path = ENV['INSTANA_CONFIG_PATH']
172
+ config_path = ENV.fetch('INSTANA_CONFIG_PATH', nil)
147
173
  return nil unless config_path && File.exist?(config_path)
148
174
 
149
175
  begin
150
176
  yaml_content = YAML.safe_load(File.read(config_path))
151
177
 
152
178
  # Support both "tracing" and "com.instana.tracing" as top-level keys
153
- if yaml_content['com.instana.tracing']
154
- ::Instana.logger.warn('Please use "tracing" instead of "com.instana.tracing"')
179
+ if yaml_content[LEGACY_TRACING_KEY]
180
+ ::Instana.logger.warn("Please use \"#{TRACING_KEY}\" instead of \"#{LEGACY_TRACING_KEY}\"")
155
181
  end
156
- tracing_config = yaml_content['tracing'] || yaml_content['com.instana.tracing']
182
+ tracing_config = yaml_content[TRACING_KEY] || yaml_content[LEGACY_TRACING_KEY]
157
183
  return nil unless tracing_config
158
184
 
159
185
  result = {}
@@ -178,8 +204,8 @@ module Instana
178
204
  # Read stack trace configuration from environment variables
179
205
  def read_span_stack_config_from_env
180
206
  @config[:back_trace] = {
181
- stack_trace_level: ENV['INSTANA_STACK_TRACE'] || 'error',
182
- stack_trace_length: ENV['INSTANA_STACK_TRACE_LENGTH']&.to_i || 30,
207
+ stack_trace_level: ENV.fetch('INSTANA_STACK_TRACE', 'error'),
208
+ stack_trace_length: ENV.fetch('INSTANA_STACK_TRACE_LENGTH', 30).to_i,
183
209
  config_source: 'env'
184
210
  }
185
211
  end
@@ -217,8 +243,197 @@ module Instana
217
243
  }
218
244
  end
219
245
 
246
+ # Read OTLP configuration from agent discovery
247
+ # @param tracing_config [Hash] The tracing configuration from discovery
248
+ def read_otlp_config_from_agent(tracing_config)
249
+ otlp_config = tracing_config['otlp']
250
+ return unless otlp_config.is_a?(Hash)
251
+
252
+ @config[:otlp][:enabled] = truthy?(otlp_config['enabled']) unless otlp_config['enabled'].nil?
253
+ @config[:otlp][:endpoint] = otlp_config['endpoint'] if otlp_config['endpoint']
254
+ @config[:otlp][:timeout] = otlp_config['timeout'].to_i if otlp_config['timeout']
255
+ @config[:otlp][:compression] = otlp_config['compression'] if otlp_config['compression']
256
+ @config[:otlp][:headers] = otlp_config['headers'] if otlp_config['headers'].is_a?(Hash)
257
+ @config[:otlp][:certificate] = otlp_config['certificate'] if otlp_config['certificate']
258
+ @config[:otlp][:client_key] = otlp_config['client_key'] if otlp_config['client_key']
259
+ @config[:otlp][:client_certificate] = otlp_config['client_certificate'] if otlp_config['client_certificate']
260
+ @config[:otlp][:config_source] = 'agent'
261
+ end
262
+
220
263
  private
221
264
 
265
+ # Read HTTP exit 4xx error classification config (wrapper — same pattern as read_span_stack_config / read_otlp_config)
266
+ def read_http_exit_config
267
+ @config[:http_exit_classify_all_4xx_as_errors] = false
268
+ @config[:http_exit_classify_as_errors] = []
269
+ read_http_exit_classification_from_env
270
+ end
271
+
272
+ # Read HTTP exit 4xx classification from environment variables (highest priority).
273
+ def read_http_exit_classification_from_env
274
+ classify_codes_raw = ENV.fetch('INSTANA_TRACING_HTTP_EXIT_CLASSIFY_AS_ERRORS', nil)
275
+ if classify_codes_raw
276
+ codes = classify_codes_raw.split(',').filter_map do |part|
277
+ part = part.strip
278
+ if part.match?(/\A\d+\z/)
279
+ code = part.to_i
280
+ if (400..499).cover?(code)
281
+ code
282
+ else
283
+ msg = "Ignoring out-of-range value in INSTANA_TRACING_HTTP_EXIT_CLASSIFY_AS_ERRORS: #{code}, must be 400-499"
284
+ ::Instana.logger.warn(msg)
285
+ nil
286
+ end
287
+ elsif part != ''
288
+ ::Instana.logger.warn("Ignoring non-integer value in INSTANA_TRACING_HTTP_EXIT_CLASSIFY_AS_ERRORS: #{part}")
289
+ nil
290
+ end
291
+ end
292
+ @config[:http_exit_classify_as_errors] = codes unless codes.empty?
293
+ else
294
+ classify_all_raw = ENV.fetch('INSTANA_TRACING_HTTP_EXIT_CLASSIFY_ALL_4XX_AS_ERRORS', nil)
295
+ @config[:http_exit_classify_all_4xx_as_errors] = truthy?(classify_all_raw) if classify_all_raw
296
+ end
297
+ end
298
+
299
+ # Return true if env vars have already configured HTTP exit 4xx classification.
300
+ def env_http_exit_classification_set?
301
+ ENV.key?('INSTANA_TRACING_HTTP_EXIT_CLASSIFY_AS_ERRORS') ||
302
+ ENV.key?('INSTANA_TRACING_HTTP_EXIT_CLASSIFY_ALL_4XX_AS_ERRORS')
303
+ end
304
+
305
+ # Read HTTP exit 4xx classification from agent discovery response (lowest priority).
306
+ # @param tracing_config [Hash] the tracing sub-hash from the discovery payload
307
+ def read_http_exit_classification_from_agent(tracing_config)
308
+ http_cfg = tracing_config['http']
309
+ return unless http_cfg.is_a?(Hash)
310
+
311
+ exit_cfg = http_cfg['exit']
312
+ return unless exit_cfg.is_a?(Hash)
313
+
314
+ if exit_cfg.key?('classify-as-errors')
315
+ codes = Array(exit_cfg['classify-as-errors']).filter_map do |code|
316
+ if code.is_a?(Integer) && (400..499).cover?(code)
317
+ code
318
+ else
319
+ ::Instana.logger.warn("Ignoring invalid value in agent config tracing.http.exit.classify-as-errors: #{code}")
320
+ nil
321
+ end
322
+ end
323
+ @config[:http_exit_classify_as_errors] = codes unless codes.empty?
324
+ elsif exit_cfg.key?('classify-all-4xx-as-errors')
325
+ val = exit_cfg['classify-all-4xx-as-errors']
326
+ if [true, false].include?(val)
327
+ @config[:http_exit_classify_all_4xx_as_errors] = val
328
+ else
329
+ ::Instana.logger.warn("Ignoring non-boolean value in agent config tracing.http.exit.classify-all-4xx-as-errors: #{val}")
330
+ end
331
+ end
332
+ end
333
+
334
+ # Read OTLP configuration — precedence: YAML > env vars > defaults (agent handled separately)
335
+ def read_otlp_config
336
+ # Try YAML first
337
+ yaml_otlp = parse_otlp_config_from_yaml
338
+ if yaml_otlp
339
+ @config[:otlp].merge!(yaml_otlp)
340
+ @config[:otlp][:config_source] = 'yaml'
341
+ return
342
+ end
343
+
344
+ # Try environment variables
345
+ env_otlp = parse_otlp_config_from_env
346
+ if env_otlp
347
+ @config[:otlp].merge!(env_otlp)
348
+ @config[:otlp][:config_source] = 'env'
349
+ end
350
+ # Otherwise leave defaults ('default' config_source), agent can update later
351
+ end
352
+
353
+ # Parse OTLP config from YAML file at INSTANA_CONFIG_PATH under tracing.otlp
354
+ # @return [Hash, nil] merged OTLP settings or nil if not found
355
+ def parse_otlp_config_from_yaml
356
+ config_path = ENV.fetch('INSTANA_CONFIG_PATH', nil)
357
+ return nil unless config_path && File.exist?(config_path)
358
+
359
+ begin
360
+ yaml_content = YAML.safe_load(File.read(config_path))
361
+ tracing_config = yaml_content[TRACING_KEY] || yaml_content[LEGACY_TRACING_KEY]
362
+ return nil unless tracing_config
363
+
364
+ otlp_yaml = tracing_config['otlp']
365
+ return nil unless otlp_yaml.is_a?(Hash)
366
+
367
+ build_otlp_yaml_result(otlp_yaml)
368
+ rescue => e
369
+ ::Instana.logger.warn("Failed to load OTLP configuration from YAML: #{e.message}")
370
+ nil
371
+ end
372
+ end
373
+
374
+ def build_otlp_yaml_result(otlp_yaml)
375
+ result = {}
376
+ result[:enabled] = truthy?(otlp_yaml['enabled']) unless otlp_yaml['enabled'].nil?
377
+ result[:endpoint] = otlp_yaml['endpoint'] if otlp_yaml['endpoint']
378
+ result[:timeout] = otlp_yaml['timeout'].to_i if otlp_yaml['timeout']
379
+ result[:compression] = otlp_yaml['compression'] if otlp_yaml['compression']
380
+ result[:headers] = otlp_yaml['headers'] if otlp_yaml['headers'].is_a?(Hash)
381
+ result[:certificate] = otlp_yaml['certificate'] if otlp_yaml['certificate']
382
+ result[:client_key] = otlp_yaml['client_key'] if otlp_yaml['client_key']
383
+ result[:client_certificate] = otlp_yaml['client_certificate'] if otlp_yaml['client_certificate']
384
+ result.empty? ? nil : result
385
+ end
386
+
387
+ # Parse OTLP config from environment variables
388
+ # @return [Hash, nil] merged OTLP settings or nil if no relevant env vars are set
389
+ def parse_otlp_config_from_env
390
+ raw = otlp_env_vars
391
+ return nil if raw.values.all?(&:nil?)
392
+
393
+ result = {}
394
+ result[:enabled] = truthy?(raw[:enabled_raw]) unless raw[:enabled_raw].nil?
395
+ result[:endpoint] = raw[:endpoint] if raw[:endpoint]
396
+ result[:timeout] = raw[:timeout_raw].to_i if raw[:timeout_raw]
397
+ result[:compression] = raw[:compression] if raw[:compression]
398
+ result[:headers] = parse_otlp_headers(raw[:headers_raw]) if raw[:headers_raw]
399
+ result[:certificate] = raw[:certificate] if raw[:certificate]
400
+ result[:client_key] = raw[:client_key] if raw[:client_key]
401
+ result[:client_certificate] = raw[:client_cert] if raw[:client_cert]
402
+ result
403
+ end
404
+
405
+ # Collect raw OTLP-related environment variable values into a single hash
406
+ # @return [Hash]
407
+ def otlp_env_vars
408
+ {
409
+ enabled_raw: ENV.fetch('INSTANA_TRACING_OTLP_ENABLED', nil),
410
+ endpoint: ENV.fetch('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT', nil) || ENV.fetch('OTEL_EXPORTER_OTLP_ENDPOINT', nil),
411
+ timeout_raw: ENV.fetch('OTEL_EXPORTER_OTLP_TIMEOUT', nil),
412
+ compression: ENV.fetch('OTEL_EXPORTER_OTLP_COMPRESSION', nil),
413
+ headers_raw: ENV.fetch('OTEL_EXPORTER_OTLP_TRACES_HEADERS', nil) || ENV.fetch('OTEL_EXPORTER_OTLP_HEADERS', nil),
414
+ certificate: ENV.fetch('OTEL_EXPORTER_OTLP_CERTIFICATE', nil),
415
+ client_key: ENV.fetch('OTEL_EXPORTER_OTLP_CLIENT_KEY', nil),
416
+ client_cert: ENV.fetch('OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE', nil)
417
+ }
418
+ end
419
+
420
+ # Parse OTEL_EXPORTER_OTLP_HEADERS value (comma-separated key=value pairs) into a Hash
421
+ # @param headers_str [String] e.g. "api-key=secret,x-tenant=tenant1"
422
+ # @return [Hash]
423
+ def parse_otlp_headers(headers_str)
424
+ return {} unless headers_str
425
+
426
+ headers_str.split(',').each_with_object({}) do |pair, hash|
427
+ key, value = pair.split('=', 2)
428
+ hash[key.strip] = value&.strip if key
429
+ end
430
+ end
431
+
432
+ # Normalise a truthy string value to a boolean
433
+ def truthy?(value)
434
+ %w[true 1 yes].include?(value.to_s.downcase)
435
+ end
436
+
222
437
  # Parse global stack trace configuration from a config hash
223
438
  # @param global_config [Hash] The global configuration hash
224
439
  # @param config_source [String] The source of the configuration ('yaml', 'agent', etc.)
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ # (c) Copyright IBM Corp. 2026
4
+
5
+ require_relative 'base_converter'
6
+ require 'opentelemetry/semconv/incubating/messaging'
7
+ require 'opentelemetry/semconv/db'
8
+ require 'opentelemetry/semconv/incubating/db'
9
+
10
+ module Instana
11
+ module Exporter
12
+ module Otlp
13
+ # Converter for AWS SDK spans (SQS, SNS, DynamoDB) to OTLP format
14
+ class AwsConverter < BaseConverter
15
+ # Build OTel-compliant span name for AWS SDK spans
16
+ #
17
+ # Formulas per SPAN_NAME_PATTERNS.txt Section 6:
18
+ # SQS send/publish → "{queue} publish"
19
+ # SQS receive/delete → "{queue} receive"
20
+ # SNS → "{topic} publish"
21
+ # DynamoDB → "DynamoDB.{op}" e.g. "DynamoDB.PutItem"
22
+ # S3 → "S3.{op}" e.g. "S3.PutObject"
23
+ # Lambda invoke → "Lambda.{function}"
24
+ #
25
+ # @return [String] The span name
26
+ def span_name
27
+ data = span[:data] || {}
28
+ sqs_span_name(data[:sqs]) ||
29
+ sns_span_name(data[:sns]) ||
30
+ dynamodb_span_name(data[:dynamodb]) ||
31
+ s3_span_name(data[:s3]) ||
32
+ lambda_span_name(data.dig(:aws, :lambda, :invoke)) ||
33
+ super
34
+ end
35
+
36
+ def convert_attributes
37
+ attributes = {}
38
+ data = span[:data]
39
+ return attributes unless data
40
+
41
+ convert_sqs_attributes(attributes, data[:sqs])
42
+ convert_sns_attributes(attributes, data[:sns])
43
+ convert_dynamodb_attributes(attributes, data[:dynamodb])
44
+ convert_s3_attributes(attributes, data[:s3])
45
+ convert_lambda_attributes(attributes, data.dig(:aws, :lambda, :invoke))
46
+
47
+ attributes
48
+ end
49
+
50
+ private
51
+
52
+ def sqs_span_name(sqs)
53
+ return unless sqs
54
+
55
+ queue = sqs[:queue].to_s.strip
56
+ operation = sqs[:type].to_s =~ /^(delete|receive)/ ? 'receive' : 'publish'
57
+ queue.empty? ? "SQS #{operation}" : "#{queue} #{operation}"
58
+ end
59
+
60
+ def sns_span_name(sns)
61
+ return unless sns
62
+
63
+ topic = sns[:topic].to_s.strip
64
+ topic = sns[:target].to_s.strip if topic.empty?
65
+ topic.empty? ? 'SNS publish' : "#{topic} publish"
66
+ end
67
+
68
+ def dynamodb_span_name(ddb)
69
+ return unless ddb
70
+
71
+ op = ddb[:op].to_s.strip
72
+ op.empty? ? 'DynamoDB' : "DynamoDB.#{op}"
73
+ end
74
+
75
+ def s3_span_name(s3_data)
76
+ return unless s3_data
77
+
78
+ op = s3_data[:op].to_s.strip
79
+ op.empty? ? 'S3' : "S3.#{op}"
80
+ end
81
+
82
+ def lambda_span_name(lambda_data)
83
+ return unless lambda_data
84
+
85
+ fn = lambda_data[:function].to_s.strip
86
+ fn.empty? ? 'Lambda.invoke' : "Lambda.#{fn}"
87
+ end
88
+
89
+ def convert_sqs_attributes(attributes, sqs_data)
90
+ return unless sqs_data
91
+
92
+ add_attribute(attributes, OpenTelemetry::SemConv::Incubating::MESSAGING::MESSAGING_SYSTEM, 'aws_sqs')
93
+ add_attribute(attributes, OpenTelemetry::SemConv::Incubating::MESSAGING::MESSAGING_DESTINATION_NAME, sqs_data[:queue])
94
+ add_attribute(attributes, 'messaging.aws.sqs.message_group_id', sqs_data[:group])
95
+ add_attribute(attributes, OpenTelemetry::SemConv::Incubating::MESSAGING::MESSAGING_BATCH_MESSAGE_COUNT, sqs_data[:size])
96
+ add_attribute(attributes, OpenTelemetry::SemConv::Incubating::MESSAGING::MESSAGING_OPERATION_TYPE, sqs_operation_type(sqs_data[:type]))
97
+ end
98
+
99
+ def sqs_operation_type(type)
100
+ case type.to_s
101
+ when /^send/, /^single\.sync/ then 'send'
102
+ when /^delete/ then 'process'
103
+ when /^create/, /^get/ then 'create'
104
+ else 'send'
105
+ end
106
+ end
107
+
108
+ def convert_sns_attributes(attributes, sns_data)
109
+ return unless sns_data
110
+
111
+ add_attribute(attributes, OpenTelemetry::SemConv::Incubating::MESSAGING::MESSAGING_SYSTEM, 'aws_sns')
112
+ add_attribute(attributes, OpenTelemetry::SemConv::Incubating::MESSAGING::MESSAGING_DESTINATION_NAME, sns_data[:topic])
113
+ add_attribute(attributes, 'messaging.aws.sns.target_arn', sns_data[:target])
114
+ add_attribute(attributes, 'messaging.aws.sns.phone_number', sns_data[:phone])
115
+ add_attribute(attributes, 'messaging.aws.sns.subject', sns_data[:subject])
116
+ add_attribute(attributes, OpenTelemetry::SemConv::Incubating::MESSAGING::MESSAGING_OPERATION_TYPE, 'send')
117
+ end
118
+
119
+ def convert_dynamodb_attributes(attributes, dynamodb_data)
120
+ return unless dynamodb_data
121
+
122
+ add_attribute(attributes, OpenTelemetry::SemConv::DB::DB_SYSTEM_NAME, 'dynamodb')
123
+ add_attribute(attributes, OpenTelemetry::SemConv::DB::DB_OPERATION_NAME, dynamodb_data[:op])
124
+ add_attribute(attributes, OpenTelemetry::SemConv::DB::DB_NAMESPACE, dynamodb_data[:table])
125
+ add_attribute(attributes, 'aws.dynamodb.table_name', dynamodb_data[:table])
126
+ end
127
+
128
+ def convert_s3_attributes(attributes, s3_data)
129
+ return unless s3_data
130
+
131
+ add_attribute(attributes, 'aws.service', 's3')
132
+ add_attribute(attributes, 'aws.s3.bucket', s3_data[:bucket])
133
+ add_attribute(attributes, 'aws.s3.key', s3_data[:key])
134
+ add_attribute(attributes, 'aws.s3.operation', s3_data[:op])
135
+ end
136
+
137
+ def convert_lambda_attributes(attributes, lambda_data)
138
+ return unless lambda_data
139
+
140
+ add_attribute(attributes, 'aws.service', 'lambda')
141
+ add_attribute(attributes, 'aws.lambda.function_name', lambda_data[:function])
142
+ add_attribute(attributes, 'aws.lambda.invocation_type', lambda_data[:type])
143
+ add_attribute(attributes, 'faas.invoked_name', lambda_data[:function])
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end