instana 2.7.2 → 2.8.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.
@@ -0,0 +1,418 @@
1
+ # frozen_string_literal: true
2
+
3
+ # (c) Copyright IBM Corp. 2026
4
+
5
+ require_relative 'resource'
6
+ require 'opentelemetry/trace'
7
+ require 'forwardable'
8
+
9
+ module Instana
10
+ module Exporter
11
+ module Otlp
12
+ # Base class for all OTLP span converters
13
+ #
14
+ # Provides common interface and shared functionality for converting Instana spans
15
+ # to OpenTelemetry Protocol (OTLP) compatible span data objects.
16
+ #
17
+ # @abstract Subclasses should override {#convert_attributes} to provide
18
+ # type-specific attribute conversion logic.
19
+ #
20
+ # @example Creating a custom converter
21
+ # class MyConverter < BaseConverter
22
+ # def convert_attributes
23
+ # attributes = {}
24
+ # add_attribute(attributes, 'custom.field', span[:data][:custom][:field])
25
+ # attributes
26
+ # end
27
+ # end
28
+ class BaseConverter
29
+ # Represents the instrumentation scope (library) that created the span
30
+ InstrumentationScope = Struct.new(:name, :version)
31
+
32
+ # Represents the status of a span (OK, ERROR, or UNSET)
33
+ Status = Struct.new(:code, :description)
34
+
35
+ # Represents a span event (mirrors OpenTelemetry::SDK::Trace::Event)
36
+ #
37
+ # Fields:
38
+ # name [String] - event name
39
+ # attributes [Hash] - key/value attributes attached to the event
40
+ # timestamp [Integer] - Unix nanoseconds
41
+ Event = Struct.new(:name, :attributes, :timestamp)
42
+
43
+ # Adapter to make resource objects compatible with OTLP exporter expectations
44
+ ResourceAdapter = Struct.new(:attributes) do
45
+ # @return [Enumerator] Iterator over resource attributes
46
+ def attribute_enumerator
47
+ attributes.each
48
+ end
49
+ end
50
+
51
+ # Plain object that directly implements the interface expected by
52
+ # OpenTelemetry::Exporter::OTLP::Exporter#export
53
+ #
54
+ # This is a simple data structure with the required methods, avoiding
55
+ # unnecessary delegation overhead.
56
+ SpanData = Struct.new(
57
+ :name,
58
+ :trace_id,
59
+ :span_id,
60
+ :parent_span_id,
61
+ :resource,
62
+ :instrumentation_scope,
63
+ :kind,
64
+ :start_timestamp,
65
+ :end_timestamp,
66
+ :attributes,
67
+ :status,
68
+ keyword_init: true
69
+ ) do
70
+ # @return [OpenTelemetry::Trace::Tracestate] Default empty tracestate
71
+ def tracestate
72
+ OpenTelemetry::Trace::Tracestate::DEFAULT
73
+ end
74
+
75
+ # @return [Integer] Number of attributes recorded
76
+ def total_recorded_attributes
77
+ attributes.size
78
+ end
79
+
80
+ # @return [Array] Empty array — error-free spans carry no events
81
+ def events
82
+ EMPTY_ARRAY
83
+ end
84
+
85
+ # @return [Integer] Zero — no events on error-free spans
86
+ def total_recorded_events
87
+ 0
88
+ end
89
+
90
+ # @return [Array] Empty array (links not currently supported)
91
+ def links
92
+ EMPTY_ARRAY
93
+ end
94
+
95
+ # @return [Integer] Zero (links not currently supported)
96
+ def total_recorded_links
97
+ 0
98
+ end
99
+
100
+ # @return [Boolean] False (remote parent detection not implemented)
101
+ def parent_span_is_remote
102
+ false
103
+ end
104
+
105
+ # @return [OpenTelemetry::Trace::TraceFlags] Default trace flags
106
+ def trace_flags
107
+ OpenTelemetry::Trace::TraceFlags::DEFAULT
108
+ end
109
+
110
+ EMPTY_ARRAY = [].freeze # rubocop:disable Lint/ConstantDefinitionInBlock
111
+ end
112
+
113
+ # SpanData extended with an optional list of span events
114
+ #
115
+ # We wrap the base struct to carry events without changing the positional
116
+ # keyword_init constructor used by all converters.
117
+ SpanDataWithEvents = Struct.new(:span_data, :span_events) do
118
+ extend Forwardable
119
+ def_delegators :span_data,
120
+ :name, :trace_id, :span_id, :parent_span_id,
121
+ :resource, :instrumentation_scope, :kind,
122
+ :start_timestamp, :end_timestamp, :attributes, :status,
123
+ :tracestate, :total_recorded_attributes,
124
+ :links, :total_recorded_links,
125
+ :parent_span_is_remote, :trace_flags
126
+
127
+ def events
128
+ span_events
129
+ end
130
+
131
+ def total_recorded_events
132
+ span_events.size
133
+ end
134
+ end
135
+
136
+ # Milliseconds to nanoseconds conversion factor
137
+ MS_TO_NS = 1_000_000
138
+ private_constant :MS_TO_NS
139
+
140
+ # @param span [Instana::Trace::Span] The span to convert
141
+ # @param resource [Object, nil] Optional resource information (defaults to global resource)
142
+ def initialize(span, resource = nil)
143
+ @span = span
144
+ @resource = resource || Resource.instance
145
+ end
146
+
147
+ # Convert the Instana span to OTLP-compatible span data
148
+ #
149
+ # @return [SpanData, SpanDataWithEvents] Converted span data object ready for export
150
+ def convert
151
+ # Resolve error info once — reused by both status and events
152
+ error_count = span[:ec].to_i
153
+ error_msg = error_count.positive? ? extract_error_message : nil
154
+ stacktrace = error_count.positive? ? convert_stack_trace : nil
155
+
156
+ span_data = SpanData.new(
157
+ name: span_name,
158
+ trace_id: format_trace_id(span[:t]),
159
+ span_id: format_span_id(span[:s]),
160
+ parent_span_id: format_parent_span_id,
161
+ resource: resource_adapter,
162
+ instrumentation_scope: instrumentation_scope,
163
+ kind: convert_span_kind,
164
+ start_timestamp: convert_to_unix_nano(span[:ts]),
165
+ end_timestamp: calculate_end_timestamp,
166
+ attributes: convert_attributes,
167
+ status: build_status(error_count, error_msg)
168
+ )
169
+
170
+ events = build_error_events(error_count, error_msg, stacktrace)
171
+ return SpanDataWithEvents.new(span_data, events) unless events.empty?
172
+
173
+ span_data
174
+ end
175
+
176
+ protected
177
+
178
+ attr_reader :span, :resource
179
+
180
+ # Format trace ID to the expected 16-byte binary format
181
+ #
182
+ # @param trace_id [String, nil] The trace ID as hex string
183
+ # @return [String] Formatted trace ID as 16-byte binary string
184
+ def format_trace_id(trace_id)
185
+ return OpenTelemetry::Trace::INVALID_TRACE_ID unless trace_id
186
+
187
+ # Pad to 32 hex characters (16 bytes) and convert to binary
188
+ hex_string = trace_id.to_s.rjust(32, '0')
189
+ [hex_string].pack('H*')
190
+ end
191
+
192
+ # Format span ID to the expected 8-byte binary format
193
+ #
194
+ # @param span_id [String, nil] The span ID as hex string
195
+ # @return [String, nil] Formatted span ID as 8-byte binary string, or nil if input is nil
196
+ def format_span_id(span_id)
197
+ return nil unless span_id
198
+
199
+ # Pad to 16 hex characters (8 bytes) and convert to binary
200
+ hex_string = span_id.to_s.rjust(16, '0')
201
+ [hex_string].pack('H*')
202
+ end
203
+
204
+ # Convert Instana span kind to OpenTelemetry span kind
205
+ #
206
+ # Instana span kinds:
207
+ # 1 = entry/server
208
+ # 2 = exit/client
209
+ # 3 = intermediate/internal
210
+ #
211
+ # @return [Symbol] One of :server, :client, :internal, :producer, or :consumer
212
+ def convert_span_kind
213
+ # Explicit kind takes precedence
214
+ case span[:k]
215
+ when 1 then :server
216
+ when 2 then :client
217
+ when 3 then :internal
218
+ else
219
+ # Infer from span name if no explicit kind
220
+ infer_span_kind_from_name
221
+ end
222
+ end
223
+
224
+ # Convert Instana millisecond timestamps to Unix nanoseconds
225
+ #
226
+ # @param time [Time, Integer, nil] The timestamp (Time object or milliseconds since epoch)
227
+ # @return [Integer] Unix timestamp in nanoseconds
228
+ def convert_to_unix_nano(time)
229
+ case time
230
+ when nil
231
+ 0
232
+ when Integer
233
+ time * MS_TO_NS
234
+ else
235
+ (time.to_f * 1_000_000_000).to_i
236
+ end
237
+ end
238
+
239
+ # Build span status from pre-resolved error info
240
+ #
241
+ # @param error_count [Integer] Span error count (span[:ec].to_i)
242
+ # @param error_msg [String, nil] Pre-extracted error message
243
+ # @return [Status]
244
+ def build_status(error_count, error_msg)
245
+ if error_count.positive?
246
+ Status.new(OpenTelemetry::Trace::Status::ERROR, error_msg.to_s)
247
+ else
248
+ Status.new(OpenTelemetry::Trace::Status::UNSET, '')
249
+ end
250
+ end
251
+
252
+ # Extract error message from span data
253
+ #
254
+ # Searches `span[:data][<type>][:error]` for any span type that
255
+ # carries an error field (e.g. http.error, activerecord.error).
256
+ # Returns the first non-nil value found, truncated to 1024 chars
257
+ # per the OTel status.message recommendation.
258
+ #
259
+ # @return [String, nil] Error message or nil when not present
260
+ def extract_error_message
261
+ data = span[:data]
262
+ return nil unless data.is_a?(Hash)
263
+
264
+ data.each_value do |type_data|
265
+ next unless type_data.is_a?(Hash)
266
+
267
+ msg = type_data[:error]
268
+ return msg.to_s[0, 1024] if msg
269
+ end
270
+
271
+ nil
272
+ end
273
+
274
+ # Convert Instana stack trace to OTel exception.stacktrace string
275
+ #
276
+ # Instana stores stack frames as an Array of Hashes with keys:
277
+ # c: file path, n: line number, m: method name
278
+ #
279
+ # OTel requires a newline-separated string of stack frames.
280
+ #
281
+ # @return [String, nil] Formatted stacktrace or nil if not present
282
+ def convert_stack_trace
283
+ stack = span[:stack]
284
+ return nil unless stack.is_a?(Array) && !stack.empty?
285
+
286
+ stack.map { |frame| "#{frame[:c]}:#{frame[:n]} in #{frame[:m]}" }.join("\n")
287
+ end
288
+
289
+ # Convert span attributes to OTLP-compatible attributes
290
+ #
291
+ # Subclasses should override this method to provide type-specific
292
+ # attribute conversion logic.
293
+ #
294
+ # @return [Hash] Hash of attribute key-value pairs
295
+ def convert_attributes
296
+ {}
297
+ end
298
+
299
+ # Build OTel span events from pre-resolved error info
300
+ #
301
+ # - error_count > 0 AND stacktrace present → "exception" event
302
+ # - error_count > 0, no stack → "error" event
303
+ # - error_count == 0 → []
304
+ #
305
+ # @param error_count [Integer]
306
+ # @param error_msg [String, nil]
307
+ # @param stacktrace [String, nil]
308
+ # @return [Array<Event>]
309
+ def build_error_events(error_count, error_msg, stacktrace)
310
+ return [] unless error_count.positive?
311
+
312
+ timestamp = calculate_end_timestamp
313
+
314
+ if stacktrace
315
+ attrs = { 'exception.type' => span_name }
316
+ attrs['exception.message'] = error_msg if error_msg
317
+ attrs['exception.stacktrace'] = stacktrace
318
+ [Event.new('exception', attrs, timestamp)]
319
+ else
320
+ [Event.new('error', { 'error.type' => span_name }, timestamp)]
321
+ end
322
+ end
323
+
324
+ # Add an attribute to the attributes hash if value is not nil
325
+ #
326
+ # @param attributes [Hash] The attributes hash to add to
327
+ # @param key [String, Symbol] The attribute key
328
+ # @param value [Object] The attribute value
329
+ # @return [void]
330
+ def add_attribute(attributes, key, value)
331
+ return if value.nil?
332
+
333
+ attributes[key] = normalize_attribute_value(value)
334
+ end
335
+
336
+ # Normalize attribute value to OTLP-compatible types
337
+ #
338
+ # OTLP supports: String, Integer, Float, Boolean, and Arrays of these types
339
+ #
340
+ # @param value [Object] The value to normalize
341
+ # @return [String, Integer, Float, Boolean, Array] Normalized value
342
+ def normalize_attribute_value(value)
343
+ case value
344
+ when String, Integer, Float, TrueClass, FalseClass
345
+ value
346
+ when Symbol
347
+ value.to_s
348
+ when Array
349
+ value.map { |item| normalize_attribute_value(item) }
350
+ else
351
+ value.to_s
352
+ end
353
+ end
354
+
355
+ private
356
+
357
+ # Get the span name as a string
358
+ #
359
+ # For custom (SDK) spans the user-supplied name is stored in
360
+ # span[:data][:sdk][:name], not in span[:n] (which is always :sdk).
361
+ # We read that path directly to avoid crashing when sdk data has been
362
+ # overwritten by tests or other code. Non-custom spans use span[:n].
363
+ #
364
+ # @return [String] The span name
365
+ def span_name
366
+ if span.respond_to?(:custom?) ? span.custom? : span[:n]&.to_sym == :sdk
367
+ span[:data]&.dig(:sdk, :name).to_s
368
+ else
369
+ span[:n].to_s
370
+ end
371
+ end
372
+
373
+ # Format parent span ID, returning INVALID_SPAN_ID if no parent
374
+ #
375
+ # @return [String] Formatted parent span ID or INVALID_SPAN_ID
376
+ def format_parent_span_id
377
+ format_span_id(span[:p]) || OpenTelemetry::Trace::INVALID_SPAN_ID
378
+ end
379
+
380
+ # Calculate end timestamp from start time and duration
381
+ #
382
+ # @return [Integer] End timestamp in nanoseconds
383
+ def calculate_end_timestamp
384
+ start_time = span[:ts] || 0
385
+ duration = span[:d] || 0
386
+ convert_to_unix_nano(start_time + duration)
387
+ end
388
+
389
+ # Infer span kind from span name using Instana's span kind registry
390
+ #
391
+ # @return [Symbol] Inferred span kind
392
+ def infer_span_kind_from_name
393
+ name = span[:n]&.to_sym
394
+ return :server if ::Instana::SpanKind::ENTRY_SPANS.include?(name)
395
+ return :client if ::Instana::SpanKind::EXIT_SPANS.include?(name)
396
+
397
+ :internal
398
+ end
399
+
400
+ # Get or create resource adapter for OTLP export
401
+ #
402
+ # @return [Object] Resource adapter with attribute_enumerator method
403
+ def resource_adapter
404
+ return resource if resource.respond_to?(:attribute_enumerator)
405
+
406
+ ResourceAdapter.new(resource)
407
+ end
408
+
409
+ # Get or create instrumentation scope
410
+ #
411
+ # @return [InstrumentationScope] Scope identifying the Instana Ruby sensor
412
+ def instrumentation_scope
413
+ @instrumentation_scope ||= InstrumentationScope.new('instana-ruby', ::Instana::VERSION)
414
+ end
415
+ end
416
+ end
417
+ end
418
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ # (c) Copyright IBM Corp. 2026
4
+
5
+ require_relative 'base_converter'
6
+ require_relative 'http_converter'
7
+ require_relative 'database_converter'
8
+ require_relative 'messaging_converter'
9
+ require_relative 'background_job_converter'
10
+ require_relative 'aws_converter'
11
+ require_relative 'rpc_converter'
12
+ require_relative 'rails_converter'
13
+ require_relative 'graphql_converter'
14
+ require_relative 'custom_converter'
15
+ require_relative '../../trace/span_kind'
16
+
17
+ module Instana
18
+ module Exporter
19
+ module Otlp
20
+ # Factory class for creating appropriate OTLP span converters
21
+ # based on span type
22
+ class ConverterFactory
23
+ # Span type constants
24
+ SPAN_TYPES = {
25
+ http: 'http',
26
+ database: 'database',
27
+ messaging: 'messaging',
28
+ background_job: 'background_job',
29
+ aws: 'aws',
30
+ rpc: 'rpc',
31
+ rails: 'rails',
32
+ graphql: 'graphql',
33
+ custom: 'custom'
34
+ }.freeze
35
+
36
+ class << self
37
+ # Create a converter for the given span
38
+ # @param span [Instana::Trace::Span] The span to convert
39
+ # @return [BaseConverter] An instance of the appropriate converter
40
+ def create(span)
41
+ span_type = determine_span_type(span)
42
+ converter_class = get_converter_class(span_type)
43
+
44
+ converter_class.new(span)
45
+ end
46
+
47
+ private
48
+
49
+ # Determine the type of span based on its attributes
50
+ # @param span [Instana::Trace::Span] The span to analyze
51
+ # @return [String] The span type
52
+ def determine_span_type(span)
53
+ return SPAN_TYPES[:http] if http_span?(span)
54
+ return SPAN_TYPES[:database] if database_span?(span)
55
+ return SPAN_TYPES[:aws] if aws_span?(span)
56
+ return SPAN_TYPES[:background_job] if background_job_span?(span)
57
+ return SPAN_TYPES[:messaging] if messaging_span?(span)
58
+ return SPAN_TYPES[:rails] if rails_span?(span)
59
+ return SPAN_TYPES[:graphql] if graphql_span?(span)
60
+ return SPAN_TYPES[:rpc] if rpc_span?(span)
61
+ return SPAN_TYPES[:custom] if custom_span?(span)
62
+
63
+ nil
64
+ end
65
+
66
+ # Get the appropriate converter class for the span type
67
+ # @param span_type [String] The type of span
68
+ # @return [Class] The converter class
69
+ def get_converter_class(span_type)
70
+ return BaseConverter unless span_type
71
+
72
+ # Convert snake_case to CamelCase (e.g., 'background_job' -> 'BackgroundJob')
73
+ class_name = "#{span_type.split('_').map(&:capitalize).join}Converter"
74
+
75
+ begin
76
+ const_get("Instana::Exporter::Otlp::#{class_name}")
77
+ rescue NameError
78
+ BaseConverter
79
+ end
80
+ end
81
+
82
+ # Check if span is an HTTP span
83
+ # Uses the HTTP_SPANS constant to identify HTTP spans
84
+ def http_span?(span)
85
+ Instana::SpanKind::HTTP_SPANS.include?(span[:n]&.to_sym)
86
+ end
87
+
88
+ # Check if span is a database span
89
+ # Instana native spans always have a name, so we only check the name
90
+ def database_span?(span)
91
+ span[:n]&.match?(/sql|database|query|activerecord|sequel|mongo|redis|dalli/i)
92
+ end
93
+
94
+ # Check if span is an AWS span
95
+ # Note: SQS and SNS are handled by messaging_span? since they're messaging services
96
+ def aws_span?(span)
97
+ span[:n]&.match?(/dynamodb|s3|aws\.lambda/i)
98
+ end
99
+
100
+ # Check if span is a messaging span
101
+ def messaging_span?(span)
102
+ span[:n]&.match?(/sqs|sns|kafka|rabbitmq|message|bunny|shoryuken/i)
103
+ end
104
+
105
+ # Check if span is a background job span
106
+ def background_job_span?(span)
107
+ span[:n]&.match?(/sidekiq-(client|worker)|resque-(client|worker)/i)
108
+ end
109
+
110
+ # Check if span is a Rails span
111
+ def rails_span?(span)
112
+ span[:n]&.match?(/actioncontroller|actionview|actionmailer|render|mail\.actionmailer/i)
113
+ end
114
+
115
+ # Check if span is a GraphQL span
116
+ def graphql_span?(span)
117
+ span[:n]&.match?(/graphql/i)
118
+ end
119
+
120
+ # Check if span is an RPC span
121
+ # Instana native spans always have a name, so we only check the name
122
+ def rpc_span?(span)
123
+ span[:n]&.match?(/grpc|rpc/i)
124
+ end
125
+
126
+ # Check if span is an Instana SDK custom span
127
+ def custom_span?(span)
128
+ span[:n]&.match?(/custom|sdk/i) ||
129
+ span[:data]&.dig(:sdk, :type)&.to_s == 'custom'
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ # (c) Copyright IBM Corp. 2026
4
+
5
+ module Instana
6
+ module Exporter
7
+ module Otlp
8
+ # Converter for Instana SDK custom spans to OTLP format
9
+ class CustomConverter < BaseConverter
10
+ # Build OTel-compliant span name for custom (SDK) spans
11
+ #
12
+ # Formula per SPAN_NAME_PATTERNS.txt Section 7:
13
+ # Use the user-supplied sdk[:name] when available, otherwise fall back
14
+ # to the internal span type key (span[:n]).
15
+ #
16
+ # @return [String] The span name
17
+ def span_name
18
+ sdk_name = span[:data]&.[](:sdk)&.[](:name).to_s.strip
19
+ sdk_name.empty? ? super : sdk_name
20
+ end
21
+
22
+ def convert_attributes
23
+ attributes = {}
24
+ sdk_data = span[:data]&.[](:sdk) || {}
25
+
26
+ # Add standard Instana attributes
27
+ add_attribute(attributes, 'instana.span.type', 'custom')
28
+ add_attribute(attributes, 'instana.sdk.name', sdk_data[:name] || span[:n])
29
+ add_attribute(attributes, 'instana.sdk.type', sdk_data[:type])
30
+
31
+ # Add tags directly
32
+ tags = sdk_data.dig(:custom, :tags) || {}
33
+ tags.each do |key, value|
34
+ attributes[key] = value
35
+ end
36
+
37
+ attributes
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end