diff --git a/Rakefile b/Rakefile index 04c897b7..85a23e5a 100644 --- a/Rakefile +++ b/Rakefile @@ -22,7 +22,8 @@ Rake::TestTask.new(:test) do |t| else t.test_files = Dir[ 'test/*_test.rb', - 'test/{agent,trace,backend,snapshot,span_filtering,samplers}/*_test.rb' + 'test/{agent,trace,backend,snapshot,span_filtering}/*_test.rb', + 'test/exporter/otlp/*_test.rb' ] end end diff --git a/instana.gemspec b/instana.gemspec index b637e66a..3f1d00f2 100644 --- a/instana.gemspec +++ b/instana.gemspec @@ -48,7 +48,10 @@ Gem::Specification.new do |spec| spec.add_runtime_dependency('csv', '>= 0.1') spec.add_runtime_dependency('sys-proctable', '>= 1.2.2') spec.add_runtime_dependency('opentelemetry-api', '~> 1.4') + # TODO: pin the versions of otel gems which are actual implementation spec.add_runtime_dependency('opentelemetry-common') + spec.add_runtime_dependency('opentelemetry-semantic_conventions') + spec.add_runtime_dependency('opentelemetry-exporter-otlp') spec.add_runtime_dependency('cgi') spec.add_runtime_dependency('oj', '>=3.0.11') unless RUBY_PLATFORM =~ /java/i end diff --git a/lib/instana/backend/host_agent_reporting_observer.rb b/lib/instana/backend/host_agent_reporting_observer.rb index cbb914e4..beb55252 100644 --- a/lib/instana/backend/host_agent_reporting_observer.rb +++ b/lib/instana/backend/host_agent_reporting_observer.rb @@ -1,5 +1,7 @@ # (c) Copyright IBM Corp. 2021 # (c) Copyright Instana Inc. 2021 +require 'opentelemetry/exporter/otlp' +require_relative '../exporter/otlp/converter_factory' module Instana module Backend @@ -22,7 +24,7 @@ def initialize(client, discovery, logger: ::Instana.logger, timer_class: Concurr @timer_class = timer_class @nonce = Time.now @processor = processor - + initialize_otlp_exporter # Initialize timers with default 1 second interval @metrics_timer = @timer_class.new(execution_interval: 1, run_now: true) { report_metrics_to_backend } @traces_timer = @timer_class.new(execution_interval: 1, run_now: true) { report_traces_to_backend } @@ -36,6 +38,8 @@ def update(time, _old_version, new_version) if new_version.nil? @metrics_timer&.shutdown @traces_timer&.shutdown + @otlp_exporter&.shutdown + @otlp_exporter = nil else # Read poll_rate from discovery payload - it's nested under plugin.ruby.poll_rate discovery = @discovery.value @@ -90,10 +94,22 @@ def report_traces path = format(TRACES_DATA_URL, discovery['pid']) @processor.send do |spans| - response = @client.send_request('POST', path, spans) + success = false + if @otlp_exporter + converted_spans = spans.map do |span| + ::Instana::Exporter::Otlp::ConverterFactory.create(span).convert + end + result_code = @otlp_exporter.export(converted_spans) + Instana.logger.debug("Using OTLP Exporter to export result code: #{result_code}") + success = result_code == OpenTelemetry::SDK::Trace::Export::SUCCESS + else + response = @client.send_request('POST', path, spans) + Instana.logger.debug("Using Instana Native Exporter to export result code: #{response}") + success = response&.ok? + end - unless response.ok? - @logger.warn("Failed to send `#{spans.count}` spans to `#{path}`. Response: #{response.code} - #{response.body}") + unless success + @logger.warn("Failed to send `#{spans.count}` spans to `#{path}`.") trigger_rediscovery break end @@ -159,6 +175,44 @@ def trigger_rediscovery @discovery.swap { nil } ::Instana.agent.announce end + + def initialize_otlp_exporter + config = ::Instana.config[:otlp] + unless config[:enabled] + @otlp_exporter = nil + return + end + + endpoint = resolve_otlp_endpoint(config[:endpoint], config[:config_source]) + opts = { endpoint: endpoint, timeout: config[:timeout] / 1000.0 } + opts[:compression] = config[:compression] if config[:compression] + opts[:headers] = config[:headers] if config[:headers]&.any? + opts[:certificate_file] = config[:certificate] if config[:certificate] + opts[:client_certificate_file] = config[:client_certificate] if config[:client_certificate] + opts[:client_key_file] = config[:client_key] if config[:client_key] + + @otlp_exporter = OpenTelemetry::Exporter::OTLP::Exporter.new(**opts) + rescue StandardError => e + @logger.error("Failed to initialize OTLP exporter: #{e.message}") + @otlp_exporter = nil + end + + # Derive the OTLP endpoint from the discovered agent host when no explicit + # endpoint has been configured (config_source == 'default'). + OTLP_DEFAULT_PORT = 4318 + OTLP_TRACES_PATH = '/v1/traces'.freeze + + def resolve_otlp_endpoint(endpoint, config_source) + return endpoint unless config_source == 'default' + + # Use the host that was discovered by HostAgentLookup (same host the + # metrics/traces client is already talking to) and append the standard + # OTLP HTTP port and traces path. + agent_host = @client&.host + return endpoint unless agent_host + + "http://#{agent_host}:#{OTLP_DEFAULT_PORT}#{OTLP_TRACES_PATH}" + end end end end diff --git a/lib/instana/config.rb b/lib/instana/config.rb index 156f470c..536ddf8b 100644 --- a/lib/instana/config.rb +++ b/lib/instana/config.rb @@ -5,16 +5,20 @@ module Instana class Config - def initialize(logger: ::Instana.logger, agent_host: ENV['INSTANA_AGENT_HOST'], agent_port: ENV['INSTANA_AGENT_PORT']) + + LEGACY_TRACING_KEY = 'com.instana.tracing'.freeze + TRACING_KEY = 'tracing'.freeze + + 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 @config = {} if agent_host - logger.debug "Using custom agent host location specified in INSTANA_AGENT_HOST (#{ENV['INSTANA_AGENT_HOST']})" + logger.debug "Using custom agent host location specified in INSTANA_AGENT_HOST (#{agent_host})" @config[:agent_host] = agent_host else @config[:agent_host] = '127.0.0.1' end if agent_port - logger.debug "Using custom agent port specified in INSTANA_AGENT_PORT (#{ENV['INSTANA_AGENT_PORT']})" + logger.debug "Using custom agent port specified in INSTANA_AGENT_PORT (#{agent_port})" @config[:agent_port] = agent_port else @config[:agent_port] = 42699 @@ -30,7 +34,7 @@ def initialize(logger: ::Instana.logger, agent_host: ENV['INSTANA_AGENT_HOST'], @config[:tracing] = { :enabled => true } # Enable/disable tracing exit spans as root spans - @config[:allow_exit_as_root] = ENV['INSTANA_ALLOW_EXIT_AS_ROOT'] == '1' + @config[:allow_exit_as_root] = ENV.fetch('INSTANA_ALLOW_EXIT_AS_ROOT', nil) == '1' # Enable/Disable logging @config[:logging] = { :enabled => true } @@ -49,6 +53,20 @@ def initialize(logger: ::Instana.logger, agent_host: ENV['INSTANA_AGENT_HOST'], # @config[:back_trace] = { stack_trace_level: nil } read_span_stack_config + # OTLP exporter configuration (default: disabled) + @config[:otlp] = { + enabled: false, + endpoint: 'http://localhost:4318/v1/traces', + timeout: 10_000, + compression: nil, + headers: {}, + certificate: nil, + client_key: nil, + client_certificate: nil, + config_source: 'default' + } + read_otlp_config + # By default, collected SQL will be sanitized to remove potentially sensitive bind params such as: # > SELECT "blocks".* FROM "blocks" WHERE "blocks"."name" = "Mr. Smith" # @@ -60,7 +78,7 @@ def initialize(logger: ::Instana.logger, agent_host: ENV['INSTANA_AGENT_HOST'], @config[:sanitize_sql] = true # W3C Trace Context Support - @config[:w3c_trace_correlation] = ENV['INSTANA_DISABLE_W3C_TRACE_CORRELATION'].nil? + @config[:w3c_trace_correlation] = ENV.fetch('INSTANA_DISABLE_W3C_TRACE_CORRELATION', nil).nil? @config[:post_fork_proc] = proc { ::Instana.agent.spawn_background_thread } @@ -94,7 +112,7 @@ def []=(key, value) # Priority: Environment variables > YAML file > Agent discovery > Defaults def read_span_stack_config # Try environment variables first - if ENV['INSTANA_STACK_TRACE'] || ENV['INSTANA_STACK_TRACE_LENGTH'] + if ENV.fetch('INSTANA_STACK_TRACE', nil) || ENV.fetch('INSTANA_STACK_TRACE_LENGTH', nil) read_span_stack_config_from_env @config[:back_trace_technologies] = {} return @@ -122,6 +140,8 @@ def read_config_from_agent(discovery) # Read stack trace configuration from agent if not already set from YAML or env read_span_stack_config_from_agent(tracing_config) if should_read_from_agent?(:back_trace) + # Read OTLP configuration from agent if not already set from YAML or env + read_otlp_config_from_agent(tracing_config) if should_read_from_agent?(:otlp) # Read span filtering configuration from agent ::Instana.span_filtering_config&.read_config_from_agent(discovery) rescue => e @@ -143,17 +163,17 @@ def read_span_stack_config_from_agent(tracing_config) # Read stack trace configuration from YAML file # Returns hash with :global and :technologies keys or nil if not found def read_span_stack_config_from_yaml - config_path = ENV['INSTANA_CONFIG_PATH'] + config_path = ENV.fetch('INSTANA_CONFIG_PATH', nil) return nil unless config_path && File.exist?(config_path) begin yaml_content = YAML.safe_load(File.read(config_path)) # Support both "tracing" and "com.instana.tracing" as top-level keys - if yaml_content['com.instana.tracing'] - ::Instana.logger.warn('Please use "tracing" instead of "com.instana.tracing"') + if yaml_content[LEGACY_TRACING_KEY] + ::Instana.logger.warn("Please use \"#{TRACING_KEY}\" instead of \"#{LEGACY_TRACING_KEY}\"") end - tracing_config = yaml_content['tracing'] || yaml_content['com.instana.tracing'] + tracing_config = yaml_content[TRACING_KEY] || yaml_content[LEGACY_TRACING_KEY] return nil unless tracing_config result = {} @@ -178,8 +198,8 @@ def read_span_stack_config_from_yaml # Read stack trace configuration from environment variables def read_span_stack_config_from_env @config[:back_trace] = { - stack_trace_level: ENV['INSTANA_STACK_TRACE'] || 'error', - stack_trace_length: ENV['INSTANA_STACK_TRACE_LENGTH']&.to_i || 30, + stack_trace_level: ENV.fetch('INSTANA_STACK_TRACE', 'error'), + stack_trace_length: ENV.fetch('INSTANA_STACK_TRACE_LENGTH', 30).to_i, config_source: 'env' } end @@ -217,8 +237,128 @@ def get_stack_trace_config(technology) } end + # Read OTLP configuration from agent discovery + # @param tracing_config [Hash] The tracing configuration from discovery + def read_otlp_config_from_agent(tracing_config) + otlp_config = tracing_config['otlp'] + return unless otlp_config.is_a?(Hash) + + @config[:otlp][:enabled] = truthy?(otlp_config['enabled']) unless otlp_config['enabled'].nil? + @config[:otlp][:endpoint] = otlp_config['endpoint'] if otlp_config['endpoint'] + @config[:otlp][:timeout] = otlp_config['timeout'].to_i if otlp_config['timeout'] + @config[:otlp][:compression] = otlp_config['compression'] if otlp_config['compression'] + @config[:otlp][:headers] = otlp_config['headers'] if otlp_config['headers'].is_a?(Hash) + @config[:otlp][:certificate] = otlp_config['certificate'] if otlp_config['certificate'] + @config[:otlp][:client_key] = otlp_config['client_key'] if otlp_config['client_key'] + @config[:otlp][:client_certificate] = otlp_config['client_certificate'] if otlp_config['client_certificate'] + @config[:otlp][:config_source] = 'agent' + end + private + # Read OTLP configuration — precedence: YAML > env vars > defaults (agent handled separately) + def read_otlp_config + # Try YAML first + yaml_otlp = parse_otlp_config_from_yaml + if yaml_otlp + @config[:otlp].merge!(yaml_otlp) + @config[:otlp][:config_source] = 'yaml' + return + end + + # Try environment variables + env_otlp = parse_otlp_config_from_env + if env_otlp + @config[:otlp].merge!(env_otlp) + @config[:otlp][:config_source] = 'env' + end + # Otherwise leave defaults ('default' config_source), agent can update later + end + + # Parse OTLP config from YAML file at INSTANA_CONFIG_PATH under tracing.otlp + # @return [Hash, nil] merged OTLP settings or nil if not found + def parse_otlp_config_from_yaml + config_path = ENV.fetch('INSTANA_CONFIG_PATH', nil) + return nil unless config_path && File.exist?(config_path) + + begin + yaml_content = YAML.safe_load(File.read(config_path)) + tracing_config = yaml_content[TRACING_KEY] || yaml_content[LEGACY_TRACING_KEY] + return nil unless tracing_config + + otlp_yaml = tracing_config['otlp'] + return nil unless otlp_yaml.is_a?(Hash) + + build_otlp_yaml_result(otlp_yaml) + rescue => e + ::Instana.logger.warn("Failed to load OTLP configuration from YAML: #{e.message}") + nil + end + end + + def build_otlp_yaml_result(otlp_yaml) + result = {} + result[:enabled] = truthy?(otlp_yaml['enabled']) unless otlp_yaml['enabled'].nil? + result[:endpoint] = otlp_yaml['endpoint'] if otlp_yaml['endpoint'] + result[:timeout] = otlp_yaml['timeout'].to_i if otlp_yaml['timeout'] + result[:compression] = otlp_yaml['compression'] if otlp_yaml['compression'] + result[:headers] = otlp_yaml['headers'] if otlp_yaml['headers'].is_a?(Hash) + result[:certificate] = otlp_yaml['certificate'] if otlp_yaml['certificate'] + result[:client_key] = otlp_yaml['client_key'] if otlp_yaml['client_key'] + result[:client_certificate] = otlp_yaml['client_certificate'] if otlp_yaml['client_certificate'] + result.empty? ? nil : result + end + + # Parse OTLP config from environment variables + # @return [Hash, nil] merged OTLP settings or nil if no relevant env vars are set + def parse_otlp_config_from_env + raw = otlp_env_vars + return nil if raw.values.all?(&:nil?) + + result = {} + result[:enabled] = truthy?(raw[:enabled_raw]) unless raw[:enabled_raw].nil? + result[:endpoint] = raw[:endpoint] if raw[:endpoint] + result[:timeout] = raw[:timeout_raw].to_i if raw[:timeout_raw] + result[:compression] = raw[:compression] if raw[:compression] + result[:headers] = parse_otlp_headers(raw[:headers_raw]) if raw[:headers_raw] + result[:certificate] = raw[:certificate] if raw[:certificate] + result[:client_key] = raw[:client_key] if raw[:client_key] + result[:client_certificate] = raw[:client_cert] if raw[:client_cert] + result + end + + # Collect raw OTLP-related environment variable values into a single hash + # @return [Hash] + def otlp_env_vars + { + enabled_raw: ENV.fetch('INSTANA_TRACING_OTLP_ENABLED', nil), + endpoint: ENV.fetch('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT', nil) || ENV.fetch('OTEL_EXPORTER_OTLP_ENDPOINT', nil), + timeout_raw: ENV.fetch('OTEL_EXPORTER_OTLP_TIMEOUT', nil), + compression: ENV.fetch('OTEL_EXPORTER_OTLP_COMPRESSION', nil), + headers_raw: ENV.fetch('OTEL_EXPORTER_OTLP_TRACES_HEADERS', nil) || ENV.fetch('OTEL_EXPORTER_OTLP_HEADERS', nil), + certificate: ENV.fetch('OTEL_EXPORTER_OTLP_CERTIFICATE', nil), + client_key: ENV.fetch('OTEL_EXPORTER_OTLP_CLIENT_KEY', nil), + client_cert: ENV.fetch('OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE', nil) + } + end + + # Parse OTEL_EXPORTER_OTLP_HEADERS value (comma-separated key=value pairs) into a Hash + # @param headers_str [String] e.g. "api-key=secret,x-tenant=tenant1" + # @return [Hash] + def parse_otlp_headers(headers_str) + return {} unless headers_str + + headers_str.split(',').each_with_object({}) do |pair, hash| + key, value = pair.split('=', 2) + hash[key.strip] = value&.strip if key + end + end + + # Normalise a truthy string value to a boolean + def truthy?(value) + %w[true 1 yes].include?(value.to_s.downcase) + end + # Parse global stack trace configuration from a config hash # @param global_config [Hash] The global configuration hash # @param config_source [String] The source of the configuration ('yaml', 'agent', etc.) diff --git a/lib/instana/exporter/otlp/aws_converter.rb b/lib/instana/exporter/otlp/aws_converter.rb new file mode 100644 index 00000000..d28725bd --- /dev/null +++ b/lib/instana/exporter/otlp/aws_converter.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +# (c) Copyright IBM Corp. 2026 + +require_relative 'base_converter' +require 'opentelemetry/semconv/incubating/messaging' +require 'opentelemetry/semconv/db' +require 'opentelemetry/semconv/incubating/db' + +module Instana + module Exporter + module Otlp + # Converter for AWS SDK spans (SQS, SNS, DynamoDB) to OTLP format + class AwsConverter < BaseConverter + # Build OTel-compliant span name for AWS SDK spans + # + # Formulas per SPAN_NAME_PATTERNS.txt Section 6: + # SQS send/publish → "{queue} publish" + # SQS receive/delete → "{queue} receive" + # SNS → "{topic} publish" + # DynamoDB → "DynamoDB.{op}" e.g. "DynamoDB.PutItem" + # S3 → "S3.{op}" e.g. "S3.PutObject" + # Lambda invoke → "Lambda.{function}" + # + # @return [String] The span name + def span_name + data = span[:data] || {} + sqs_span_name(data[:sqs]) || + sns_span_name(data[:sns]) || + dynamodb_span_name(data[:dynamodb]) || + s3_span_name(data[:s3]) || + lambda_span_name(data.dig(:aws, :lambda, :invoke)) || + super + end + + def convert_attributes + attributes = {} + data = span[:data] + return attributes unless data + + convert_sqs_attributes(attributes, data[:sqs]) + convert_sns_attributes(attributes, data[:sns]) + convert_dynamodb_attributes(attributes, data[:dynamodb]) + convert_s3_attributes(attributes, data[:s3]) + convert_lambda_attributes(attributes, data.dig(:aws, :lambda, :invoke)) + + attributes + end + + private + + def sqs_span_name(sqs) + return unless sqs + + queue = sqs[:queue].to_s.strip + operation = sqs[:type].to_s =~ /^(delete|receive)/ ? 'receive' : 'publish' + queue.empty? ? "SQS #{operation}" : "#{queue} #{operation}" + end + + def sns_span_name(sns) + return unless sns + + topic = sns[:topic].to_s.strip + topic = sns[:target].to_s.strip if topic.empty? + topic.empty? ? 'SNS publish' : "#{topic} publish" + end + + def dynamodb_span_name(ddb) + return unless ddb + + op = ddb[:op].to_s.strip + op.empty? ? 'DynamoDB' : "DynamoDB.#{op}" + end + + def s3_span_name(s3_data) + return unless s3_data + + op = s3_data[:op].to_s.strip + op.empty? ? 'S3' : "S3.#{op}" + end + + def lambda_span_name(lambda_data) + return unless lambda_data + + fn = lambda_data[:function].to_s.strip + fn.empty? ? 'Lambda.invoke' : "Lambda.#{fn}" + end + + def convert_sqs_attributes(attributes, sqs_data) + return unless sqs_data + + add_attribute(attributes, OpenTelemetry::SemConv::Incubating::MESSAGING::MESSAGING_SYSTEM, 'aws_sqs') + add_attribute(attributes, OpenTelemetry::SemConv::Incubating::MESSAGING::MESSAGING_DESTINATION_NAME, sqs_data[:queue]) + add_attribute(attributes, 'messaging.aws.sqs.message_group_id', sqs_data[:group]) + add_attribute(attributes, OpenTelemetry::SemConv::Incubating::MESSAGING::MESSAGING_BATCH_MESSAGE_COUNT, sqs_data[:size]) + add_attribute(attributes, OpenTelemetry::SemConv::Incubating::MESSAGING::MESSAGING_OPERATION_TYPE, sqs_operation_type(sqs_data[:type])) + end + + def sqs_operation_type(type) + case type.to_s + when /^send/, /^single\.sync/ then 'send' + when /^delete/ then 'process' + when /^create/, /^get/ then 'create' + else 'send' + end + end + + def convert_sns_attributes(attributes, sns_data) + return unless sns_data + + add_attribute(attributes, OpenTelemetry::SemConv::Incubating::MESSAGING::MESSAGING_SYSTEM, 'aws_sns') + add_attribute(attributes, OpenTelemetry::SemConv::Incubating::MESSAGING::MESSAGING_DESTINATION_NAME, sns_data[:topic]) + add_attribute(attributes, 'messaging.aws.sns.target_arn', sns_data[:target]) + add_attribute(attributes, 'messaging.aws.sns.phone_number', sns_data[:phone]) + add_attribute(attributes, 'messaging.aws.sns.subject', sns_data[:subject]) + add_attribute(attributes, OpenTelemetry::SemConv::Incubating::MESSAGING::MESSAGING_OPERATION_TYPE, 'send') + end + + def convert_dynamodb_attributes(attributes, dynamodb_data) + return unless dynamodb_data + + add_attribute(attributes, OpenTelemetry::SemConv::DB::DB_SYSTEM_NAME, 'dynamodb') + add_attribute(attributes, OpenTelemetry::SemConv::DB::DB_OPERATION_NAME, dynamodb_data[:op]) + add_attribute(attributes, OpenTelemetry::SemConv::DB::DB_NAMESPACE, dynamodb_data[:table]) + add_attribute(attributes, 'aws.dynamodb.table_name', dynamodb_data[:table]) + end + + def convert_s3_attributes(attributes, s3_data) + return unless s3_data + + add_attribute(attributes, 'aws.service', 's3') + add_attribute(attributes, 'aws.s3.bucket', s3_data[:bucket]) + add_attribute(attributes, 'aws.s3.key', s3_data[:key]) + add_attribute(attributes, 'aws.s3.operation', s3_data[:op]) + end + + def convert_lambda_attributes(attributes, lambda_data) + return unless lambda_data + + add_attribute(attributes, 'aws.service', 'lambda') + add_attribute(attributes, 'aws.lambda.function_name', lambda_data[:function]) + add_attribute(attributes, 'aws.lambda.invocation_type', lambda_data[:type]) + add_attribute(attributes, 'faas.invoked_name', lambda_data[:function]) + end + end + end + end +end diff --git a/lib/instana/exporter/otlp/background_job_converter.rb b/lib/instana/exporter/otlp/background_job_converter.rb new file mode 100644 index 00000000..c40f2354 --- /dev/null +++ b/lib/instana/exporter/otlp/background_job_converter.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +# (c) Copyright IBM Corp. 2026 + +require_relative 'base_converter' +require 'opentelemetry/semconv/incubating/messaging' +require 'opentelemetry/semconv/server' + +module Instana + module Exporter + module Otlp + class BackgroundJobConverter < BaseConverter + # Build OTel-compliant span name for background-job spans + # + # Formula per SPAN_NAME_PATTERNS.txt Section 3 (sidekiq / resque): + # client/producer → "{queue} publish" + # worker/consumer → "{queue} process" + # + # @return [String] The span name + def span_name + span_type = span[:n].to_s + data_key = span_type.to_sym + job_data = span[data_key] || span[:data]&.[](data_key) || {} + + queue = job_data[:queue] || job_data['queue'] + queue = queue.to_s.strip + + operation = span_type.end_with?('-client') ? 'publish' : 'process' + queue.empty? ? operation : "#{queue} #{operation}" + end + + def convert_attributes + attributes = {} + span_type = span[:n].to_s + + if span_type == 'sidekiq-client' # rubocop:disable Style/CaseLikeIf + convert_job_attributes(attributes, span[:'sidekiq-client'] || span[:data]&.[](:'sidekiq-client'), 'sidekiq', 'publish') + elsif span_type == 'sidekiq-worker' + convert_job_attributes(attributes, span[:'sidekiq-worker'] || span[:data]&.[](:'sidekiq-worker'), 'sidekiq', 'process') + elsif span_type == 'resque-client' + convert_job_attributes(attributes, span[:'resque-client'] || span[:data]&.[](:'resque-client'), 'resque', 'publish') + elsif span_type == 'resque-worker' + convert_job_attributes(attributes, span[:'resque-worker'] || span[:data]&.[](:'resque-worker'), 'resque', 'process') + end + + attributes + end + + private + + def convert_job_attributes(attributes, data, system, operation) + return unless data + + add_attribute(attributes, OpenTelemetry::SemConv::Incubating::MESSAGING::MESSAGING_SYSTEM, system) + add_attribute(attributes, OpenTelemetry::SemConv::Incubating::MESSAGING::MESSAGING_DESTINATION_NAME, data[:queue] || data['queue']) + add_attribute(attributes, OpenTelemetry::SemConv::Incubating::MESSAGING::MESSAGING_OPERATION, operation) + add_attribute(attributes, OpenTelemetry::SemConv::Incubating::MESSAGING::MESSAGING_MESSAGE_ID, data[:job_id] || data['job_id']) + add_attribute(attributes, OpenTelemetry::SemConv::Incubating::MESSAGING::MESSAGING_CONSUMER_GROUP_NAME, data[:job] || data['job']) + add_attribute(attributes, OpenTelemetry::SemConv::SERVER::SERVER_ADDRESS, extract_host(data[:'redis-url'] || data['redis-url'])) + add_attribute(attributes, OpenTelemetry::SemConv::SERVER::SERVER_PORT, extract_port(data[:'redis-url'] || data['redis-url'])) + end + + def extract_host(connection) + return nil unless connection + + connection.to_s.split(':').first + end + + def extract_port(connection) + return nil unless connection + + port = connection.to_s.split(':').last + port.to_i if port =~ /^\d+$/ + end + end + end + end +end diff --git a/lib/instana/exporter/otlp/base_converter.rb b/lib/instana/exporter/otlp/base_converter.rb new file mode 100644 index 00000000..1075d24d --- /dev/null +++ b/lib/instana/exporter/otlp/base_converter.rb @@ -0,0 +1,418 @@ +# frozen_string_literal: true + +# (c) Copyright IBM Corp. 2026 + +require_relative 'resource' +require 'opentelemetry/trace' +require 'forwardable' + +module Instana + module Exporter + module Otlp + # Base class for all OTLP span converters + # + # Provides common interface and shared functionality for converting Instana spans + # to OpenTelemetry Protocol (OTLP) compatible span data objects. + # + # @abstract Subclasses should override {#convert_attributes} to provide + # type-specific attribute conversion logic. + # + # @example Creating a custom converter + # class MyConverter < BaseConverter + # def convert_attributes + # attributes = {} + # add_attribute(attributes, 'custom.field', span[:data][:custom][:field]) + # attributes + # end + # end + class BaseConverter + # Represents the instrumentation scope (library) that created the span + InstrumentationScope = Struct.new(:name, :version) + + # Represents the status of a span (OK, ERROR, or UNSET) + Status = Struct.new(:code, :description) + + # Represents a span event (mirrors OpenTelemetry::SDK::Trace::Event) + # + # Fields: + # name [String] - event name + # attributes [Hash] - key/value attributes attached to the event + # timestamp [Integer] - Unix nanoseconds + Event = Struct.new(:name, :attributes, :timestamp) + + # Adapter to make resource objects compatible with OTLP exporter expectations + ResourceAdapter = Struct.new(:attributes) do + # @return [Enumerator] Iterator over resource attributes + def attribute_enumerator + attributes.each + end + end + + # Plain object that directly implements the interface expected by + # OpenTelemetry::Exporter::OTLP::Exporter#export + # + # This is a simple data structure with the required methods, avoiding + # unnecessary delegation overhead. + SpanData = Struct.new( + :name, + :trace_id, + :span_id, + :parent_span_id, + :resource, + :instrumentation_scope, + :kind, + :start_timestamp, + :end_timestamp, + :attributes, + :status, + keyword_init: true + ) do + # @return [OpenTelemetry::Trace::Tracestate] Default empty tracestate + def tracestate + OpenTelemetry::Trace::Tracestate::DEFAULT + end + + # @return [Integer] Number of attributes recorded + def total_recorded_attributes + attributes.size + end + + # @return [Array] Empty array — error-free spans carry no events + def events + EMPTY_ARRAY + end + + # @return [Integer] Zero — no events on error-free spans + def total_recorded_events + 0 + end + + # @return [Array] Empty array (links not currently supported) + def links + EMPTY_ARRAY + end + + # @return [Integer] Zero (links not currently supported) + def total_recorded_links + 0 + end + + # @return [Boolean] False (remote parent detection not implemented) + def parent_span_is_remote + false + end + + # @return [OpenTelemetry::Trace::TraceFlags] Default trace flags + def trace_flags + OpenTelemetry::Trace::TraceFlags::DEFAULT + end + + EMPTY_ARRAY = [].freeze # rubocop:disable Lint/ConstantDefinitionInBlock + end + + # SpanData extended with an optional list of span events + # + # We wrap the base struct to carry events without changing the positional + # keyword_init constructor used by all converters. + SpanDataWithEvents = Struct.new(:span_data, :span_events) do + extend Forwardable + def_delegators :span_data, + :name, :trace_id, :span_id, :parent_span_id, + :resource, :instrumentation_scope, :kind, + :start_timestamp, :end_timestamp, :attributes, :status, + :tracestate, :total_recorded_attributes, + :links, :total_recorded_links, + :parent_span_is_remote, :trace_flags + + def events + span_events + end + + def total_recorded_events + span_events.size + end + end + + # Milliseconds to nanoseconds conversion factor + MS_TO_NS = 1_000_000 + private_constant :MS_TO_NS + + # @param span [Instana::Trace::Span] The span to convert + # @param resource [Object, nil] Optional resource information (defaults to global resource) + def initialize(span, resource = nil) + @span = span + @resource = resource || Resource.instance + end + + # Convert the Instana span to OTLP-compatible span data + # + # @return [SpanData, SpanDataWithEvents] Converted span data object ready for export + def convert + # Resolve error info once — reused by both status and events + error_count = span[:ec].to_i + error_msg = error_count.positive? ? extract_error_message : nil + stacktrace = error_count.positive? ? convert_stack_trace : nil + + span_data = SpanData.new( + name: span_name, + trace_id: format_trace_id(span[:t]), + span_id: format_span_id(span[:s]), + parent_span_id: format_parent_span_id, + resource: resource_adapter, + instrumentation_scope: instrumentation_scope, + kind: convert_span_kind, + start_timestamp: convert_to_unix_nano(span[:ts]), + end_timestamp: calculate_end_timestamp, + attributes: convert_attributes, + status: build_status(error_count, error_msg) + ) + + events = build_error_events(error_count, error_msg, stacktrace) + return SpanDataWithEvents.new(span_data, events) unless events.empty? + + span_data + end + + protected + + attr_reader :span, :resource + + # Format trace ID to the expected 16-byte binary format + # + # @param trace_id [String, nil] The trace ID as hex string + # @return [String] Formatted trace ID as 16-byte binary string + def format_trace_id(trace_id) + return OpenTelemetry::Trace::INVALID_TRACE_ID unless trace_id + + # Pad to 32 hex characters (16 bytes) and convert to binary + hex_string = trace_id.to_s.rjust(32, '0') + [hex_string].pack('H*') + end + + # Format span ID to the expected 8-byte binary format + # + # @param span_id [String, nil] The span ID as hex string + # @return [String, nil] Formatted span ID as 8-byte binary string, or nil if input is nil + def format_span_id(span_id) + return nil unless span_id + + # Pad to 16 hex characters (8 bytes) and convert to binary + hex_string = span_id.to_s.rjust(16, '0') + [hex_string].pack('H*') + end + + # Convert Instana span kind to OpenTelemetry span kind + # + # Instana span kinds: + # 1 = entry/server + # 2 = exit/client + # 3 = intermediate/internal + # + # @return [Symbol] One of :server, :client, :internal, :producer, or :consumer + def convert_span_kind + # Explicit kind takes precedence + case span[:k] + when 1 then :server + when 2 then :client + when 3 then :internal + else + # Infer from span name if no explicit kind + infer_span_kind_from_name + end + end + + # Convert Instana millisecond timestamps to Unix nanoseconds + # + # @param time [Time, Integer, nil] The timestamp (Time object or milliseconds since epoch) + # @return [Integer] Unix timestamp in nanoseconds + def convert_to_unix_nano(time) + case time + when nil + 0 + when Integer + time * MS_TO_NS + else + (time.to_f * 1_000_000_000).to_i + end + end + + # Build span status from pre-resolved error info + # + # @param error_count [Integer] Span error count (span[:ec].to_i) + # @param error_msg [String, nil] Pre-extracted error message + # @return [Status] + def build_status(error_count, error_msg) + if error_count.positive? + Status.new(OpenTelemetry::Trace::Status::ERROR, error_msg.to_s) + else + Status.new(OpenTelemetry::Trace::Status::UNSET, '') + end + end + + # Extract error message from span data + # + # Searches `span[:data][][:error]` for any span type that + # carries an error field (e.g. http.error, activerecord.error). + # Returns the first non-nil value found, truncated to 1024 chars + # per the OTel status.message recommendation. + # + # @return [String, nil] Error message or nil when not present + def extract_error_message + data = span[:data] + return nil unless data.is_a?(Hash) + + data.each_value do |type_data| + next unless type_data.is_a?(Hash) + + msg = type_data[:error] + return msg.to_s[0, 1024] if msg + end + + nil + end + + # Convert Instana stack trace to OTel exception.stacktrace string + # + # Instana stores stack frames as an Array of Hashes with keys: + # c: file path, n: line number, m: method name + # + # OTel requires a newline-separated string of stack frames. + # + # @return [String, nil] Formatted stacktrace or nil if not present + def convert_stack_trace + stack = span[:stack] + return nil unless stack.is_a?(Array) && !stack.empty? + + stack.map { |frame| "#{frame[:c]}:#{frame[:n]} in #{frame[:m]}" }.join("\n") + end + + # Convert span attributes to OTLP-compatible attributes + # + # Subclasses should override this method to provide type-specific + # attribute conversion logic. + # + # @return [Hash] Hash of attribute key-value pairs + def convert_attributes + {} + end + + # Build OTel span events from pre-resolved error info + # + # - error_count > 0 AND stacktrace present → "exception" event + # - error_count > 0, no stack → "error" event + # - error_count == 0 → [] + # + # @param error_count [Integer] + # @param error_msg [String, nil] + # @param stacktrace [String, nil] + # @return [Array] + def build_error_events(error_count, error_msg, stacktrace) + return [] unless error_count.positive? + + timestamp = calculate_end_timestamp + + if stacktrace + attrs = { 'exception.type' => span_name } + attrs['exception.message'] = error_msg if error_msg + attrs['exception.stacktrace'] = stacktrace + [Event.new('exception', attrs, timestamp)] + else + [Event.new('error', { 'error.type' => span_name }, timestamp)] + end + end + + # Add an attribute to the attributes hash if value is not nil + # + # @param attributes [Hash] The attributes hash to add to + # @param key [String, Symbol] The attribute key + # @param value [Object] The attribute value + # @return [void] + def add_attribute(attributes, key, value) + return if value.nil? + + attributes[key] = normalize_attribute_value(value) + end + + # Normalize attribute value to OTLP-compatible types + # + # OTLP supports: String, Integer, Float, Boolean, and Arrays of these types + # + # @param value [Object] The value to normalize + # @return [String, Integer, Float, Boolean, Array] Normalized value + def normalize_attribute_value(value) + case value + when String, Integer, Float, TrueClass, FalseClass + value + when Symbol + value.to_s + when Array + value.map { |item| normalize_attribute_value(item) } + else + value.to_s + end + end + + private + + # Get the span name as a string + # + # For custom (SDK) spans the user-supplied name is stored in + # span[:data][:sdk][:name], not in span[:n] (which is always :sdk). + # We read that path directly to avoid crashing when sdk data has been + # overwritten by tests or other code. Non-custom spans use span[:n]. + # + # @return [String] The span name + def span_name + if span.respond_to?(:custom?) ? span.custom? : span[:n]&.to_sym == :sdk + span[:data]&.dig(:sdk, :name).to_s + else + span[:n].to_s + end + end + + # Format parent span ID, returning INVALID_SPAN_ID if no parent + # + # @return [String] Formatted parent span ID or INVALID_SPAN_ID + def format_parent_span_id + format_span_id(span[:p]) || OpenTelemetry::Trace::INVALID_SPAN_ID + end + + # Calculate end timestamp from start time and duration + # + # @return [Integer] End timestamp in nanoseconds + def calculate_end_timestamp + start_time = span[:ts] || 0 + duration = span[:d] || 0 + convert_to_unix_nano(start_time + duration) + end + + # Infer span kind from span name using Instana's span kind registry + # + # @return [Symbol] Inferred span kind + def infer_span_kind_from_name + name = span[:n]&.to_sym + return :server if ::Instana::SpanKind::ENTRY_SPANS.include?(name) + return :client if ::Instana::SpanKind::EXIT_SPANS.include?(name) + + :internal + end + + # Get or create resource adapter for OTLP export + # + # @return [Object] Resource adapter with attribute_enumerator method + def resource_adapter + return resource if resource.respond_to?(:attribute_enumerator) + + ResourceAdapter.new(resource) + end + + # Get or create instrumentation scope + # + # @return [InstrumentationScope] Scope identifying the Instana Ruby sensor + def instrumentation_scope + @instrumentation_scope ||= InstrumentationScope.new('instana-ruby', ::Instana::VERSION) + end + end + end + end +end diff --git a/lib/instana/exporter/otlp/converter_factory.rb b/lib/instana/exporter/otlp/converter_factory.rb new file mode 100644 index 00000000..7cdaf28f --- /dev/null +++ b/lib/instana/exporter/otlp/converter_factory.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +# (c) Copyright IBM Corp. 2026 + +require_relative 'base_converter' +require_relative 'http_converter' +require_relative 'database_converter' +require_relative 'messaging_converter' +require_relative 'background_job_converter' +require_relative 'aws_converter' +require_relative 'rpc_converter' +require_relative 'rails_converter' +require_relative 'graphql_converter' +require_relative 'custom_converter' +require_relative '../../trace/span_kind' + +module Instana + module Exporter + module Otlp + # Factory class for creating appropriate OTLP span converters + # based on span type + class ConverterFactory + # Span type constants + SPAN_TYPES = { + http: 'http', + database: 'database', + messaging: 'messaging', + background_job: 'background_job', + aws: 'aws', + rpc: 'rpc', + rails: 'rails', + graphql: 'graphql', + custom: 'custom' + }.freeze + + class << self + # Create a converter for the given span + # @param span [Instana::Trace::Span] The span to convert + # @return [BaseConverter] An instance of the appropriate converter + def create(span) + span_type = determine_span_type(span) + converter_class = get_converter_class(span_type) + + converter_class.new(span) + end + + private + + # Determine the type of span based on its attributes + # @param span [Instana::Trace::Span] The span to analyze + # @return [String] The span type + def determine_span_type(span) + return SPAN_TYPES[:http] if http_span?(span) + return SPAN_TYPES[:database] if database_span?(span) + return SPAN_TYPES[:aws] if aws_span?(span) + return SPAN_TYPES[:background_job] if background_job_span?(span) + return SPAN_TYPES[:messaging] if messaging_span?(span) + return SPAN_TYPES[:rails] if rails_span?(span) + return SPAN_TYPES[:graphql] if graphql_span?(span) + return SPAN_TYPES[:rpc] if rpc_span?(span) + return SPAN_TYPES[:custom] if custom_span?(span) + + nil + end + + # Get the appropriate converter class for the span type + # @param span_type [String] The type of span + # @return [Class] The converter class + def get_converter_class(span_type) + return BaseConverter unless span_type + + # Convert snake_case to CamelCase (e.g., 'background_job' -> 'BackgroundJob') + class_name = "#{span_type.split('_').map(&:capitalize).join}Converter" + + begin + const_get("Instana::Exporter::Otlp::#{class_name}") + rescue NameError + BaseConverter + end + end + + # Check if span is an HTTP span + # Uses the HTTP_SPANS constant to identify HTTP spans + def http_span?(span) + Instana::SpanKind::HTTP_SPANS.include?(span[:n]&.to_sym) + end + + # Check if span is a database span + # Instana native spans always have a name, so we only check the name + def database_span?(span) + span[:n]&.match?(/sql|database|query|activerecord|sequel|mongo|redis|dalli/i) + end + + # Check if span is an AWS span + # Note: SQS and SNS are handled by messaging_span? since they're messaging services + def aws_span?(span) + span[:n]&.match?(/dynamodb|s3|aws\.lambda/i) + end + + # Check if span is a messaging span + def messaging_span?(span) + span[:n]&.match?(/sqs|sns|kafka|rabbitmq|message|bunny|shoryuken/i) + end + + # Check if span is a background job span + def background_job_span?(span) + span[:n]&.match?(/sidekiq-(client|worker)|resque-(client|worker)/i) + end + + # Check if span is a Rails span + def rails_span?(span) + span[:n]&.match?(/actioncontroller|actionview|actionmailer|render|mail\.actionmailer/i) + end + + # Check if span is a GraphQL span + def graphql_span?(span) + span[:n]&.match?(/graphql/i) + end + + # Check if span is an RPC span + # Instana native spans always have a name, so we only check the name + def rpc_span?(span) + span[:n]&.match?(/grpc|rpc/i) + end + + # Check if span is an Instana SDK custom span + def custom_span?(span) + span[:n]&.match?(/custom|sdk/i) || + span[:data]&.dig(:sdk, :type)&.to_s == 'custom' + end + end + end + end + end +end diff --git a/lib/instana/exporter/otlp/custom_converter.rb b/lib/instana/exporter/otlp/custom_converter.rb new file mode 100644 index 00000000..3aa6a98c --- /dev/null +++ b/lib/instana/exporter/otlp/custom_converter.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +# (c) Copyright IBM Corp. 2026 + +module Instana + module Exporter + module Otlp + # Converter for Instana SDK custom spans to OTLP format + class CustomConverter < BaseConverter + # Build OTel-compliant span name for custom (SDK) spans + # + # Formula per SPAN_NAME_PATTERNS.txt Section 7: + # Use the user-supplied sdk[:name] when available, otherwise fall back + # to the internal span type key (span[:n]). + # + # @return [String] The span name + def span_name + sdk_name = span[:data]&.[](:sdk)&.[](:name).to_s.strip + sdk_name.empty? ? super : sdk_name + end + + def convert_attributes + attributes = {} + sdk_data = span[:data]&.[](:sdk) || {} + + # Add standard Instana attributes + add_attribute(attributes, 'instana.span.type', 'custom') + add_attribute(attributes, 'instana.sdk.name', sdk_data[:name] || span[:n]) + add_attribute(attributes, 'instana.sdk.type', sdk_data[:type]) + + # Add tags directly + tags = sdk_data.dig(:custom, :tags) || {} + tags.each do |key, value| + attributes[key] = value + end + + attributes + end + end + end + end +end diff --git a/lib/instana/exporter/otlp/database_converter.rb b/lib/instana/exporter/otlp/database_converter.rb new file mode 100644 index 00000000..3b158e29 --- /dev/null +++ b/lib/instana/exporter/otlp/database_converter.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +# (c) Copyright IBM Corp. 2026 + +require_relative 'base_converter' +require 'opentelemetry/semconv/db' +require 'opentelemetry/semconv/server' + +module Instana + module Exporter + module Otlp + # Converter for database spans to OTLP format + class DatabaseConverter < BaseConverter + def convert_attributes + attributes = {} + data = span[:data] || {} + + convert_activerecord_attributes(attributes, data[:activerecord]) + convert_sequel_attributes(attributes, data[:sequel]) + convert_redis_attributes(attributes, data[:redis]) + convert_memcache_attributes(attributes, data[:memcache]) + convert_mongo_attributes(attributes, data[:mongo]) + + attributes + end + + # Build OTel-compliant span name for database spans + # + # Formulas per SPAN_NAME_PATTERNS.txt Section 2: + # activerecord / sequel → "{adapter} {db}" e.g. "mysql2 myapp" + # redis → "redis {command}" e.g. "redis GET" + # memcache → "memcached {command}" e.g. "memcached get" + # mongo → "{namespace}.{command}" e.g. "users.find" + # + # @return [String] The span name + def span_name + data = span[:data] || {} + activerecord_span_name(data[:activerecord]) || + sequel_span_name(data[:sequel]) || + redis_span_name(data[:redis]) || + memcache_span_name(data[:memcache]) || + mongo_span_name(data[:mongo]) || + super + end + + private + + def activerecord_span_name(ar_data) + return unless ar_data + + parts = [ar_data[:adapter], ar_data[:db]].compact.reject(&:empty?) + parts.empty? ? 'activerecord' : parts.join(' ') + end + + def sequel_span_name(seq) + return unless seq + + parts = [seq[:adapter], seq[:db]].compact.reject(&:empty?) + parts.empty? ? 'sequel' : parts.join(' ') + end + + def redis_span_name(redis) + return unless redis + + cmd = redis[:command].to_s.strip + cmd.empty? ? 'redis' : "redis #{cmd}" + end + + def memcache_span_name(mc_data) + return unless mc_data + + cmd = mc_data[:command].to_s.strip + cmd.empty? ? 'memcached' : "memcached #{cmd}" + end + + def mongo_span_name(mongo) + return unless mongo + + ns = mongo[:namespace].to_s.strip + cmd = mongo[:command].to_s.strip + parts = [ns, cmd].reject(&:empty?) + parts.empty? ? 'mongodb' : parts.join('.') + end + + def convert_activerecord_attributes(attributes, ar_data) + return unless ar_data + + add_attribute(attributes, OpenTelemetry::SemConv::DB::DB_SYSTEM_NAME, ar_data[:adapter]) + add_attribute(attributes, OpenTelemetry::SemConv::DB::DB_NAMESPACE, ar_data[:db]) + add_attribute(attributes, OpenTelemetry::SemConv::DB::DB_QUERY_TEXT, ar_data[:sql]) + add_attribute(attributes, OpenTelemetry::SemConv::Incubating::DB::DB_USER, ar_data[:username]) + add_attribute(attributes, OpenTelemetry::SemConv::SERVER::SERVER_ADDRESS, ar_data[:host]) + end + + def convert_sequel_attributes(attributes, seq_data) + return unless seq_data + + add_attribute(attributes, OpenTelemetry::SemConv::DB::DB_SYSTEM_NAME, seq_data[:adapter]) + add_attribute(attributes, OpenTelemetry::SemConv::DB::DB_NAMESPACE, seq_data[:db]) + add_attribute(attributes, OpenTelemetry::SemConv::DB::DB_QUERY_TEXT, seq_data[:sql]) + add_attribute(attributes, OpenTelemetry::SemConv::Incubating::DB::DB_USER, seq_data[:username]) + add_attribute(attributes, OpenTelemetry::SemConv::SERVER::SERVER_ADDRESS, seq_data[:host]) + end + + def convert_redis_attributes(attributes, redis_data) + return unless redis_data + + add_attribute(attributes, OpenTelemetry::SemConv::DB::DB_SYSTEM_NAME, 'redis') + add_attribute(attributes, OpenTelemetry::SemConv::DB::DB_QUERY_TEXT, redis_data[:command]) + add_attribute(attributes, 'db.redis.database_index', redis_data[:db]) + add_attribute(attributes, OpenTelemetry::SemConv::SERVER::SERVER_ADDRESS, extract_host(redis_data[:connection])) + add_attribute(attributes, OpenTelemetry::SemConv::SERVER::SERVER_PORT, extract_port(redis_data[:connection])) + end + + def convert_memcache_attributes(attributes, mc_data) + return unless mc_data + + add_attribute(attributes, OpenTelemetry::SemConv::DB::DB_SYSTEM_NAME, 'memcached') + add_attribute(attributes, OpenTelemetry::SemConv::DB::DB_OPERATION_NAME, mc_data[:command]) + add_attribute(attributes, 'db.memcached.key', mc_data[:key]) + add_attribute(attributes, 'db.memcached.keys', mc_data[:keys]) + add_attribute(attributes, 'db.memcached.namespace', mc_data[:namespace]) + add_attribute(attributes, OpenTelemetry::SemConv::SERVER::SERVER_ADDRESS, extract_host(mc_data[:server])) + add_attribute(attributes, OpenTelemetry::SemConv::SERVER::SERVER_PORT, extract_port(mc_data[:server])) + end + + def convert_mongo_attributes(attributes, mongo_data) + return unless mongo_data + + add_attribute(attributes, OpenTelemetry::SemConv::DB::DB_SYSTEM_NAME, 'mongodb') + add_attribute(attributes, OpenTelemetry::SemConv::DB::DB_NAMESPACE, mongo_data[:namespace]) + add_attribute(attributes, OpenTelemetry::SemConv::DB::DB_OPERATION_NAME, mongo_data[:command]) + add_attribute(attributes, OpenTelemetry::SemConv::DB::DB_QUERY_TEXT, mongo_data[:json]) + add_attribute(attributes, OpenTelemetry::SemConv::SERVER::SERVER_ADDRESS, mongo_data.dig(:peer, :hostname)) + add_attribute(attributes, OpenTelemetry::SemConv::SERVER::SERVER_PORT, mongo_data.dig(:peer, :port)) + end + + def extract_host(connection) + return nil unless connection + + connection.to_s.split(':').first + end + + def extract_port(connection) + return nil unless connection + + port = connection.to_s.split(':').last + port.to_i if port =~ /^\d+$/ + end + end + end + end +end diff --git a/lib/instana/exporter/otlp/graphql_converter.rb b/lib/instana/exporter/otlp/graphql_converter.rb new file mode 100644 index 00000000..58850738 --- /dev/null +++ b/lib/instana/exporter/otlp/graphql_converter.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +# (c) Copyright IBM Corp. 2026 + +require_relative 'base_converter' +require 'opentelemetry/semconv/incubating/graphql' + +module Instana + module Exporter + module Otlp + # Converter for GraphQL spans to OTLP format + class GraphqlConverter < BaseConverter + # Build OTel-compliant span name for GraphQL spans + # + # Formula per SPAN_NAME_PATTERNS.txt Section 7 (observability): + # "{operationType} {operationName}" e.g. "query MyQuery" + # Falls back to just "{operationType}" when no name, or "graphql" when both absent. + # + # @return [String] The span name + def span_name + gql = span[:data]&.[](:graphql) || {} + type = gql[:operationType].to_s.strip + name = gql[:operationName].to_s.strip + + if type.empty? + 'graphql' + elsif name.empty? + type + else + "#{type} #{name}" + end + end + + def convert_attributes + attributes = {} + + graphql_data = span[:data]&.[](:graphql) + return attributes unless graphql_data + + add_attribute(attributes, OpenTelemetry::SemConv::Incubating::GRAPHQL::GRAPHQL_OPERATION_NAME, graphql_data[:operationName]) + add_attribute(attributes, OpenTelemetry::SemConv::Incubating::GRAPHQL::GRAPHQL_OPERATION_TYPE, graphql_data[:operationType]) + add_attribute(attributes, OpenTelemetry::SemConv::Incubating::GRAPHQL::GRAPHQL_DOCUMENT, format_fields(graphql_data[:fields])) + + # Add arguments as custom attribute + add_attribute(attributes, 'graphql.arguments', format_arguments(graphql_data[:arguments])) if graphql_data[:arguments] + + attributes + end + + private + + def format_fields(fields) + return nil unless fields + + fields.map { |obj, flds| "#{obj} { #{flds.join(', ')} }" }.join(', ') + end + + def format_arguments(arguments) + return nil unless arguments + + arguments.map { |obj, args| "#{obj}(#{args.join(', ')})" }.join(', ') + end + end + end + end +end diff --git a/lib/instana/exporter/otlp/http_converter.rb b/lib/instana/exporter/otlp/http_converter.rb new file mode 100644 index 00000000..99a40543 --- /dev/null +++ b/lib/instana/exporter/otlp/http_converter.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +# (c) Copyright IBM Corp. 2026 + +require_relative 'base_converter' +require 'opentelemetry/semconv/http' +require 'opentelemetry/semconv/url' +require 'opentelemetry/semconv/server' +require 'opentelemetry/semconv/user_agent' + +module Instana + module Exporter + module Otlp + # Converter for HTTP spans to OTLP format + # Handles conversion of HTTP-related spans with specific attributes + class HttpConverter < BaseConverter + # Extract HTTP-specific attributes as plain key/value pairs + # @return [Hash] HTTP attributes + def convert_attributes + attributes = {} + http_data = span[:data]&.[](:http) || {} + + add_attribute(attributes, OpenTelemetry::SemConv::HTTP::HTTP_REQUEST_METHOD, http_data[:method]) + add_attribute(attributes, OpenTelemetry::SemConv::URL::URL_FULL, http_data[:url]) + add_attribute(attributes, OpenTelemetry::SemConv::URL::URL_PATH, http_data[:path]) + add_attribute(attributes, OpenTelemetry::SemConv::URL::URL_QUERY, http_data[:params]) + add_attribute(attributes, OpenTelemetry::SemConv::SERVER::SERVER_ADDRESS, extract_host(http_data[:host])) + add_attribute(attributes, OpenTelemetry::SemConv::SERVER::SERVER_PORT, extract_port(http_data[:host], http_data[:url])) + add_attribute(attributes, OpenTelemetry::SemConv::URL::URL_SCHEME, extract_scheme(http_data[:url])) + add_attribute(attributes, OpenTelemetry::SemConv::HTTP::HTTP_RESPONSE_STATUS_CODE, http_data[:status]) + add_attribute(attributes, OpenTelemetry::SemConv::USER_AGENT::USER_AGENT_ORIGINAL, http_data.dig(:header, 'user-agent')) + + add_protocol_attributes(attributes, http_data[:protocol]) + + attributes + end + + # Build OTel-compliant span status, treating EXIT spans with 4xx as ERROR + # + # @param error_count [Integer] Span error count + # @param error_msg [String, nil] Pre-extracted error message + # @return [Status] + def build_status(error_count, error_msg) + return super unless error_count.zero? + + http_data = span[:data]&.[](:http) || {} + status_code = http_data[:status].to_i + + if convert_span_kind == :client && status_code >= 400 && status_code < 500 + Status.new(OpenTelemetry::Trace::Status::ERROR, '') + else + super + end + end + + # Build OTel-compliant span name for HTTP spans + # + # Convention (stable): "{METHOD}" or "{METHOD} {url.template/path}" + # Falls back to "HTTP" when no method is present. + # + # @return [String] The span name + def span_name + http_data = span[:data]&.[](:http) || {} + method = http_data[:method].to_s.upcase + method = 'HTTP' if method.empty? + + path = http_data[:path].to_s.strip + path.empty? ? method : "#{method} #{path}" + end + + private + + # Extract scheme from URL + # @param url [String] The URL + # @return [String, nil] The scheme (http or https) + def extract_scheme(url) + return nil unless url + + uri = URI.parse(url) + uri.scheme + rescue URI::InvalidURIError + nil + end + + # Extract the host part from a "host:port" string or bare hostname + # @param host_str [String, nil] e.g. "api.example.com:8080" or "api.example.com" + # @return [String, nil] + def extract_host(host_str) + return nil unless host_str + + part = host_str.to_s.split(':').first + part && !part.empty? ? part : host_str + end + + # Extract the port from a "host:port" string, falling back to the URL port + # @param host_str [String, nil] e.g. "api.example.com:8080" + # @param url [String, nil] full URL as fallback + # @return [Integer, nil] + def extract_port(host_str, url) + if host_str&.include?(':') + port = host_str.split(':').last + return port.to_i unless port.nil? || port.empty? + end + + return nil unless url + + uri = URI.parse(url) + uri.port + rescue URI::InvalidURIError + nil + end + + # Emit network.protocol.name and network.protocol.version from e.g. "HTTP/1.1" + # @param attributes [Hash] + # @param protocol [String, nil] e.g. "HTTP/1.1" or "h2" + def add_protocol_attributes(attributes, protocol) + return unless protocol + + parts = protocol.to_s.split('/', 2) + name = parts[0].downcase + version = parts[1] + + add_attribute(attributes, 'network.protocol.name', name) + add_attribute(attributes, 'network.protocol.version', version) + end + end + end + end +end diff --git a/lib/instana/exporter/otlp/messaging_converter.rb b/lib/instana/exporter/otlp/messaging_converter.rb new file mode 100644 index 00000000..aa0c88a1 --- /dev/null +++ b/lib/instana/exporter/otlp/messaging_converter.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +# (c) Copyright IBM Corp. 2026 + +require_relative 'base_converter' +require 'opentelemetry/semconv/incubating/messaging' +require 'opentelemetry/semconv/server' + +module Instana + module Exporter + module Otlp + # Converter for messaging spans to OTLP format + class MessagingConverter < BaseConverter + # Build OTel-compliant span name for messaging (RabbitMQ) spans + # + # Formula per SPAN_NAME_PATTERNS.txt Section 3 (bunny/AMQP): + # publish → "{exchange} publish" (or "{queue} publish" when no exchange) + # receive → "{queue} receive" + # + # @return [String] The span name + def span_name + rabbitmq_data = span[:data]&.[](:rabbitmq) || {} + sort = rabbitmq_data[:sort].to_s + + if sort == 'publish' + dest = rabbitmq_data[:exchange].to_s.strip + dest = rabbitmq_data[:queue].to_s.strip if dest.empty? + dest.empty? ? 'publish' : "#{dest} publish" + else + queue = rabbitmq_data[:queue].to_s.strip + queue.empty? ? 'receive' : "#{queue} receive" + end + end + + def convert_attributes + attributes = {} + + rabbitmq_data = span[:data]&.[](:rabbitmq) + if rabbitmq_data + add_attribute(attributes, OpenTelemetry::SemConv::Incubating::MESSAGING::MESSAGING_SYSTEM, 'rabbitmq') + add_attribute(attributes, OpenTelemetry::SemConv::Incubating::MESSAGING::MESSAGING_DESTINATION_NAME, + rabbitmq_destination_name(rabbitmq_data)) + add_attribute(attributes, OpenTelemetry::SemConv::Incubating::MESSAGING::MESSAGING_RABBITMQ_DESTINATION_ROUTING_KEY, rabbitmq_data[:key]) + add_attribute(attributes, 'messaging.rabbitmq.queue', rabbitmq_data[:queue]) + add_attribute(attributes, OpenTelemetry::SemConv::SERVER::SERVER_ADDRESS, rabbitmq_data[:address]) + + operation = rabbitmq_data[:sort] == 'publish' ? 'send' : 'receive' + add_attribute(attributes, OpenTelemetry::SemConv::Incubating::MESSAGING::MESSAGING_OPERATION_TYPE, operation) + end + + attributes + end + + private + + # Build the composite destination name per spec: + # Producer (publish): "{exchange}:{key}" — omit absent parts + # Consumer (receive): "{exchange}:{key}:{queue}" — omit absent; deduplicate key==queue + # + # @param data [Hash] rabbitmq span data + # @return [String, nil] + def rabbitmq_destination_name(data) + exchange = data[:exchange].to_s.strip + key = data[:key].to_s.strip + queue = data[:queue].to_s.strip + sort = data[:sort].to_s + + if sort == 'publish' + parts = [exchange, key].reject(&:empty?) + else + # Consumer: exchange:key:queue, dedup key==queue + parts = [exchange, key] + parts << queue unless queue.empty? || queue == key + parts = parts.reject(&:empty?) + end + parts.empty? ? nil : parts.join(':') + end + end + end + end +end diff --git a/lib/instana/exporter/otlp/rails_converter.rb b/lib/instana/exporter/otlp/rails_converter.rb new file mode 100644 index 00000000..fe0b8163 --- /dev/null +++ b/lib/instana/exporter/otlp/rails_converter.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +# (c) Copyright IBM Corp. 2026 + +require_relative 'base_converter' +require 'opentelemetry/semconv/incubating/code' + +module Instana + module Exporter + module Otlp + # Converter for Rails-related spans (ActionController, ActionView, ActionMailer) to OTLP format + class RailsConverter < BaseConverter + ACTIONMAILER_SPAN = 'mail.actionmailer' + + # Build OTel-compliant span name for Rails spans + # + # Formulas per SPAN_NAME_PATTERNS.txt Sections 5 & 7: + # actioncontroller → "{Controller}#{action}" e.g. "UsersController#index" + # actionview → "{view_name}" e.g. "users/index" + # render → "{type} {name}" e.g. "template users/index" + # mail.actionmailer → "{Class}#{method}" e.g. "UserMailer#welcome_email" + # + # @return [String] The span name + def span_name + case span[:n].to_s + when 'actioncontroller' + d = span_data_for(:actioncontroller) + ctrl = d[:controller].to_s.strip + action = d[:action].to_s.strip + parts = [ctrl, action].reject(&:empty?) + parts.empty? ? 'actioncontroller' : parts.join('#') + when 'actionview' + d = span_data_for(:actionview) + d[:name].to_s.strip.then { |n| n.empty? ? 'actionview' : n } + when 'render' + d = span_data_for(:render) + type = d[:type].to_s.strip + name = d[:name].to_s.strip + parts = [type, name].reject(&:empty?) + parts.empty? ? 'render' : parts.join(' ') + when ACTIONMAILER_SPAN + d = span_data_for(:actionmailer) + klass = d[:class].to_s.strip + method = d[:method].to_s.strip + parts = [klass, method].reject(&:empty?) + parts.empty? ? ACTIONMAILER_SPAN : parts.join('#') + else + super + end + end + + def convert_attributes + attributes = {} + span_type = span[:n].to_s + + if span_type == 'actioncontroller' # rubocop:disable Style/CaseLikeIf + convert_action_controller_attributes(attributes) + elsif span_type == 'actionview' + convert_action_view_attributes(attributes) + elsif span_type == 'render' + convert_render_attributes(attributes) + elsif span_type == ACTIONMAILER_SPAN + convert_action_mailer_attributes(attributes) + end + + attributes + end + + private + + # Return the data hash for a given span data key, falling back to + # top-level span key, then an empty hash. + def span_data_for(key) + span[:data]&.[](key) || span[key] || {} + end + + # Convert ActionController span attributes + def convert_action_controller_attributes(attributes) + controller_data = span[:data]&.[](:actioncontroller) || span[:actioncontroller] + return unless controller_data + + add_attribute(attributes, OpenTelemetry::SemConv::Incubating::CODE::CODE_NAMESPACE, controller_data[:controller]) + add_attribute(attributes, OpenTelemetry::SemConv::Incubating::CODE::CODE_FUNCTION, controller_data[:action]) + end + + # Convert ActionView span attributes + def convert_action_view_attributes(attributes) + view_data = span[:data]&.[](:actionview) || span[:actionview] + return unless view_data + + add_attribute(attributes, 'rails.view.name', view_data[:name]) + end + + # Convert render span attributes + def convert_render_attributes(attributes) + render_data = span[:data]&.[](:render) || span[:render] + return unless render_data + + add_attribute(attributes, 'rails.render.type', render_data[:type]) + add_attribute(attributes, 'rails.render.name', render_data[:name]) + end + + # Convert ActionMailer span attributes + def convert_action_mailer_attributes(attributes) + mailer_data = span[:data]&.[](:actionmailer) || span[:actionmailer] + return unless mailer_data + + add_attribute(attributes, OpenTelemetry::SemConv::Incubating::CODE::CODE_NAMESPACE, mailer_data[:class]) + add_attribute(attributes, OpenTelemetry::SemConv::Incubating::CODE::CODE_FUNCTION, mailer_data[:method]) + end + end + end + end +end diff --git a/lib/instana/exporter/otlp/resource.rb b/lib/instana/exporter/otlp/resource.rb new file mode 100644 index 00000000..2db69da2 --- /dev/null +++ b/lib/instana/exporter/otlp/resource.rb @@ -0,0 +1,403 @@ +# frozen_string_literal: true + +# (c) Copyright IBM Corp. 2026 + +require 'socket' +require 'opentelemetry/semantic_conventions' +require_relative '../../util' + +module Instana + module Exporter + module Otlp + # Resource represents a resource, which captures identifying information about the entities + # for which telemetry (metrics or traces) is reported. + # This follows OpenTelemetry semantic conventions for resource attributes + class Resource + PROC_SELF_CGROUP = '/proc/self/cgroup' + DOCKER_ENV_FILE = '/.dockerenv' + PODMAN_CONTAINERENV = '/run/.containerenv' + # Linux-only stable machine-id paths (systemd and D-Bus fallback). + # These files do not exist on macOS or Windows; host_id returns nil there. + MACHINE_ID_PATHS = %w[/etc/machine-id /var/lib/dbus/machine-id].freeze + # cloud.resource_id is not yet in the installed semconv gem version + CLOUD_RESOURCE_ID = 'cloud.resource_id' + + class << self + private :new + + # Returns a newly created {Resource} with the specified attributes + # + # @param [Hash{String => String, Numeric, Boolean}] attributes Hash of key-value pairs to be used + # as attributes for this resource + # @return [Resource] + def create(attributes = {}) + frozen_attributes = attributes.each_with_object({}) do |(k, v), memo| + memo[k.freeze] = v.freeze + end.freeze + + new(frozen_attributes) + end + + # Returns the default resource with standard attributes + # + # @return [Resource] + def default + @default ||= create(OpenTelemetry::SemanticConventions::Resource::SERVICE_NAME => 'ruby-service') + .merge(process) + .merge(telemetry_sdk) + .merge(service_name_from_env) + .merge(optional_attributes) + .merge(container_attributes) + .merge(faas_attributes) + end + + # Get the global resource instance (singleton pattern) + # This method provides backward compatibility with the previous API + # + # @return [Hash] Resource attributes as a hash + def instance + @instance ||= default.attributes + end + + # Reset the resource instance (useful for testing) + # This method provides backward compatibility with the previous API + def reset! + @instance = nil + @default = nil + end + + # Returns telemetry SDK resource attributes + # + # @return [Resource] + def telemetry_sdk + create( + OpenTelemetry::SemanticConventions::Resource::TELEMETRY_SDK_NAME => 'instana', + OpenTelemetry::SemanticConventions::Resource::TELEMETRY_SDK_LANGUAGE => 'ruby', + OpenTelemetry::SemanticConventions::Resource::TELEMETRY_SDK_VERSION => ::Instana::VERSION + ) + end + + # Returns process resource attributes + # + # @return [Resource] + def process + create( + OpenTelemetry::SemanticConventions::Resource::PROCESS_PID => Process.pid, + OpenTelemetry::SemanticConventions::Resource::PROCESS_COMMAND => $PROGRAM_NAME, + OpenTelemetry::SemanticConventions::Resource::PROCESS_EXECUTABLE_NAME => File.basename($PROGRAM_NAME), + OpenTelemetry::SemanticConventions::Resource::PROCESS_RUNTIME_NAME => RUBY_ENGINE, + OpenTelemetry::SemanticConventions::Resource::PROCESS_RUNTIME_VERSION => RUBY_VERSION, + OpenTelemetry::SemanticConventions::Resource::PROCESS_RUNTIME_DESCRIPTION => RUBY_DESCRIPTION + ) + end + + private + + # Returns service name from environment variables + # + # @return [Resource] + def service_name_from_env + service_name = ENV.fetch('OTEL_SERVICE_NAME', nil) || + ENV.fetch('INSTANA_SERVICE_NAME', nil) || + ::Instana::Util.get_app_name + + return create({}) unless service_name + + create(OpenTelemetry::SemanticConventions::Resource::SERVICE_NAME => service_name) + end + + # Returns optional resource attributes: + # os.type, host.name, host.arch, host.id, service.version, service.instance.id + # + # service.instance.id priority (v2 spec §General Resource Attributes): + # container.id → k8s.pod.uid → host.id → hostname:pid + # + # @return [Resource] + def optional_attributes + attrs = {} + + # os.type — Required per v2 spec + attrs[OpenTelemetry::SemanticConventions::Resource::OS_TYPE] = detect_os_type + + host = hostname + + # host.name — Recommended (Conditional) per v2 spec + attrs[OpenTelemetry::SemanticConventions::Resource::HOST_NAME] = host if host && host != 'unknown' + + # host.arch + arch = host_architecture + attrs[OpenTelemetry::SemanticConventions::Resource::HOST_ARCH] = arch if arch + + # host.id — Recommended per v2 spec; stable machine identifier + hid = host_id + attrs[OpenTelemetry::SemanticConventions::Resource::HOST_ID] = hid if hid + + # service.version + version = ENV.fetch('OTEL_SERVICE_VERSION', nil) || + ENV.fetch('INSTANA_SERVICE_VERSION', nil) || + detect_app_version + attrs[OpenTelemetry::SemanticConventions::Resource::SERVICE_VERSION] = version if version + + # service.instance.id — priority: container.id > k8s.pod.uid > host.id > hostname:pid + instance_id = extract_container_id || + ENV.fetch('MY_POD_UID', nil) || + hid || + "#{host}:#{Process.pid}" + attrs[OpenTelemetry::SemanticConventions::Resource::SERVICE_INSTANCE_ID] = instance_id + + create(attrs) + end + + # Returns container and cloud platform resource attributes. + # AWS Lambda is intentionally excluded here — it is a FaaS platform, + # not a container runtime. See faas_attributes for Lambda/Cloud Run. + # + # @return [Resource] + def container_attributes + attrs = {} + + add_docker_or_podman_attributes(attrs) + add_kubernetes_attributes(attrs) + add_aws_ecs_attributes(attrs) + + create(attrs) + end + + # Returns FaaS (Function-as-a-Service) platform resource attributes. + # Kept separate from container_attributes because Lambda and Cloud Run + # are serverless runtimes, not container runtimes. + # + # @return [Resource] + def faas_attributes + attrs = {} + + add_aws_lambda_attributes(attrs) + add_cloud_run_attributes(attrs) + + create(attrs) + end + + # Sets container.runtime and container.id attributes when a container + # engine is detected. Only runs on Linux since all sentinel paths are + # Linux-specific. + def add_docker_or_podman_attributes(attrs) + return unless linux? + + engine = extract_container_engine + attrs[OpenTelemetry::SemanticConventions::Resource::CONTAINER_RUNTIME] = engine if engine + + container_id = extract_container_id + attrs[OpenTelemetry::SemanticConventions::Resource::CONTAINER_ID] = container_id if container_id + end + + # Detects the container engine by inspecting Linux sentinel files. + # Returns 'podman', 'docker', or nil if no container environment is found. + # + # @return [String, nil] + def extract_container_engine + if File.exist?(PODMAN_CONTAINERENV) + 'podman' + elsif File.exist?(DOCKER_ENV_FILE) || File.exist?(PROC_SELF_CGROUP) + 'docker' + end + end + + def add_kubernetes_attributes(attrs) + return unless ENV.fetch('KUBERNETES_SERVICE_HOST', nil) + + attrs[OpenTelemetry::SemanticConventions::Resource::K8S_POD_NAME] = ENV.fetch('HOSTNAME', nil) + + pod_uid = ENV.fetch('MY_POD_UID', nil) + attrs[OpenTelemetry::SemanticConventions::Resource::K8S_POD_UID] = pod_uid if pod_uid + + ns = ENV.fetch('KUBERNETES_NAMESPACE', nil) + attrs[OpenTelemetry::SemanticConventions::Resource::K8S_NAMESPACE_NAME] = ns if ns + end + + def add_aws_ecs_attributes(attrs) + return unless ENV.fetch('ECS_CONTAINER_METADATA_URI', nil) || ENV.fetch('ECS_CONTAINER_METADATA_URI_V4', nil) + + attrs[OpenTelemetry::SemanticConventions::Resource::CLOUD_PROVIDER] = 'aws' + attrs[OpenTelemetry::SemanticConventions::Resource::CLOUD_PLATFORM] = 'aws_ecs' + end + + def add_aws_lambda_attributes(attrs) + lambda_name = ENV.fetch('AWS_LAMBDA_FUNCTION_NAME', nil) + return unless lambda_name + + attrs[OpenTelemetry::SemanticConventions::Resource::CLOUD_PROVIDER] = 'aws' + attrs[OpenTelemetry::SemanticConventions::Resource::CLOUD_PLATFORM] = 'aws_lambda' + attrs[OpenTelemetry::SemanticConventions::Resource::FAAS_NAME] = lambda_name + + version = ENV.fetch('AWS_LAMBDA_FUNCTION_VERSION', nil) + attrs[OpenTelemetry::SemanticConventions::Resource::FAAS_VERSION] = version if version + + arn = ENV.fetch('AWS_LAMBDA_FUNCTION_ARN', nil) + attrs.merge!(parse_lambda_arn(arn)) if arn + end + + def add_cloud_run_attributes(attrs) + service = ENV.fetch('K_SERVICE', nil) + return unless service + + attrs[OpenTelemetry::SemanticConventions::Resource::CLOUD_PROVIDER] = 'gcp' + attrs[OpenTelemetry::SemanticConventions::Resource::CLOUD_PLATFORM] = 'gcp_cloud_run' + attrs[OpenTelemetry::SemanticConventions::Resource::FAAS_NAME] = service + + revision = ENV.fetch('K_REVISION', nil) + attrs[OpenTelemetry::SemanticConventions::Resource::FAAS_VERSION] = revision if revision + end + + # Returns true when the current OS is Linux. + # + # @return [Boolean] + def linux? + detect_os_type == 'linux' + end + + # Detect the OS type string per OTel semconv os.type values. + # Returns one of: "linux", "darwin", "windows", or the raw RbConfig string. + # + # @return [String] + def detect_os_type + raw = RbConfig::CONFIG['host_os'].to_s.downcase + case raw + when /linux/ then 'linux' + when /darwin/ then 'darwin' + when /mingw|mswin|cygwin/ then 'windows' + else raw + end + end + + # Returns a stable machine-level identifier. + # Reads /etc/machine-id (Linux systemd standard) or + # /var/lib/dbus/machine-id as fallback. Returns nil on macOS/Windows. + # + # @return [String, nil] + def host_id + path = MACHINE_ID_PATHS.find { |machine_id_path| File.exist?(machine_id_path) } + return nil unless path + + id = File.read(path).strip + return id unless id.empty? + + nil + rescue StandardError + nil + end + + # Parses a Lambda ARN and returns a hash of cloud.* resource attributes. + # + # @param arn [String] e.g. "arn:aws:lambda:us-east-1:123456789012:function:my-fn" + # @return [Hash] + def parse_lambda_arn(arn) + return {} if arn.nil? || arn.empty? + + parts = arn.split(':') + result = {} + result[OpenTelemetry::SemanticConventions::Resource::CLOUD_REGION] = parts[3] if parts[3] && !parts[3].empty? + result[OpenTelemetry::SemanticConventions::Resource::CLOUD_ACCOUNT_ID] = parts[4] if parts[4] && !parts[4].empty? + result[CLOUD_RESOURCE_ID] = arn + result + rescue StandardError + {} + end + + # Get hostname + # + # @return [String] Hostname + def hostname + Socket.gethostname + rescue StandardError + 'unknown' + end + + # Get host architecture + # + # @return [String] Host architecture + def host_architecture + RbConfig::CONFIG['host_cpu'] + end + + # Extract container ID from cgroup file + # + # @return [String, nil] Container ID + def extract_container_id + return nil unless File.exist?(PROC_SELF_CGROUP) + + line = File.readlines(PROC_SELF_CGROUP).find do |l| + l.match?(%r{/docker/([a-f0-9]{64})}) + end + + return nil unless line + + match = line.match(%r{/docker/([a-f0-9]{64})}) + match[1] + rescue StandardError + nil + end + + # Detect application version from various sources + # + # @return [String, nil] Application version + def detect_app_version + # Try to get version from Rails + if defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application + app_class = ::Rails.application.class + return app_class::VERSION if app_class.const_defined?(:VERSION) + end + + # Try to get version from Gemfile.lock + if File.exist?('Gemfile.lock') + lockfile = File.read('Gemfile.lock') + # Look for the main gem version (first gem in the file) + match = lockfile.match(/^\s{4}(\S+)\s+\(([^)]+)\)/) + return match[2] if match + end + + nil + rescue StandardError + nil + end + end + + # @api private + # The constructor is private and only for use internally by the class. + # Users should use the {create} factory method to obtain a {Resource} + # instance. + # + # @param [Hash] frozen_attributes Frozen-hash of frozen-string + # key-value pairs to be used as attributes for this resource + # @return [Resource] + def initialize(frozen_attributes) + @attributes = frozen_attributes + end + + # Returns an enumerator for attributes of this {Resource} + # + # @return [Enumerator] + def attribute_enumerator + @attribute_enumerator ||= attributes.to_enum + end + + # Returns a new, merged {Resource} by merging the current {Resource} with + # the other {Resource}. In case of a collision, the other {Resource} + # takes precedence + # + # @param [Resource] other The other resource to merge + # @return [Resource] A new resource formed by merging the current resource + # with other + def merge(other) + return self unless other.is_a?(Resource) + + self.class.send(:new, attributes.merge(other.send(:attributes)).freeze) + end + + # Returns the attributes hash for this resource + # + # @return [Hash] The frozen attributes hash + attr_reader :attributes + end + end + end +end diff --git a/lib/instana/exporter/otlp/rpc_converter.rb b/lib/instana/exporter/otlp/rpc_converter.rb new file mode 100644 index 00000000..f641cd43 --- /dev/null +++ b/lib/instana/exporter/otlp/rpc_converter.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +# (c) Copyright IBM Corp. 2026 + +require_relative 'base_converter' +require 'opentelemetry/semconv/incubating/rpc' +require 'opentelemetry/semconv/incubating/code' +require 'opentelemetry/semconv/server' + +module Instana + module Exporter + module Otlp + # Converter for RPC spans (gRPC, ActionCable) to OTLP format + class RpcConverter < BaseConverter + # Build OTel-compliant span name for RPC spans + # + # Formulas per SPAN_NAME_PATTERNS.txt Section 4: + # gRPC → "{package.Service/Method}" (leading "/" stripped per OTel spec) + # ActionCable → "{ChannelClass#action}" (call string used as-is) + # + # @return [String] The span name + def span_name + rpc_data = span[:data]&.[](:rpc) || {} + + if rpc_data[:flavor] == :actioncable + rpc_data[:call].to_s + else + # Strip the mandatory leading slash per gRPC/OTel spec + rpc_data[:call].to_s.delete_prefix('/') + end.then { |n| n.empty? ? super : n } + end + + def convert_attributes + attributes = {} + + rpc_data = span[:data]&.[](:rpc) + return attributes unless rpc_data + + # Check if this is an ActionCable span + if rpc_data[:flavor] == :actioncable + convert_action_cable_attributes(attributes, rpc_data) + else + convert_grpc_attributes(attributes, rpc_data) + end + + attributes + end + + private + + # Convert gRPC span attributes + def convert_grpc_attributes(attributes, rpc_data) + # RPC system + add_attribute(attributes, OpenTelemetry::SemConv::Incubating::RPC::RPC_SYSTEM, 'grpc') + + # RPC service and method + if rpc_data[:call] + service, method = parse_grpc_call(rpc_data[:call]) + add_attribute(attributes, OpenTelemetry::SemConv::Incubating::RPC::RPC_SERVICE, service) + add_attribute(attributes, OpenTelemetry::SemConv::Incubating::RPC::RPC_METHOD, method) + end + + # Network peer + add_attribute(attributes, OpenTelemetry::SemConv::SERVER::SERVER_ADDRESS, rpc_data[:host]) + add_attribute(attributes, OpenTelemetry::SemConv::SERVER::SERVER_ADDRESS, rpc_data.dig(:peer, :address)) + + # gRPC-specific attributes + add_attribute(attributes, 'rpc.grpc.call_type', rpc_data[:call_type]) + end + + # Convert ActionCable span attributes + def convert_action_cable_attributes(attributes, rpc_data) + # RPC system + add_attribute(attributes, OpenTelemetry::SemConv::Incubating::RPC::RPC_SYSTEM, 'actioncable') + + # ActionCable-specific attributes + add_attribute(attributes, 'rails.actioncable.channel', rpc_data[:call]) + add_attribute(attributes, 'rails.actioncable.call_type', rpc_data[:call_type]) + add_attribute(attributes, OpenTelemetry::SemConv::Incubating::RPC::RPC_SERVICE, span[:data]&.[](:service) || span[:service]) + + # Extract channel class and action from the call attribute + # Format can be either "ChannelClass" (for transmit) or "ChannelClass#action" (for action dispatch) + if rpc_data[:call] + call_parts = rpc_data[:call].to_s.split('#') + add_attribute(attributes, OpenTelemetry::SemConv::Incubating::CODE::CODE_NAMESPACE, call_parts[0]) + add_attribute(attributes, OpenTelemetry::SemConv::Incubating::CODE::CODE_FUNCTION, call_parts[1]) if call_parts[1] + end + + # Network peer + add_attribute(attributes, OpenTelemetry::SemConv::SERVER::SERVER_ADDRESS, rpc_data[:host]) + end + + def parse_grpc_call(call) + parts = call.to_s.split('/') + return [nil, nil] if parts.size < 3 + + [parts[1], parts[2]] + end + end + end + end +end diff --git a/test/backend/host_agent_reporting_observer_test.rb b/test/backend/host_agent_reporting_observer_test.rb index c04b2be0..0ebcd650 100644 --- a/test/backend/host_agent_reporting_observer_test.rb +++ b/test/backend/host_agent_reporting_observer_test.rb @@ -3,7 +3,7 @@ require 'test_helper' -class HostAgentReportingObserverTest < Minitest::Test +class HostAgentReportingObserverTest < Minitest::Test # rubocop:disable Metrics/ClassLength def test_start_stop client = Instana::Backend::RequestClient.new('10.10.10.10', 9292) discovery = Concurrent::Atom.new(nil) @@ -318,4 +318,411 @@ def test_poll_rate_changes_metrics_timer_interval # Verify traces_timer always stays at 1 second assert_equal 1, subject.traces_timer.opts[:execution_interval] end + + # ============================================================================ + # OTLP EXPORT TESTS (driven by ::Instana.config[:otlp]) + # ============================================================================ + + # Helper: stub ::Instana.config[:otlp] for the duration of a block + def with_otlp_config(overrides = {}) + base = { + enabled: true, + endpoint: 'http://localhost:4318/v1/traces', + timeout: 5_000, + compression: nil, + headers: {}, + certificate: nil, + client_key: nil, + client_certificate: nil, + config_source: 'default' + } + ::Instana.config[:otlp] = base.merge(overrides) + yield + ensure + ::Instana.config[:otlp] = { enabled: false, endpoint: 'http://localhost:4318/v1/traces', + timeout: 10_000, compression: nil, headers: {}, + certificate: nil, client_key: nil, client_certificate: nil, + config_source: 'default' } + end + + def test_otlp_exporter_initialised_when_config_enabled + client = Instana::Backend::RequestClient.new('10.10.10.10', 9292) + discovery = Concurrent::Atom.new(nil) + + fake_exporter = Object.new + with_otlp_config(enabled: true) do + OpenTelemetry::Exporter::OTLP::Exporter.stub(:new, fake_exporter) do + subject = Instana::Backend::HostAgentReportingObserver.new(client, discovery, timer_class: MockTimer) + refute_nil subject.instance_variable_get(:@otlp_exporter), + 'OTLP exporter should be initialised when config[:otlp][:enabled] is true' + end + end + end + + def test_otlp_exporter_nil_when_config_disabled + client = Instana::Backend::RequestClient.new('10.10.10.10', 9292) + discovery = Concurrent::Atom.new(nil) + + with_otlp_config(enabled: false) do + subject = Instana::Backend::HostAgentReportingObserver.new(client, discovery, timer_class: MockTimer) + assert_nil subject.instance_variable_get(:@otlp_exporter), + 'OTLP exporter should be nil when config[:otlp][:enabled] is false' + end + end + + def test_otlp_exporter_construction_error_is_rescued + client = Instana::Backend::RequestClient.new('10.10.10.10', 9292) + discovery = Concurrent::Atom.new(nil) + log_lines = [] + logger = Logger.new(StringIO.new).tap { |l| l.define_singleton_method(:error) { |msg| log_lines << msg } } + + with_otlp_config(enabled: true) do + OpenTelemetry::Exporter::OTLP::Exporter.stub(:new, ->(**_) { raise StandardError, 'boom' }) do + subject = Instana::Backend::HostAgentReportingObserver.new(client, discovery, + logger: logger, + timer_class: MockTimer) + assert_nil subject.instance_variable_get(:@otlp_exporter), + 'Exporter should be nil when construction raises' + assert log_lines.any? { |l| l.include?('boom') }, + 'Error should be logged' + end + end + end + + def test_otlp_exporter_timeout_converted_to_seconds + client = Instana::Backend::RequestClient.new('10.10.10.10', 9292) + discovery = Concurrent::Atom.new(nil) + received_opts = nil + + with_otlp_config(enabled: true, timeout: 8_000) do + capture = lambda { |** opts| + received_opts = opts + Minitest::Mock.new + } + OpenTelemetry::Exporter::OTLP::Exporter.stub(:new, capture) do + Instana::Backend::HostAgentReportingObserver.new(client, discovery, timer_class: MockTimer) + end + end + + assert_in_delta 8.0, received_opts[:timeout], 0.001, + 'Timeout should be converted from ms to seconds' + end + + def test_otlp_exporter_passes_compression_when_set + client = Instana::Backend::RequestClient.new('10.10.10.10', 9292) + discovery = Concurrent::Atom.new(nil) + received_opts = nil + + with_otlp_config(enabled: true, compression: 'gzip') do + capture = lambda { |**opts| + received_opts = opts + Minitest::Mock.new + } + OpenTelemetry::Exporter::OTLP::Exporter.stub(:new, capture) do + Instana::Backend::HostAgentReportingObserver.new(client, discovery, timer_class: MockTimer) + end + end + + assert_equal 'gzip', received_opts[:compression] + end + + def test_otlp_exporter_passes_headers_when_present + client = Instana::Backend::RequestClient.new('10.10.10.10', 9292) + discovery = Concurrent::Atom.new(nil) + received_opts = nil + + with_otlp_config(enabled: true, headers: { 'x-api-key' => 'secret' }) do + capture = lambda { |**opts| + received_opts = opts + Minitest::Mock.new + } + OpenTelemetry::Exporter::OTLP::Exporter.stub(:new, capture) do + Instana::Backend::HostAgentReportingObserver.new(client, discovery, timer_class: MockTimer) + end + end + + assert_equal({ 'x-api-key' => 'secret' }, received_opts[:headers]) + end + + def test_otlp_exporter_passes_certificate_file_when_set + client = Instana::Backend::RequestClient.new('10.10.10.10', 9292) + discovery = Concurrent::Atom.new(nil) + received_opts = nil + + with_otlp_config(enabled: true, certificate: '/etc/ssl/certs/ca.pem') do + capture = lambda { |**opts| + received_opts = opts + Minitest::Mock.new + } + OpenTelemetry::Exporter::OTLP::Exporter.stub(:new, capture) do + Instana::Backend::HostAgentReportingObserver.new(client, discovery, timer_class: MockTimer) + end + end + + assert_equal '/etc/ssl/certs/ca.pem', received_opts[:certificate_file] + refute received_opts.key?(:client_certificate_file), 'client_certificate_file should be absent when not configured' + refute received_opts.key?(:client_key_file), 'client_key_file should be absent when not configured' + end + + def test_otlp_exporter_passes_client_cert_and_key_when_set + client = Instana::Backend::RequestClient.new('10.10.10.10', 9292) + discovery = Concurrent::Atom.new(nil) + received_opts = nil + + with_otlp_config(enabled: true, + client_certificate: '/etc/ssl/certs/client.pem', + client_key: '/etc/ssl/private/client.key') do + capture = lambda { |**opts| + received_opts = opts + Minitest::Mock.new + } + OpenTelemetry::Exporter::OTLP::Exporter.stub(:new, capture) do + Instana::Backend::HostAgentReportingObserver.new(client, discovery, timer_class: MockTimer) + end + end + + assert_equal '/etc/ssl/certs/client.pem', received_opts[:client_certificate_file] + assert_equal '/etc/ssl/private/client.key', received_opts[:client_key_file] + refute received_opts.key?(:certificate_file), 'certificate_file should be absent when not configured' + end + + def test_otlp_exporter_omits_cert_keys_when_nil + client = Instana::Backend::RequestClient.new('10.10.10.10', 9292) + discovery = Concurrent::Atom.new(nil) + received_opts = nil + + with_otlp_config(enabled: true) do + capture = lambda { |**opts| + received_opts = opts + Minitest::Mock.new + } + OpenTelemetry::Exporter::OTLP::Exporter.stub(:new, capture) do + Instana::Backend::HostAgentReportingObserver.new(client, discovery, timer_class: MockTimer) + end + end + + refute received_opts.key?(:certificate_file), 'certificate_file should not be set when nil' + refute received_opts.key?(:client_certificate_file), 'client_certificate_file should not be set when nil' + refute received_opts.key?(:client_key_file), 'client_key_file should not be set when nil' + end + + def test_otlp_export_enabled_exports_spans + stub_request(:post, "http://10.10.10.10:9292/com.instana.plugin.ruby.1234") + .to_return(status: 200) + + client = Instana::Backend::RequestClient.new('10.10.10.10', 9292) + discovery = Concurrent::Atom.new({'pid' => 1234}) + + exported_spans = nil + otlp_exporter = Minitest::Mock.new + otlp_exporter.expect(:export, OpenTelemetry::SDK::Trace::Export::SUCCESS) do |spans| + exported_spans = spans + OpenTelemetry::SDK::Trace::Export::SUCCESS + end + + processor = Class.new do + def send = yield([{n: 'test', t: '1234', s: '5678'}]) + end.new + + with_otlp_config(enabled: true) do + OpenTelemetry::Exporter::OTLP::Exporter.stub(:new, otlp_exporter) do + subject = Instana::Backend::HostAgentReportingObserver.new(client, discovery, + timer_class: MockTimer, + processor: processor) + subject.traces_timer.block.call + end + end + + refute_nil exported_spans, 'OTLP exporter should have received spans' + assert exported_spans.is_a?(Array) + assert_equal 1, exported_spans.length + otlp_exporter.verify + refute_nil discovery.value, 'Discovery should remain valid after successful export' + end + + def test_otlp_export_disabled_uses_native_reporting + stub_request(:post, "http://10.10.10.10:9292/com.instana.plugin.ruby.1234") + .to_return(status: 200) + stub_request(:post, "http://10.10.10.10:9292/com.instana.plugin.ruby/traces.1234") + .to_return(status: 200) + + client = Instana::Backend::RequestClient.new('10.10.10.10', 9292) + discovery = Concurrent::Atom.new({'pid' => 1234}) + + processor = Class.new do + def send = yield([{n: 'test'}]) + end.new + + with_otlp_config(enabled: false) do + subject = Instana::Backend::HostAgentReportingObserver.new(client, discovery, + timer_class: MockTimer, + processor: processor) + assert_nil subject.instance_variable_get(:@otlp_exporter), + 'OTLP exporter should not be initialised when disabled' + subject.traces_timer.block.call + end + + refute_nil discovery.value, 'Discovery should remain valid' + end + + def test_otlp_export_converts_spans_correctly + stub_request(:post, "http://10.10.10.10:9292/com.instana.plugin.ruby.1234") + .to_return(status: 200) + + client = Instana::Backend::RequestClient.new('10.10.10.10', 9292) + discovery = Concurrent::Atom.new({'pid' => 1234}) + + test_span = { + n: 'rack', t: '1234567890abcdef', s: 'fedcba0987654321', + ts: Time.now.to_i * 1000, d: 100, k: 1, + data: { http: { method: 'GET', url: 'http://example.com/test', status: 200 } } + } + + exported_spans = nil + otlp_exporter = Minitest::Mock.new + otlp_exporter.expect(:export, OpenTelemetry::SDK::Trace::Export::SUCCESS) do |spans| + exported_spans = spans + OpenTelemetry::SDK::Trace::Export::SUCCESS + end + + processor = Class.new do + def initialize(span) = @span = span + def send = yield([@span]) + end.new(test_span) + + with_otlp_config(enabled: true) do + OpenTelemetry::Exporter::OTLP::Exporter.stub(:new, otlp_exporter) do + subject = Instana::Backend::HostAgentReportingObserver.new(client, discovery, + timer_class: MockTimer, + processor: processor) + subject.traces_timer.block.call + end + end + + refute_nil exported_spans + assert_equal 1, exported_spans.length + refute_nil exported_spans.first + otlp_exporter.verify + end + + def test_otlp_export_failure_triggers_rediscovery + stub_request(:post, "http://10.10.10.10:9292/com.instana.plugin.ruby.1234") + .to_return(status: 200) + stub_request(:get, "http://127.0.0.1:42699/") + .to_return(status: 200) + stub_request(:put, "http://127.0.0.1:42699/com.instana.plugin.ruby.discovery") + .to_return(status: 200, body: '{"pid": 1234}') + stub_request(:head, "http://127.0.0.1:42699/com.instana.plugin.ruby.1234") + .to_return(status: 200) + + client = Instana::Backend::RequestClient.new('10.10.10.10', 9292) + discovery = Concurrent::Atom.new({'pid' => 1234}) + + otlp_exporter = Minitest::Mock.new + otlp_exporter.expect(:export, OpenTelemetry::SDK::Trace::Export::FAILURE) do |_spans| + OpenTelemetry::SDK::Trace::Export::FAILURE + end + + processor = Class.new do + def send = yield([{n: 'test'}]) + end.new + + with_otlp_config(enabled: true) do + OpenTelemetry::Exporter::OTLP::Exporter.stub(:new, otlp_exporter) do + subject = Instana::Backend::HostAgentReportingObserver.new(client, discovery, + timer_class: MockTimer, + processor: processor) + subject.traces_timer.block.call + end + end + + otlp_exporter.verify + assert_nil discovery.value, 'Discovery should be reset after export failure' + end + + def test_otlp_export_with_multiple_spans + stub_request(:post, "http://10.10.10.10:9292/com.instana.plugin.ruby.1234") + .to_return(status: 200) + + client = Instana::Backend::RequestClient.new('10.10.10.10', 9292) + discovery = Concurrent::Atom.new({'pid' => 1234}) + + test_spans = [ + {n: 'rack', t: '1111', s: '2222'}, + {n: 'activerecord', t: '1111', s: '3333', p: '2222'}, + {n: 'redis', t: '1111', s: '4444', p: '2222'} + ] + + exported_spans = nil + otlp_exporter = Minitest::Mock.new + otlp_exporter.expect(:export, OpenTelemetry::SDK::Trace::Export::SUCCESS) do |spans| + exported_spans = spans + OpenTelemetry::SDK::Trace::Export::SUCCESS + end + + processor = Class.new do + def initialize(spans) = @spans = spans + def send = yield(@spans) + end.new(test_spans) + + with_otlp_config(enabled: true) do + OpenTelemetry::Exporter::OTLP::Exporter.stub(:new, otlp_exporter) do + subject = Instana::Backend::HostAgentReportingObserver.new(client, discovery, + timer_class: MockTimer, + processor: processor) + subject.traces_timer.block.call + end + end + + refute_nil exported_spans + assert_equal 3, exported_spans.length + otlp_exporter.verify + end + + def test_otlp_export_handles_empty_span_batch + stub_request(:post, "http://10.10.10.10:9292/com.instana.plugin.ruby.1234") + .to_return(status: 200) + + client = Instana::Backend::RequestClient.new('10.10.10.10', 9292) + discovery = Concurrent::Atom.new({'pid' => 1234}) + + otlp_exporter = Minitest::Mock.new # export should never be called + + processor = Class.new do + def send = yield([]) + end.new + + with_otlp_config(enabled: true) do + OpenTelemetry::Exporter::OTLP::Exporter.stub(:new, otlp_exporter) do + subject = Instana::Backend::HostAgentReportingObserver.new(client, discovery, + timer_class: MockTimer, + processor: processor) + subject.traces_timer.block.call + end + end + + refute_nil discovery.value, 'Discovery should remain valid with empty span batch' + end + + def test_otlp_exporter_shutdown_on_agent_disconnect + client = Instana::Backend::RequestClient.new('10.10.10.10', 9292) + discovery = Concurrent::Atom.new(nil) + + shutdown_called = false + fake_exporter = Object.new + fake_exporter.define_singleton_method(:shutdown) { shutdown_called = true } + + with_otlp_config(enabled: true) do + OpenTelemetry::Exporter::OTLP::Exporter.stub(:new, fake_exporter) do + subject = Instana::Backend::HostAgentReportingObserver.new(client, discovery, timer_class: MockTimer) + + # Simulate agent going away (new_version.nil? branch) + subject.update(Time.now, nil, nil) + + assert shutdown_called, 'OTLP exporter should be shut down when agent disconnects' + assert_nil subject.instance_variable_get(:@otlp_exporter), + 'OTLP exporter reference should be cleared after shutdown' + end + end + end end diff --git a/test/config_test.rb b/test/config_test.rb index 5a187234..db7167f3 100644 --- a/test/config_test.rb +++ b/test/config_test.rb @@ -706,3 +706,375 @@ def test_yaml_technology_config_not_overridden_by_agent_when_no_env ENV.delete('INSTANA_CONFIG_PATH') end end + +# ============================================================================ +# OTLP configuration tests +# ============================================================================ + +class OtlpConfigTest < Minitest::Test + OTLP_ENV_VARS = %w[ + INSTANA_TRACING_OTLP_ENABLED + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT + OTEL_EXPORTER_OTLP_ENDPOINT + OTEL_EXPORTER_OTLP_TIMEOUT + OTEL_EXPORTER_OTLP_COMPRESSION + OTEL_EXPORTER_OTLP_TRACES_HEADERS + OTEL_EXPORTER_OTLP_HEADERS + OTEL_EXPORTER_OTLP_CERTIFICATE + OTEL_EXPORTER_OTLP_CLIENT_KEY + OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE + INSTANA_CONFIG_PATH + ].freeze + + def setup + OTLP_ENV_VARS.each { |k| ENV.delete(k) } + end + + def teardown + OTLP_ENV_VARS.each { |k| ENV.delete(k) } + File.unlink('test_otlp_config.yaml') if File.exist?('test_otlp_config.yaml') + end + + # ── defaults ─────────────────────────────────────────────────────────────── + + def test_otlp_defaults_when_no_env_or_yaml + subject = Instana::Config.new(logger: Logger.new('/dev/null')) + + otlp = subject[:otlp] + refute_nil otlp, 'config[:otlp] should always be populated' + assert_equal false, otlp[:enabled] + assert_equal 'http://localhost:4318/v1/traces', otlp[:endpoint] + assert_equal 10_000, otlp[:timeout] + assert_nil otlp[:compression] + assert_equal({}, otlp[:headers]) + assert_nil otlp[:certificate] + assert_nil otlp[:client_key] + assert_nil otlp[:client_certificate] + assert_equal 'default', otlp[:config_source] + end + + # ── INSTANA_TRACING_OTLP_ENABLED truthy variants ────────────────────────── + + def test_enable_flag_true_string + ENV['INSTANA_TRACING_OTLP_ENABLED'] = 'true' + + subject = Instana::Config.new(logger: Logger.new('/dev/null')) + + assert_equal true, subject[:otlp][:enabled] + assert_equal 'env', subject[:otlp][:config_source] + end + + def test_enable_flag_one_string + ENV['INSTANA_TRACING_OTLP_ENABLED'] = '1' + + subject = Instana::Config.new(logger: Logger.new('/dev/null')) + + assert_equal true, subject[:otlp][:enabled] + end + + def test_enable_flag_yes_string + ENV['INSTANA_TRACING_OTLP_ENABLED'] = 'yes' + + subject = Instana::Config.new(logger: Logger.new('/dev/null')) + + assert_equal true, subject[:otlp][:enabled] + end + + def test_enable_flag_yes_case_insensitive + ENV['INSTANA_TRACING_OTLP_ENABLED'] = 'YES' + + subject = Instana::Config.new(logger: Logger.new('/dev/null')) + + assert_equal true, subject[:otlp][:enabled] + end + + def test_enable_flag_false_leaves_disabled + ENV['INSTANA_TRACING_OTLP_ENABLED'] = 'false' + + subject = Instana::Config.new(logger: Logger.new('/dev/null')) + + assert_equal false, subject[:otlp][:enabled] + assert_equal 'env', subject[:otlp][:config_source] + end + + # ── endpoint precedence ──────────────────────────────────────────────────── + + def test_traces_endpoint_takes_precedence_over_base_endpoint + ENV['OTEL_EXPORTER_OTLP_TRACES_ENDPOINT'] = 'http://traces.example.com/v1/traces' + ENV['OTEL_EXPORTER_OTLP_ENDPOINT'] = 'http://base.example.com' + + subject = Instana::Config.new(logger: Logger.new('/dev/null')) + + assert_equal 'http://traces.example.com/v1/traces', subject[:otlp][:endpoint] + end + + def test_base_endpoint_used_when_no_traces_endpoint + ENV['OTEL_EXPORTER_OTLP_ENDPOINT'] = 'http://base.example.com' + + subject = Instana::Config.new(logger: Logger.new('/dev/null')) + + assert_equal 'http://base.example.com', subject[:otlp][:endpoint] + end + + # ── timeout ──────────────────────────────────────────────────────────────── + + def test_timeout_stored_as_integer_milliseconds + ENV['OTEL_EXPORTER_OTLP_TIMEOUT'] = '30000' + + subject = Instana::Config.new(logger: Logger.new('/dev/null')) + + assert_equal 30_000, subject[:otlp][:timeout] + assert_instance_of Integer, subject[:otlp][:timeout] + end + + # ── compression ─────────────────────────────────────────────────────────── + + def test_compression_from_env + ENV['OTEL_EXPORTER_OTLP_COMPRESSION'] = 'gzip' + + subject = Instana::Config.new(logger: Logger.new('/dev/null')) + + assert_equal 'gzip', subject[:otlp][:compression] + end + + # ── headers ─────────────────────────────────────────────────────────────── + + def test_headers_parsed_from_env_into_hash + ENV['OTEL_EXPORTER_OTLP_HEADERS'] = 'api-key=secret,x-tenant=tenant1' + + subject = Instana::Config.new(logger: Logger.new('/dev/null')) + + assert_equal({ 'api-key' => 'secret', 'x-tenant' => 'tenant1' }, subject[:otlp][:headers]) + end + + def test_single_header_parsed_correctly + ENV['OTEL_EXPORTER_OTLP_HEADERS'] = 'authorization=Bearer token123' + + subject = Instana::Config.new(logger: Logger.new('/dev/null')) + + assert_equal({ 'authorization' => 'Bearer token123' }, subject[:otlp][:headers]) + end + + def test_traces_headers_takes_precedence_over_general_headers + ENV['OTEL_EXPORTER_OTLP_TRACES_HEADERS'] = 'x-traces-key=traces-secret' + ENV['OTEL_EXPORTER_OTLP_HEADERS'] = 'x-general-key=general-secret' + + subject = Instana::Config.new(logger: Logger.new('/dev/null')) + + assert_equal({ 'x-traces-key' => 'traces-secret' }, subject[:otlp][:headers]) + end + + def test_general_headers_used_when_no_traces_headers + ENV['OTEL_EXPORTER_OTLP_HEADERS'] = 'x-general-key=general-secret' + + subject = Instana::Config.new(logger: Logger.new('/dev/null')) + + assert_equal({ 'x-general-key' => 'general-secret' }, subject[:otlp][:headers]) + end + + # ── TLS fields ──────────────────────────────────────────────────────────── + + def test_tls_fields_from_env + ENV['OTEL_EXPORTER_OTLP_CERTIFICATE'] = '/etc/certs/ca.pem' + ENV['OTEL_EXPORTER_OTLP_CLIENT_KEY'] = '/etc/certs/client.key' + ENV['OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE'] = '/etc/certs/client.crt' + + subject = Instana::Config.new(logger: Logger.new('/dev/null')) + + assert_equal '/etc/certs/ca.pem', subject[:otlp][:certificate] + assert_equal '/etc/certs/client.key', subject[:otlp][:client_key] + assert_equal '/etc/certs/client.crt', subject[:otlp][:client_certificate] + end + + # ── YAML configuration ──────────────────────────────────────────────────── + + def test_yaml_populates_otlp_config + yaml_content = <<~YAML + tracing: + otlp: + enabled: true + endpoint: "http://otlp.example.com/v1/traces" + timeout: 5000 + compression: gzip + YAML + + File.write('test_otlp_config.yaml', yaml_content) + ENV['INSTANA_CONFIG_PATH'] = 'test_otlp_config.yaml' + + subject = Instana::Config.new(logger: Logger.new('/dev/null')) + + assert_equal true, subject[:otlp][:enabled] + assert_equal 'http://otlp.example.com/v1/traces', subject[:otlp][:endpoint] + assert_equal 5000, subject[:otlp][:timeout] + assert_equal 'gzip', subject[:otlp][:compression] + assert_equal 'yaml', subject[:otlp][:config_source] + end + + def test_yaml_headers_as_hash + yaml_content = <<~YAML + tracing: + otlp: + enabled: true + headers: + x-api-key: mysecret + x-tenant: acme + YAML + + File.write('test_otlp_config.yaml', yaml_content) + ENV['INSTANA_CONFIG_PATH'] = 'test_otlp_config.yaml' + + subject = Instana::Config.new(logger: Logger.new('/dev/null')) + + assert_equal({ 'x-api-key' => 'mysecret', 'x-tenant' => 'acme' }, subject[:otlp][:headers]) + end + + def test_yaml_takes_precedence_over_env + yaml_content = <<~YAML + tracing: + otlp: + enabled: true + endpoint: "http://from-yaml.example.com/v1/traces" + timeout: 5000 + YAML + + File.write('test_otlp_config.yaml', yaml_content) + ENV['INSTANA_CONFIG_PATH'] = 'test_otlp_config.yaml' + ENV['INSTANA_TRACING_OTLP_ENABLED'] = 'false' + ENV['OTEL_EXPORTER_OTLP_TRACES_ENDPOINT'] = 'http://from-env.example.com/v1/traces' + + subject = Instana::Config.new(logger: Logger.new('/dev/null')) + + assert_equal true, subject[:otlp][:enabled] + assert_equal 'http://from-yaml.example.com/v1/traces', subject[:otlp][:endpoint] + assert_equal 'yaml', subject[:otlp][:config_source] + end + + def test_yaml_without_otlp_section_leaves_defaults + yaml_content = <<~YAML + tracing: + global: + stack-trace: error + YAML + + File.write('test_otlp_config.yaml', yaml_content) + ENV['INSTANA_CONFIG_PATH'] = 'test_otlp_config.yaml' + + subject = Instana::Config.new(logger: Logger.new('/dev/null')) + + assert_equal false, subject[:otlp][:enabled] + assert_equal 'default', subject[:otlp][:config_source] + end + + # ── agent discovery ─────────────────────────────────────────────────────── + + def test_agent_discovery_sets_otlp_config_when_source_is_default + subject = Instana::Config.new(logger: Logger.new('/dev/null')) + assert_equal 'default', subject[:otlp][:config_source] + + discovery = { + 'tracing' => { + 'otlp' => { + 'enabled' => 'true', + 'endpoint' => 'http://agent.example.com/v1/traces', + 'timeout' => 8000 + } + } + } + subject.read_config_from_agent(discovery) + + assert_equal true, subject[:otlp][:enabled] + assert_equal 'http://agent.example.com/v1/traces', subject[:otlp][:endpoint] + assert_equal 8000, subject[:otlp][:timeout] + assert_equal 'agent', subject[:otlp][:config_source] + end + + def test_agent_discovery_does_not_override_env_config + ENV['INSTANA_TRACING_OTLP_ENABLED'] = 'true' + ENV['OTEL_EXPORTER_OTLP_TRACES_ENDPOINT'] = 'http://from-env.example.com/v1/traces' + + subject = Instana::Config.new(logger: Logger.new('/dev/null')) + assert_equal 'env', subject[:otlp][:config_source] + + discovery = { + 'tracing' => { + 'otlp' => { + 'enabled' => 'false', + 'endpoint' => 'http://agent.example.com/v1/traces' + } + } + } + subject.read_config_from_agent(discovery) + + # env values must be preserved + assert_equal true, subject[:otlp][:enabled] + assert_equal 'http://from-env.example.com/v1/traces', subject[:otlp][:endpoint] + assert_equal 'env', subject[:otlp][:config_source] + end + + def test_agent_discovery_does_not_override_yaml_config + yaml_content = <<~YAML + tracing: + otlp: + enabled: true + endpoint: "http://from-yaml.example.com/v1/traces" + YAML + + File.write('test_otlp_config.yaml', yaml_content) + ENV['INSTANA_CONFIG_PATH'] = 'test_otlp_config.yaml' + + subject = Instana::Config.new(logger: Logger.new('/dev/null')) + assert_equal 'yaml', subject[:otlp][:config_source] + + discovery = { + 'tracing' => { + 'otlp' => { + 'enabled' => 'false', + 'endpoint' => 'http://agent.example.com/v1/traces' + } + } + } + subject.read_config_from_agent(discovery) + + assert_equal true, subject[:otlp][:enabled] + assert_equal 'http://from-yaml.example.com/v1/traces', subject[:otlp][:endpoint] + assert_equal 'yaml', subject[:otlp][:config_source] + end + + def test_agent_discovery_without_otlp_key_is_ignored + subject = Instana::Config.new(logger: Logger.new('/dev/null')) + + discovery = { 'tracing' => { 'global' => { 'stack-trace' => 'all' } } } + subject.read_config_from_agent(discovery) + + assert_equal false, subject[:otlp][:enabled] + assert_equal 'default', subject[:otlp][:config_source] + end + + # ── should_read_from_agent? guard ──────────────────────────────────────── + + def test_should_read_from_agent_returns_true_for_default_otlp + subject = Instana::Config.new(logger: Logger.new('/dev/null')) + assert subject.send(:should_read_from_agent?, :otlp) + end + + def test_should_read_from_agent_returns_false_after_env_config + ENV['INSTANA_TRACING_OTLP_ENABLED'] = 'true' + + subject = Instana::Config.new(logger: Logger.new('/dev/null')) + refute subject.send(:should_read_from_agent?, :otlp) + end + + def test_should_read_from_agent_returns_false_after_yaml_config + yaml_content = <<~YAML + tracing: + otlp: + enabled: true + YAML + + File.write('test_otlp_config.yaml', yaml_content) + ENV['INSTANA_CONFIG_PATH'] = 'test_otlp_config.yaml' + + subject = Instana::Config.new(logger: Logger.new('/dev/null')) + refute subject.send(:should_read_from_agent?, :otlp) + end +end diff --git a/test/exporter/otlp/aws_converter_test.rb b/test/exporter/otlp/aws_converter_test.rb new file mode 100644 index 00000000..fa43ede6 --- /dev/null +++ b/test/exporter/otlp/aws_converter_test.rb @@ -0,0 +1,447 @@ +# frozen_string_literal: true + +# (c) Copyright IBM Corp. 2026 + +require 'test_helper' +require 'instana/exporter/otlp/aws_converter' + +class AwsConverterTest < Minitest::Test # rubocop:disable Metrics/ClassLength + # AWS SQS Tests + def test_sqs_send_operation_conversion + span = create_span('aws.sqs', { + sqs: { queue: 'my-queue', group: 'group-1', size: 5, type: 'send' } + }) + converter = Instana::Exporter::Otlp::AwsConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 'aws_sqs', attrs['messaging.system'] + assert_equal 'my-queue', attrs['messaging.destination.name'] + assert_equal 'group-1', attrs['messaging.aws.sqs.message_group_id'] + assert_equal 5, attrs['messaging.batch.message_count'] + assert_equal 'send', attrs['messaging.operation.type'] + end + + def test_sqs_single_sync_operation_conversion + span = create_span('aws.sqs', { + sqs: { queue: 'test-queue', type: 'single.sync' } + }) + converter = Instana::Exporter::Otlp::AwsConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 'aws_sqs', attrs['messaging.system'] + assert_equal 'test-queue', attrs['messaging.destination.name'] + assert_equal 'send', attrs['messaging.operation.type'] + end + + def test_sqs_delete_operation_conversion + span = create_span('aws.sqs', { + sqs: { queue: 'delete-queue', type: 'delete' } + }) + converter = Instana::Exporter::Otlp::AwsConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 'aws_sqs', attrs['messaging.system'] + assert_equal 'delete-queue', attrs['messaging.destination.name'] + assert_equal 'process', attrs['messaging.operation.type'] + end + + def test_sqs_create_operation_conversion + span = create_span('aws.sqs', { + sqs: { queue: 'new-queue', type: 'create' } + }) + converter = Instana::Exporter::Otlp::AwsConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 'aws_sqs', attrs['messaging.system'] + assert_equal 'new-queue', attrs['messaging.destination.name'] + assert_equal 'create', attrs['messaging.operation.type'] + end + + def test_sqs_get_operation_conversion + span = create_span('aws.sqs', { + sqs: { queue: 'get-queue', type: 'get' } + }) + converter = Instana::Exporter::Otlp::AwsConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 'aws_sqs', attrs['messaging.system'] + assert_equal 'get-queue', attrs['messaging.destination.name'] + assert_equal 'create', attrs['messaging.operation.type'] + end + + def test_sqs_unknown_operation_defaults_to_send + span = create_span('aws.sqs', { + sqs: { queue: 'unknown-queue', type: 'unknown_operation' } + }) + converter = Instana::Exporter::Otlp::AwsConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 'send', attrs['messaging.operation.type'] + end + + def test_sqs_with_nil_values + span = create_span('aws.sqs', { + sqs: { queue: 'test-queue', group: nil, size: nil, type: 'send' } + }) + converter = Instana::Exporter::Otlp::AwsConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 'aws_sqs', attrs['messaging.system'] + assert_equal 'test-queue', attrs['messaging.destination.name'] + assert_nil attrs['messaging.aws.sqs.message_group_id'] + assert_nil attrs['messaging.batch.message_count'] + end + + # AWS SNS Tests + def test_sns_basic_conversion + span = create_span('aws.sns', { + sns: { topic: 'my-topic', target: 'arn:aws:sns:us-east-1:123456789:my-topic' } + }) + converter = Instana::Exporter::Otlp::AwsConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 'aws_sns', attrs['messaging.system'] + assert_equal 'my-topic', attrs['messaging.destination.name'] + assert_equal 'arn:aws:sns:us-east-1:123456789:my-topic', attrs['messaging.aws.sns.target_arn'] + assert_equal 'send', attrs['messaging.operation.type'] + end + + def test_sns_with_phone_number + span = create_span('aws.sns', { + sns: { topic: 'sms-topic', phone: '+1234567890' } + }) + converter = Instana::Exporter::Otlp::AwsConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 'aws_sns', attrs['messaging.system'] + assert_equal '+1234567890', attrs['messaging.aws.sns.phone_number'] + end + + def test_sns_with_subject + span = create_span('aws.sns', { + sns: { topic: 'notification-topic', subject: 'Important Alert' } + }) + converter = Instana::Exporter::Otlp::AwsConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 'aws_sns', attrs['messaging.system'] + assert_equal 'Important Alert', attrs['messaging.aws.sns.subject'] + end + + def test_sns_with_all_attributes + span = create_span('aws.sns', { + sns: { + topic: 'full-topic', + target: 'arn:aws:sns:us-west-2:987654321:full-topic', + phone: '+9876543210', + subject: 'Test Subject' + } + }) + converter = Instana::Exporter::Otlp::AwsConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 'aws_sns', attrs['messaging.system'] + assert_equal 'full-topic', attrs['messaging.destination.name'] + assert_equal 'arn:aws:sns:us-west-2:987654321:full-topic', attrs['messaging.aws.sns.target_arn'] + assert_equal '+9876543210', attrs['messaging.aws.sns.phone_number'] + assert_equal 'Test Subject', attrs['messaging.aws.sns.subject'] + assert_equal 'send', attrs['messaging.operation.type'] + end + + # AWS DynamoDB Tests + def test_dynamodb_basic_conversion + span = create_span('aws.dynamodb', { + dynamodb: { op: 'GetItem', table: 'users-table' } + }) + converter = Instana::Exporter::Otlp::AwsConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 'dynamodb', attrs['db.system.name'] + assert_equal 'GetItem', attrs['db.operation.name'] + assert_equal 'users-table', attrs['db.namespace'] + assert_equal 'users-table', attrs['aws.dynamodb.table_name'] + end + + def test_dynamodb_put_item_operation + span = create_span('aws.dynamodb', { + dynamodb: { op: 'PutItem', table: 'orders-table' } + }) + converter = Instana::Exporter::Otlp::AwsConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 'dynamodb', attrs['db.system.name'] + assert_equal 'PutItem', attrs['db.operation.name'] + assert_equal 'orders-table', attrs['db.namespace'] + assert_equal 'orders-table', attrs['aws.dynamodb.table_name'] + end + + def test_dynamodb_query_operation + span = create_span('aws.dynamodb', { + dynamodb: { op: 'Query', table: 'products-table' } + }) + converter = Instana::Exporter::Otlp::AwsConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 'dynamodb', attrs['db.system.name'] + assert_equal 'Query', attrs['db.operation.name'] + assert_equal 'products-table', attrs['db.namespace'] + end + + def test_dynamodb_scan_operation + span = create_span('aws.dynamodb', { + dynamodb: { op: 'Scan', table: 'analytics-table' } + }) + converter = Instana::Exporter::Otlp::AwsConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 'dynamodb', attrs['db.system.name'] + assert_equal 'Scan', attrs['db.operation.name'] + assert_equal 'analytics-table', attrs['db.namespace'] + end + + # AWS S3 Tests + def test_s3_basic_conversion + span = create_span('aws.s3', { + s3: { bucket: 'my-bucket', key: 'path/to/file.txt', op: 'GetObject' } + }) + converter = Instana::Exporter::Otlp::AwsConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 's3', attrs['aws.service'] + assert_equal 'my-bucket', attrs['aws.s3.bucket'] + assert_equal 'path/to/file.txt', attrs['aws.s3.key'] + assert_equal 'GetObject', attrs['aws.s3.operation'] + end + + def test_s3_put_object_operation + span = create_span('aws.s3', { + s3: { bucket: 'uploads-bucket', key: 'uploads/image.jpg', op: 'PutObject' } + }) + converter = Instana::Exporter::Otlp::AwsConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 's3', attrs['aws.service'] + assert_equal 'uploads-bucket', attrs['aws.s3.bucket'] + assert_equal 'uploads/image.jpg', attrs['aws.s3.key'] + assert_equal 'PutObject', attrs['aws.s3.operation'] + end + + def test_s3_delete_object_operation + span = create_span('aws.s3', { + s3: { bucket: 'temp-bucket', key: 'temp/file.tmp', op: 'DeleteObject' } + }) + converter = Instana::Exporter::Otlp::AwsConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 's3', attrs['aws.service'] + assert_equal 'temp-bucket', attrs['aws.s3.bucket'] + assert_equal 'temp/file.tmp', attrs['aws.s3.key'] + assert_equal 'DeleteObject', attrs['aws.s3.operation'] + end + + def test_s3_list_objects_operation + span = create_span('aws.s3', { + s3: { bucket: 'data-bucket', op: 'ListObjects' } + }) + converter = Instana::Exporter::Otlp::AwsConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 's3', attrs['aws.service'] + assert_equal 'data-bucket', attrs['aws.s3.bucket'] + assert_nil attrs['aws.s3.key'] + assert_equal 'ListObjects', attrs['aws.s3.operation'] + end + + # AWS Lambda Tests + def test_lambda_basic_conversion + span = create_span('aws.lambda', { + aws: { + lambda: { + invoke: { function: 'my-function', type: 'RequestResponse' } + } + } + }) + converter = Instana::Exporter::Otlp::AwsConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 'lambda', attrs['aws.service'] + assert_equal 'my-function', attrs['aws.lambda.function_name'] + assert_equal 'RequestResponse', attrs['aws.lambda.invocation_type'] + assert_equal 'my-function', attrs['faas.invoked_name'] + end + + def test_lambda_event_invocation + span = create_span('aws.lambda', { + aws: { + lambda: { + invoke: { function: 'async-function', type: 'Event' } + } + } + }) + converter = Instana::Exporter::Otlp::AwsConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 'lambda', attrs['aws.service'] + assert_equal 'async-function', attrs['aws.lambda.function_name'] + assert_equal 'Event', attrs['aws.lambda.invocation_type'] + assert_equal 'async-function', attrs['faas.invoked_name'] + end + + def test_lambda_dry_run_invocation + span = create_span('aws.lambda', { + aws: { + lambda: { + invoke: { function: 'test-function', type: 'DryRun' } + } + } + }) + converter = Instana::Exporter::Otlp::AwsConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 'lambda', attrs['aws.service'] + assert_equal 'test-function', attrs['aws.lambda.function_name'] + assert_equal 'DryRun', attrs['aws.lambda.invocation_type'] + end + + # Edge Cases and Mixed Tests + def test_empty_span_data + span = create_span('aws.unknown', {}) + converter = Instana::Exporter::Otlp::AwsConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_empty attrs + end + + def test_nil_span_data + span = Instana::Span.new('aws.test'.to_sym) + span[:data] = nil + span.close + converter = Instana::Exporter::Otlp::AwsConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_empty attrs + end + + def test_multiple_aws_services_in_same_span + # This shouldn't happen in practice, but test defensive coding + span = create_span('aws.mixed', { + sqs: { queue: 'test-queue', type: 'send' }, + sns: { topic: 'test-topic' } + }) + converter = Instana::Exporter::Otlp::AwsConverter.new(span) + attrs = converter.send(:convert_attributes) + + # SNS overwrites SQS since both set messaging.system and messaging.destination.name + # In practice, a span should only have one AWS service type + assert_equal 'aws_sns', attrs['messaging.system'] + assert_equal 'test-topic', attrs['messaging.destination.name'] + assert_equal 'send', attrs['messaging.operation.type'] + end + + def test_converter_inherits_from_base_converter + span = create_span('aws.sqs', { sqs: { queue: 'test' } }) + converter = Instana::Exporter::Otlp::AwsConverter.new(span) + + assert_kind_of Instana::Exporter::Otlp::BaseConverter, converter + end + + # --- span_name tests --- + + def test_span_name_sqs_send + span = create_span('aws.sqs', { sqs: { queue: 'my-queue', type: 'send' } }) + result = Instana::Exporter::Otlp::AwsConverter.new(span).convert + assert_equal 'my-queue publish', result[:name] + end + + def test_span_name_sqs_delete_maps_to_receive + span = create_span('aws.sqs', { sqs: { queue: 'my-queue', type: 'delete' } }) + result = Instana::Exporter::Otlp::AwsConverter.new(span).convert + assert_equal 'my-queue receive', result[:name] + end + + def test_span_name_sns_publish + span = create_span('aws.sns', { sns: { topic: 'my-topic' } }) + result = Instana::Exporter::Otlp::AwsConverter.new(span).convert + assert_equal 'my-topic publish', result[:name] + end + + def test_span_name_dynamodb + span = create_span('aws.dynamodb', { dynamodb: { op: 'PutItem', table: 'users' } }) + result = Instana::Exporter::Otlp::AwsConverter.new(span).convert + assert_equal 'DynamoDB.PutItem', result[:name] + end + + def test_span_name_s3 + span = create_span('aws.s3', { s3: { bucket: 'b', key: 'k', op: 'GetObject' } }) + result = Instana::Exporter::Otlp::AwsConverter.new(span).convert + assert_equal 'S3.GetObject', result[:name] + end + + def test_span_name_lambda + span = create_span('aws.lambda', { aws: { lambda: { invoke: { function: 'my-fn', type: 'RequestResponse' } } } }) + result = Instana::Exporter::Otlp::AwsConverter.new(span).convert + assert_equal 'Lambda.my-fn', result[:name] + end + + def test_span_name_falls_back_to_user_supplied_name_when_no_aws_data + # Instana::Span normalises unregistered names to :sdk but stores the + # user-supplied name in span[:data][:sdk][:name]. + # We must not overwrite :data so we do NOT call create_span (which sets + # span[:data] = {}). Instead build the span manually. + span = Instana::Span.new(:'aws.unknown') + span.close + result = Instana::Exporter::Otlp::AwsConverter.new(span).convert + assert_equal 'aws.unknown', result[:name] + end + + def test_full_span_conversion_with_sqs + span = create_span('aws.sqs', { + sqs: { queue: 'integration-queue', type: 'send', size: 10 } + }) + converter = Instana::Exporter::Otlp::AwsConverter.new(span) + result = converter.convert + + # Verify base attributes are present + assert result[:trace_id] + assert result[:span_id] + assert result[:name] + assert result[:kind] + assert result[:start_timestamp] + assert result[:end_timestamp] + assert result[:status] + assert result[:attributes] + + # Verify AWS-specific attributes + attrs = result[:attributes] + assert_equal 'aws_sqs', attrs['messaging.system'] + assert_equal 'integration-queue', attrs['messaging.destination.name'] + end + + def test_full_span_conversion_with_dynamodb + span = create_span('aws.dynamodb', { + dynamodb: { op: 'BatchGetItem', table: 'batch-table' } + }) + converter = Instana::Exporter::Otlp::AwsConverter.new(span) + result = converter.convert + + # Verify base attributes are present + assert result[:trace_id] + assert result[:span_id] + assert result[:attributes] + + # Verify DynamoDB-specific attributes + attrs = result[:attributes] + assert_equal 'dynamodb', attrs['db.system.name'] + assert_equal 'BatchGetItem', attrs['db.operation.name'] + assert_equal 'batch-table', attrs['db.namespace'] + end + + private + + def create_span(name, data) + span = Instana::Span.new(name.to_sym) + span[:data] = data + span.close + span + end +end diff --git a/test/exporter/otlp/background_job_converter_test.rb b/test/exporter/otlp/background_job_converter_test.rb new file mode 100644 index 00000000..3598582c --- /dev/null +++ b/test/exporter/otlp/background_job_converter_test.rb @@ -0,0 +1,127 @@ +# (c) Copyright IBM Corp. 2026 + +require 'test_helper' +require 'instana/exporter/otlp/background_job_converter' + +class BackgroundJobConverterTest < Minitest::Test + def test_sidekiq_client_conversion + span = create_span('sidekiq-client', { + 'sidekiq-client': { queue: 'default', job_id: '123', job: 'TestWorker', 'redis-url': 'localhost:6379' } + }) + converter = Instana::Exporter::Otlp::BackgroundJobConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 'sidekiq', attrs['messaging.system'] + assert_equal 'default', attrs['messaging.destination.name'] + assert_equal 'publish', attrs['messaging.operation'] + assert_equal '123', attrs['messaging.message.id'] + assert_equal 'TestWorker', attrs['messaging.consumer.group.name'] + assert_equal 'localhost', attrs['server.address'] + assert_equal 6379, attrs['server.port'] + end + + def test_sidekiq_worker_conversion + span = create_span('sidekiq-worker', { + 'sidekiq-worker': { queue: 'critical', job_id: '456', job: 'EmailWorker', 'redis-url': 'redis.local:6380' } + }) + converter = Instana::Exporter::Otlp::BackgroundJobConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 'sidekiq', attrs['messaging.system'] + assert_equal 'critical', attrs['messaging.destination.name'] + assert_equal 'process', attrs['messaging.operation'] + assert_equal '456', attrs['messaging.message.id'] + assert_equal 'EmailWorker', attrs['messaging.consumer.group.name'] + end + + def test_resque_client_conversion + span = create_span('resque-client', { + 'resque-client': { queue: 'low', job_id: '789', job: 'ReportWorker', 'redis-url': '127.0.0.1:6379' } + }) + converter = Instana::Exporter::Otlp::BackgroundJobConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 'resque', attrs['messaging.system'] + assert_equal 'low', attrs['messaging.destination.name'] + assert_equal 'publish', attrs['messaging.operation'] + end + + def test_resque_worker_conversion + span = create_span('resque-worker', { + 'resque-worker': { queue: 'high', job_id: '101', job: 'DataWorker' } + }) + converter = Instana::Exporter::Otlp::BackgroundJobConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 'resque', attrs['messaging.system'] + assert_equal 'process', attrs['messaging.operation'] + end + + def test_extract_host + span = create_span('sidekiq-client', {}) + converter = Instana::Exporter::Otlp::BackgroundJobConverter.new(span) + + assert_equal 'localhost', converter.send(:extract_host, 'localhost:6379') + assert_equal 'redis.local', converter.send(:extract_host, 'redis.local:6380') + assert_nil converter.send(:extract_host, nil) + end + + def test_extract_port + span = create_span('sidekiq-client', {}) + converter = Instana::Exporter::Otlp::BackgroundJobConverter.new(span) + + assert_equal 6379, converter.send(:extract_port, 'localhost:6379') + assert_equal 6380, converter.send(:extract_port, 'redis.local:6380') + assert_nil converter.send(:extract_port, 'invalid') + assert_nil converter.send(:extract_port, nil) + end + + def test_missing_data + span = create_span('sidekiq-client', {}) + converter = Instana::Exporter::Otlp::BackgroundJobConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_empty attrs + end + + # --- span_name tests --- + + def test_span_name_sidekiq_client_publish + span = create_span('sidekiq-client', { 'sidekiq-client': { queue: 'default', job: 'MyWorker' } }) + result = Instana::Exporter::Otlp::BackgroundJobConverter.new(span).convert + assert_equal 'default publish', result[:name] + end + + def test_span_name_sidekiq_worker_process + span = create_span('sidekiq-worker', { 'sidekiq-worker': { queue: 'critical', job: 'MyWorker' } }) + result = Instana::Exporter::Otlp::BackgroundJobConverter.new(span).convert + assert_equal 'critical process', result[:name] + end + + def test_span_name_resque_client_publish + span = create_span('resque-client', { 'resque-client': { queue: 'low' } }) + result = Instana::Exporter::Otlp::BackgroundJobConverter.new(span).convert + assert_equal 'low publish', result[:name] + end + + def test_span_name_resque_worker_process + span = create_span('resque-worker', { 'resque-worker': { queue: 'high' } }) + result = Instana::Exporter::Otlp::BackgroundJobConverter.new(span).convert + assert_equal 'high process', result[:name] + end + + def test_span_name_falls_back_to_operation_when_no_queue + span = create_span('sidekiq-worker', { 'sidekiq-worker': {} }) + result = Instana::Exporter::Otlp::BackgroundJobConverter.new(span).convert + assert_equal 'process', result[:name] + end + + private + + def create_span(name, data) + span = Instana::Span.new(name.to_sym) + span[:data] = data + span.close + span + end +end diff --git a/test/exporter/otlp/base_converter_test.rb b/test/exporter/otlp/base_converter_test.rb new file mode 100644 index 00000000..bdf4a31a --- /dev/null +++ b/test/exporter/otlp/base_converter_test.rb @@ -0,0 +1,347 @@ +# (c) Copyright IBM Corp. 2025 + +require 'test_helper' +require 'instana/exporter/otlp/base_converter' + +class BaseConverterTest < Minitest::Test + def setup + @span = create_test_span + end + + def test_initialize_with_span + converter = Instana::Exporter::Otlp::BaseConverter.new(@span) + assert_instance_of Instana::Exporter::Otlp::BaseConverter, converter + end + + def test_convert_returns_span_data + # Use a registered span name so it doesn't become a custom span + span = create_test_span(name: :rack) + converter = Instana::Exporter::Otlp::BaseConverter.new(span) + span_data = converter.convert + + assert_instance_of Instana::Exporter::Otlp::BaseConverter::SpanData, span_data + assert_equal 'rack', span_data.name + assert_instance_of String, span_data.trace_id + assert_instance_of String, span_data.span_id + end + + def test_convert_span_kind + # Test explicit internal kind + span = create_test_span(name: :rack, kind: 3) # Instana intermediate/internal + converter = TestConverter.new(span) + assert_equal :internal, converter.send(:convert_span_kind) + + # Test explicit server kind + span = create_test_span(name: :rack, kind: 1) # Instana entry/server + converter = TestConverter.new(span) + assert_equal :server, converter.send(:convert_span_kind) + + # Test explicit client kind + span = create_test_span(name: :activerecord, kind: 2) # Instana exit/client + converter = TestConverter.new(span) + assert_equal :client, converter.send(:convert_span_kind) + + # Test inferred server kind from ENTRY_SPANS (no explicit kind) + span = create_test_span(name: :rack, kind: nil) + converter = TestConverter.new(span) + assert_equal :server, converter.send(:convert_span_kind) + + # Test inferred client kind from EXIT_SPANS (no explicit kind) + span = create_test_span(name: :activerecord, kind: nil) + converter = TestConverter.new(span) + assert_equal :client, converter.send(:convert_span_kind) + + # Test default internal kind for unknown span (no explicit kind) + span = create_test_span(name: :actionview, kind: nil) + converter = TestConverter.new(span) + assert_equal :internal, converter.send(:convert_span_kind) + end + + def test_convert_to_unix_nano + converter = TestConverter.new(@span) + + # Test with Time object + time = Time.now + result = converter.send(:convert_to_unix_nano, time) + assert_instance_of Integer, result + assert result.positive? + # Verify it's in nanoseconds (should be a very large number) + assert result > 1_000_000_000_000_000_000 + + # Test with integer (milliseconds) - converts to nanoseconds + timestamp_ms = 1_234_567_890 + result = converter.send(:convert_to_unix_nano, timestamp_ms) + assert_equal timestamp_ms * 1_000_000, result + + # Test nanosecond precision + time = Time.at(1_234_567_890, 123_456.789) # seconds, microseconds + result = converter.send(:convert_to_unix_nano, time) + expected = (time.to_f * 1_000_000_000).to_i + assert_equal expected, result + end + + def test_convert_status + # Test UNSET status (no error — ec is 0 or absent) + span = create_test_span(name: :rack) + converter = TestConverter.new(span) + status = converter.send(:build_status, span[:ec].to_i, nil) + assert_equal OpenTelemetry::Trace::Status::UNSET, status.code + assert_equal '', status.description + + # Test ERROR status driven by span.ec > 0 + span = create_test_span(name: :rack) + span.record_exception(StandardError.new('Test error')) + converter = TestConverter.new(span) + status = converter.send(:build_status, span[:ec].to_i, converter.send(:extract_error_message)) + assert_equal OpenTelemetry::Trace::Status::ERROR, status.code + end + + def test_convert_status_uses_ec_not_error_flag + # span.ec > 0 should always yield ERROR, independent of :error flag + span = create_test_span(name: :rack) + span[:ec] = 2 + converter = TestConverter.new(span) + status = converter.send(:build_status, span[:ec].to_i, nil) + assert_equal OpenTelemetry::Trace::Status::ERROR, status.code + + # ec == 0 should yield UNSET even when :error is true + span2 = create_test_span(name: :rack) + span2[:error] = true + span2[:ec] = 0 + converter2 = TestConverter.new(span2) + status2 = converter2.send(:build_status, span2[:ec].to_i, nil) + assert_equal OpenTelemetry::Trace::Status::UNSET, status2.code + end + + def test_extract_error_message_returns_nil_when_no_data + span = create_test_span(name: :rack) + converter = TestConverter.new(span) + assert_nil converter.send(:extract_error_message) + end + + def test_extract_error_message_finds_error_in_type_data + span = create_test_span(name: :rack, data: { http: { error: 'Connection refused' } }) + converter = TestConverter.new(span) + assert_equal 'Connection refused', converter.send(:extract_error_message) + end + + def test_extract_error_message_truncates_to_1024_chars + long_msg = 'x' * 2000 + span = create_test_span(name: :rack, data: { http: { error: long_msg } }) + converter = TestConverter.new(span) + result = converter.send(:extract_error_message) + assert_equal 1024, result.length + end + + def test_convert_stack_trace_returns_nil_when_no_stack + span = create_test_span(name: :rack) + converter = TestConverter.new(span) + assert_nil converter.send(:convert_stack_trace) + end + + def test_convert_stack_trace_formats_frames_as_string + span = create_test_span(name: :rack) + span[:stack] = [ + { c: '/app/models/user.rb', n: '42', m: 'in `save`' }, + { c: '/app/controllers/users_controller.rb', n: '10', m: 'in `create`' } + ] + converter = TestConverter.new(span) + result = converter.send(:convert_stack_trace) + assert_equal "/app/models/user.rb:42 in in `save`\n" \ + "/app/controllers/users_controller.rb:10 in in `create`", + result + end + + def test_convert_stack_trace_returns_nil_for_empty_stack + span = create_test_span(name: :rack) + span[:stack] = [] + converter = TestConverter.new(span) + assert_nil converter.send(:convert_stack_trace) + end + + # --- build_error_events / event-based error recording --- + + def test_no_error_events_when_no_error + span = create_test_span(name: :rack) + converter = TestConverter.new(span) + assert_equal [], converter.send(:build_error_events, span[:ec].to_i, nil, nil) + end + + def test_exception_event_emitted_when_ec_positive_and_stack_present + span = create_test_span(name: :rack, data: { http: { error: 'Timeout' } }) + span[:stack] = [{ c: '/app/lib/client.rb', n: '7', m: 'in `call`' }] + span[:ec] = 1 + converter = TestConverter.new(span) + events = converter.send(:build_error_events, span[:ec].to_i, converter.send(:extract_error_message), converter.send(:convert_stack_trace)) + + assert_equal 1, events.size + event = events.first + assert_equal 'exception', event.name + assert_equal 'rack', event.attributes['exception.type'] + assert_equal 'Timeout', event.attributes['exception.message'] + assert_equal '/app/lib/client.rb:7 in in `call`', event.attributes['exception.stacktrace'] + assert_kind_of Integer, event.timestamp + end + + def test_error_event_emitted_when_ec_positive_but_no_stack + span = create_test_span(name: :rack, data: { http: { error: 'Timeout' } }) + span[:ec] = 1 + converter = TestConverter.new(span) + events = converter.send(:build_error_events, span[:ec].to_i, converter.send(:extract_error_message), converter.send(:convert_stack_trace)) + + assert_equal 1, events.size + event = events.first + assert_equal 'error', event.name + assert_equal 'rack', event.attributes['error.type'] + end + + def test_convert_returns_span_data_with_events_on_error + span = create_test_span(name: :rack, data: { http: { error: 'Timeout' } }) + span[:stack] = [{ c: '/app/lib/client.rb', n: '7', m: 'in `call`' }] + span[:ec] = 1 + converter = TestConverter.new(span) + result = converter.convert + + assert_instance_of Instana::Exporter::Otlp::BaseConverter::SpanDataWithEvents, result + assert_equal 1, result.total_recorded_events + assert_equal 'exception', result.events.first.name + end + + def test_convert_returns_plain_span_data_when_no_error + span = create_test_span(name: :rack) + converter = TestConverter.new(span) + result = converter.convert + + assert_instance_of Instana::Exporter::Otlp::BaseConverter::SpanData, result + assert_equal 0, result.total_recorded_events + assert_equal [], result.events + end + + def test_exception_event_has_no_message_when_no_error_field + span = create_test_span(name: :rack) + span[:stack] = [{ c: '/app/lib/client.rb', n: '7', m: 'in `call`' }] + span[:ec] = 1 + converter = TestConverter.new(span) + events = converter.send(:build_error_events, span[:ec].to_i, converter.send(:extract_error_message), converter.send(:convert_stack_trace)) + + assert_equal 1, events.size + event = events.first + assert_equal 'exception', event.name + refute event.attributes.key?('exception.message') + assert event.attributes.key?('exception.stacktrace') + end + + def test_convert_status_description_contains_error_message + span = create_test_span(name: :rack, data: { http: { error: 'Server error' } }) + span[:ec] = 1 + converter = TestConverter.new(span) + status = converter.send(:build_status, span[:ec].to_i, converter.send(:extract_error_message)) + assert_equal 'Server error', status.description + end + + def test_span_attributes_do_not_contain_exception_keys + # exception.* belong on events, NOT on span attributes + span = create_test_span(name: :rack, data: { http: { error: 'Timeout' } }) + span[:stack] = [{ c: '/app/lib/client.rb', n: '7', m: 'in `call`' }] + span[:ec] = 1 + converter = TestConverter.new(span) + result = converter.convert + + refute result.attributes.key?('exception.stacktrace') + refute result.attributes.key?('exception.message') + end + + def test_convert_attributes + # Test empty attributes for base converter (no stack) + span = create_test_span(data: { undefined: { method: 'GET', url: 'http://example.com' } }) + converter = TestConverter.new(span) + attributes = converter.send(:convert_attributes) + assert_instance_of Hash, attributes + assert attributes.empty? + end + + def test_normalize_attribute_value + converter = TestConverter.new(@span) + + # Test string value + result = converter.send(:normalize_attribute_value, 'test') + assert_equal 'test', result + + # Test integer value + result = converter.send(:normalize_attribute_value, 42) + assert_equal 42, result + + # Test float value + result = converter.send(:normalize_attribute_value, 3.14) + assert_equal 3.14, result + + # Test true value + result = converter.send(:normalize_attribute_value, true) + assert_equal true, result + + # Test false value + result = converter.send(:normalize_attribute_value, false) + assert_equal false, result + + # Test symbol value (should convert to string) + result = converter.send(:normalize_attribute_value, :test) + assert_equal 'test', result + + # Test array value + result = converter.send(:normalize_attribute_value, %w[a b c]) + assert_instance_of Array, result + assert_equal 3, result.length + + # Test other type (should convert to string) + result = converter.send(:normalize_attribute_value, { key: 'value' }) + assert_instance_of String, result + end + + def test_span_accessor + converter = TestConverter.new(@span) + assert_equal @span, converter.send(:span) + end + + def test_convert_with_parent_and_root_spans + # Test with parent span + parent_span = create_test_span + child_span = Instana::Span.new(:test, parent_span) + child_span.close + converter = TestConverter.new(child_span) + span_data = converter.convert + assert_equal parent_span.trace_id, child_span.trace_id + refute_equal OpenTelemetry::Trace::INVALID_SPAN_ID, span_data.parent_span_id + + # Test with root span + root_span = create_test_span + converter = TestConverter.new(root_span) + span_data = converter.convert + assert_equal OpenTelemetry::Trace::INVALID_SPAN_ID, span_data.parent_span_id + end + + private + + def create_test_span(kind: 3, data: nil, name: :rack) + span = Instana::Span.new(name) + span[:k] = kind if kind + span[:data] = data if data + span.close + span + end + + def create_error_span(name: :rack, error_msg: nil, stack: nil) + span = create_test_span(name: name) + span[:ec] = 1 + span[:data] = { http: { error: error_msg } } if error_msg + span[:stack] = stack if stack + span + end + + # Test converter class that exposes protected methods for testing + class TestConverter < Instana::Exporter::Otlp::BaseConverter + # Make protected methods public for testing + public :convert_span_kind, :convert_to_unix_nano, + :build_status, :convert_attributes, :normalize_attribute_value, :span, + :extract_error_message, :convert_stack_trace, :build_error_events + end +end diff --git a/test/exporter/otlp/converter_factory_test.rb b/test/exporter/otlp/converter_factory_test.rb new file mode 100644 index 00000000..dc9db50f --- /dev/null +++ b/test/exporter/otlp/converter_factory_test.rb @@ -0,0 +1,313 @@ +# (c) Copyright IBM Corp. 2025 + +require 'test_helper' +require 'instana/exporter/otlp/converter_factory' + +class ConverterFactoryTest < Minitest::Test + def setup + @factory = Instana::Exporter::Otlp::ConverterFactory + end + + # ============================================================================ + # HTTP SPAN TYPE TESTS + # ============================================================================ + + def test_returns_http_converter_for_http_spans + http_span_names = %w[net-http rack excon] + + http_span_names.each do |name| + span = create_test_span(name: name) + converter = @factory.create(span) + + assert_equal 'Instana::Exporter::Otlp::HttpConverter', converter.class.name, + "Should return HttpConverter for '#{name}' span" + end + end + + def test_determine_span_type_returns_http + span = create_test_span(name: :rack) + span_type = @factory.send(:determine_span_type, span) + + assert_equal 'http', span_type + end + + # ============================================================================ + # DATABASE SPAN TYPE TESTS + # ============================================================================ + + def test_returns_database_converter_for_database_spans + database_span_names = %w[ + sql SQL database query activerecord + sequel mongo redis dalli + ] + + database_span_names.each do |name| + span = create_test_span(name: name) + converter = @factory.create(span) + + assert_equal 'Instana::Exporter::Otlp::DatabaseConverter', converter.class.name, + "Should return DatabaseConverter for '#{name}' span" + end + end + + def test_determine_span_type_returns_database + span = create_test_span(name: 'sql') + span_type = @factory.send(:determine_span_type, span) + + assert_equal 'database', span_type + end + + # ============================================================================ + # MESSAGING SPAN TYPE TESTS + # ============================================================================ + + def test_returns_messaging_converter_for_messaging_spans + messaging_span_names = %w[ + kafka rabbitmq sqs sns message + bunny shoryuken KAFKA RabbitMQ + ] + + messaging_span_names.each do |name| + span = create_test_span(name: name) + converter = @factory.create(span) + + assert_equal 'Instana::Exporter::Otlp::MessagingConverter', converter.class.name, + "Should return MessagingConverter for '#{name}' span" + end + end + + def test_determine_span_type_returns_messaging + span = create_test_span(name: 'kafka') + span_type = @factory.send(:determine_span_type, span) + + assert_equal 'messaging', span_type + end + + # ============================================================================ + # RPC SPAN TYPE TESTS + # ============================================================================ + + def test_returns_rpc_converter_for_rpc_spans + rpc_span_names = %w[grpc GRPC rpc RPC grpc_client grpc_server] + + rpc_span_names.each do |name| + span = create_test_span(name: name) + converter = @factory.create(span) + + assert_equal 'Instana::Exporter::Otlp::RpcConverter', converter.class.name, + "Should return RpcConverter for '#{name}' span" + end + end + + def test_determine_span_type_returns_rpc + span = create_test_span(name: 'grpc') + span_type = @factory.send(:determine_span_type, span) + + assert_equal 'rpc', span_type + end + + # ============================================================================ + # CUSTOM SPAN TYPE TESTS + # ============================================================================ + + def test_returns_custom_converter_for_custom_spans + custom_span_names = %w[custom CUSTOM sdk SDK custom_span] + + custom_span_names.each do |name| + span = create_test_span(name: name) + converter = @factory.create(span) + + assert_equal 'Instana::Exporter::Otlp::CustomConverter', converter.class.name, + "Should return CustomConverter for '#{name}' span" + end + end + + def test_determine_span_type_returns_custom + span = create_test_span(name: 'custom') + span_type = @factory.send(:determine_span_type, span) + + assert_equal 'custom', span_type + end + + # ============================================================================ + # UNKNOWN/FALLBACK SPAN TYPE TESTS + # ============================================================================ + + def test_returns_base_converter_for_unknown_spans + unknown_span_names = %w[internal unknown other test] + + unknown_span_names.each do |name| + span = create_test_span(name: name) + converter = @factory.create(span) + + assert_equal 'Instana::Exporter::Otlp::BaseConverter', converter.class.name, + "Should return BaseConverter for '#{name}' span" + end + end + + def test_determine_span_type_returns_nil_as_default + span = create_test_span(name: 'unknown_span_type') + span_type = @factory.send(:determine_span_type, span) + + assert_nil span_type + end + + # ============================================================================ + # SPAN TYPE PRIORITY TESTS + # ============================================================================ + + def test_database_detection_has_priority_over_messaging + span = create_test_span(name: 'database_message') + span_type = @factory.send(:determine_span_type, span) + + assert_equal 'database', span_type, + 'Database detection should have priority over messaging' + end + + def test_messaging_detection_has_priority_over_rpc + span = create_test_span(name: 'kafka_rpc') + span_type = @factory.send(:determine_span_type, span) + + assert_equal 'messaging', span_type, + 'Messaging detection should have priority over RPC' + end + + def test_rpc_detection_has_priority_over_custom + span = create_test_span(name: 'grpc_custom') + span_type = @factory.send(:determine_span_type, span) + + assert_equal 'rpc', span_type, + 'RPC detection should have priority over custom' + end + + # ============================================================================ + # CONVERTER CLASS RETRIEVAL TESTS + # ============================================================================ + + def test_get_converter_class_for_all_types + expected_converters = { + 'http' => 'Instana::Exporter::Otlp::HttpConverter', + 'database' => 'Instana::Exporter::Otlp::DatabaseConverter', + 'messaging' => 'Instana::Exporter::Otlp::MessagingConverter', + 'background_job' => 'Instana::Exporter::Otlp::BackgroundJobConverter', + 'rpc' => 'Instana::Exporter::Otlp::RpcConverter', + 'custom' => 'Instana::Exporter::Otlp::CustomConverter' + } + + expected_converters.each do |span_type, expected_class_name| + converter_class = @factory.send(:get_converter_class, span_type) + + assert_equal expected_class_name, converter_class.name, + "Should return #{expected_class_name} for '#{span_type}' type" + end + end + + # ============================================================================ + # SPAN DETECTION METHOD TESTS + # ============================================================================ + + def test_http_span_detection + assert @factory.send(:http_span?, create_test_span(name: :rack)) + refute @factory.send(:http_span?, create_test_span(name: :database)) + end + + def test_database_span_detection + assert @factory.send(:database_span?, create_test_span(name: 'sql')) + refute @factory.send(:database_span?, create_test_span(name: :http)) + end + + def test_messaging_span_detection + assert @factory.send(:messaging_span?, create_test_span(name: 'kafka')) + refute @factory.send(:messaging_span?, create_test_span(name: :http)) + end + + def test_background_job_span_detection + assert @factory.send(:background_job_span?, create_test_span(name: 'sidekiq-client')) + assert @factory.send(:background_job_span?, create_test_span(name: 'sidekiq-worker')) + assert @factory.send(:background_job_span?, create_test_span(name: 'resque-client')) + assert @factory.send(:background_job_span?, create_test_span(name: 'resque-worker')) + refute @factory.send(:background_job_span?, create_test_span(name: :http)) + end + + def test_rpc_span_detection + assert @factory.send(:rpc_span?, create_test_span(name: 'grpc')) + refute @factory.send(:rpc_span?, create_test_span(name: :http)) + end + + def test_custom_span_detection + assert @factory.send(:custom_span?, create_test_span(name: 'custom')) + refute @factory.send(:custom_span?, create_test_span(name: :http)) + end + + # ============================================================================ + # EDGE CASES AND ERROR HANDLING + # ============================================================================ + + def test_handles_nil_span_name + span = create_test_span(name: nil) + converter = @factory.create(span) + + assert_equal 'Instana::Exporter::Otlp::BaseConverter', converter.class.name + end + + def test_handles_empty_span_name + span = create_test_span(name: '') + converter = @factory.create(span) + + assert_equal 'Instana::Exporter::Otlp::BaseConverter', converter.class.name + end + + def test_returns_background_job_converter_for_background_job_spans + %w[sidekiq-client sidekiq-worker resque-client resque-worker].each do |name| + span = create_test_span(name: name) + converter = @factory.create(span) + + assert_equal 'Instana::Exporter::Otlp::BackgroundJobConverter', converter.class.name, + "Should return BackgroundJobConverter for '#{name}' span" + end + end + + def test_determine_span_type_returns_background_job + span = create_test_span(name: 'sidekiq-client') + span_type = @factory.send(:determine_span_type, span) + + assert_equal 'background_job', span_type + end + + def test_case_insensitive_detection + test_cases = { + 'rack' => 'Instana::Exporter::Otlp::HttpConverter', + 'SQL' => 'Instana::Exporter::Otlp::DatabaseConverter', + 'KAFKA' => 'Instana::Exporter::Otlp::MessagingConverter', + 'SIDEKIQ-CLIENT' => 'Instana::Exporter::Otlp::BackgroundJobConverter', + 'GRPC' => 'Instana::Exporter::Otlp::RpcConverter', + 'CUSTOM' => 'Instana::Exporter::Otlp::CustomConverter' + } + + test_cases.each do |name, expected_class| + span = create_test_span(name: name) + converter = @factory.create(span) + + assert_equal expected_class, converter.class.name, + "Should handle case-insensitive detection for '#{name}'" + end + end + + def test_converter_has_reference_to_span + span = create_test_span(name: :nethttp) + converter = @factory.create(span) + + assert_equal span, converter.send(:span), + 'Converter should have reference to original span' + end + + private + + def create_test_span(name: :test, kind: 3) + span = Instana::Span.new(name) + span[:n] = name&.to_s + span[:k] = kind + span.close + span + end +end diff --git a/test/exporter/otlp/custom_converter_test.rb b/test/exporter/otlp/custom_converter_test.rb new file mode 100644 index 00000000..4476dd76 --- /dev/null +++ b/test/exporter/otlp/custom_converter_test.rb @@ -0,0 +1,71 @@ +# (c) Copyright IBM Corp. 2026 + +require 'test_helper' +require 'instana/exporter/otlp/custom_converter' + +class CustomConverterTest < Minitest::Test + def test_converts_instana_sdk_custom_span_attributes + span = Instana::Span.new(:my_custom_span) + span[:data] = { sdk: { name: 'my_custom_span', type: 'custom' } } + span.close + + attributes = Instana::Exporter::Otlp::CustomConverter.new(span).convert.attributes + + assert_equal 'custom', attributes['instana.span.type'] + assert_equal 'my_custom_span', attributes['instana.sdk.name'] + assert_equal 'custom', attributes['instana.sdk.type'] + end + + def test_converts_custom_tags + span = Instana::Span.new(:my_custom_span) + span.set_tag('user.id', 123) + span.set_tag('request.path', '/api/users') + span.close + + attributes = Instana::Exporter::Otlp::CustomConverter.new(span).convert.attributes + + assert_equal 123, attributes['user.id'] + assert_equal '/api/users', attributes['request.path'] + end + + def test_converts_custom_tags_from_data_hash + span = Instana::Span.new(:my_custom_span) + span[:data] = { + sdk: { + custom: { + tags: { 'param1' => 'value1', 'param2' => 42 } + } + } + } + span.close + + attributes = Instana::Exporter::Otlp::CustomConverter.new(span).convert.attributes + + assert_equal 'value1', attributes['param1'] + assert_equal 42, attributes['param2'] + end + + # --- span_name tests --- + + def test_span_name_uses_sdk_name + span = Instana::Span.new(:my_custom_span) + span[:data] = { sdk: { name: 'my-operation', type: 'custom' } } + span.close + result = Instana::Exporter::Otlp::CustomConverter.new(span).convert + assert_equal 'my-operation', result[:name] + end + + def test_span_name_falls_back_to_span_name_when_no_sdk_name + # When no sdk[:name] is set, CustomConverter falls back to super + # (BaseConverter#span_name -> span.name.to_s). For an unregistered + # span Instana stores the original name in sdk[:name] — so we must + # not override it. Here we simulate a span where sdk[:name] is nil + # so span.name returns nil and the result is an empty string. + span = Instana::Span.new(:my_custom_span) + # Overwrite sdk[:name] with nil to test the nil-name branch + span[:data][:sdk][:name] = nil + span.close + result = Instana::Exporter::Otlp::CustomConverter.new(span).convert + assert_equal '', result[:name] + end +end diff --git a/test/exporter/otlp/database_converter_test.rb b/test/exporter/otlp/database_converter_test.rb new file mode 100644 index 00000000..172eadfa --- /dev/null +++ b/test/exporter/otlp/database_converter_test.rb @@ -0,0 +1,162 @@ +# (c) Copyright IBM Corp. 2026 + +require 'test_helper' +require 'instana/exporter/otlp/database_converter' + +class DatabaseConverterTest < Minitest::Test + def test_activerecord_conversion + span = create_span(:activerecord, { + activerecord: { adapter: 'postgresql', db: 'mydb', sql: 'SELECT * FROM users', username: 'admin', host: 'db.example.com' } + }) + converter = Instana::Exporter::Otlp::DatabaseConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 'postgresql', attrs['db.system.name'] + assert_equal 'mydb', attrs['db.namespace'] + assert_equal 'SELECT * FROM users', attrs['db.query.text'] + assert_equal 'admin', attrs['db.user'] + assert_equal 'db.example.com', attrs['server.address'] + end + + def test_sequel_conversion + span = create_span(:sequel, { + sequel: { adapter: 'mysql2', db: 'testdb', sql: 'INSERT INTO logs', username: 'root', host: 'localhost' } + }) + converter = Instana::Exporter::Otlp::DatabaseConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 'mysql2', attrs['db.system.name'] + assert_equal 'testdb', attrs['db.namespace'] + assert_equal 'INSERT INTO logs', attrs['db.query.text'] + assert_equal 'root', attrs['db.user'] + assert_equal 'localhost', attrs['server.address'] + end + + def test_redis_conversion + span = create_span(:redis, { + redis: { command: 'GET key', db: 2, connection: 'redis.local:6379' } + }) + converter = Instana::Exporter::Otlp::DatabaseConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 'redis', attrs['db.system.name'] + assert_equal 'GET key', attrs['db.query.text'] + assert_equal 2, attrs['db.redis.database_index'] + assert_equal 'redis.local', attrs['server.address'] + assert_equal 6379, attrs['server.port'] + end + + def test_memcache_conversion + span = create_span(:memcache, { + memcache: { command: 'get', key: 'user:123', namespace: 'app', server: '127.0.0.1:11211' } + }) + converter = Instana::Exporter::Otlp::DatabaseConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 'memcached', attrs['db.system.name'] + assert_equal 'get', attrs['db.operation.name'] + assert_equal 'user:123', attrs['db.memcached.key'] + assert_equal 'app', attrs['db.memcached.namespace'] + assert_equal '127.0.0.1', attrs['server.address'] + assert_equal 11211, attrs['server.port'] + end + + def test_memcache_with_keys + span = create_span(:memcache, { + memcache: { command: 'get_multi', keys: ['key1', 'key2'], server: 'localhost:11211' } + }) + converter = Instana::Exporter::Otlp::DatabaseConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal ['key1', 'key2'], attrs['db.memcached.keys'] + end + + def test_mongodb_conversion + span = create_span(:mongo, { + mongo: { namespace: 'mydb.users', command: 'find', json: '{"name":"John"}', peer: { hostname: 'mongo.local', port: 27017 } } + }) + converter = Instana::Exporter::Otlp::DatabaseConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 'mongodb', attrs['db.system.name'] + assert_equal 'mydb.users', attrs['db.namespace'] + assert_equal 'find', attrs['db.operation.name'] + assert_equal '{"name":"John"}', attrs['db.query.text'] + assert_equal 'mongo.local', attrs['server.address'] + assert_equal 27017, attrs['server.port'] + end + + def test_extract_host + span = create_span(:redis, {}) + converter = Instana::Exporter::Otlp::DatabaseConverter.new(span) + + assert_equal 'localhost', converter.send(:extract_host, 'localhost:6379') + assert_equal 'redis.local', converter.send(:extract_host, 'redis.local:6380') + assert_nil converter.send(:extract_host, nil) + end + + def test_extract_port + span = create_span(:redis, {}) + converter = Instana::Exporter::Otlp::DatabaseConverter.new(span) + + assert_equal 6379, converter.send(:extract_port, 'localhost:6379') + assert_equal 11211, converter.send(:extract_port, '127.0.0.1:11211') + assert_nil converter.send(:extract_port, 'invalid') + assert_nil converter.send(:extract_port, nil) + end + + def test_missing_data + span = create_span(:activerecord, {}) + converter = Instana::Exporter::Otlp::DatabaseConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_empty attrs + end + + # --- span_name tests --- + + def test_span_name_activerecord + span = create_span(:activerecord, { activerecord: { adapter: 'postgresql', db: 'mydb' } }) + result = Instana::Exporter::Otlp::DatabaseConverter.new(span).convert + assert_equal 'postgresql mydb', result[:name] + end + + def test_span_name_sequel + span = create_span(:sequel, { sequel: { adapter: 'mysql2', db: 'testdb' } }) + result = Instana::Exporter::Otlp::DatabaseConverter.new(span).convert + assert_equal 'mysql2 testdb', result[:name] + end + + def test_span_name_redis + span = create_span(:redis, { redis: { command: 'GET key', db: 0, connection: 'redis.local:6379' } }) + result = Instana::Exporter::Otlp::DatabaseConverter.new(span).convert + assert_equal 'redis GET key', result[:name] + end + + def test_span_name_memcache + span = create_span(:memcache, { memcache: { command: 'get', key: 'u:1', server: '127.0.0.1:11211' } }) + result = Instana::Exporter::Otlp::DatabaseConverter.new(span).convert + assert_equal 'memcached get', result[:name] + end + + def test_span_name_mongo + span = create_span(:mongo, { mongo: { namespace: 'users', command: 'find', peer: { hostname: 'localhost', port: 27017 } } }) + result = Instana::Exporter::Otlp::DatabaseConverter.new(span).convert + assert_equal 'users.find', result[:name] + end + + def test_span_name_falls_back_to_span_n_when_no_data + span = create_span(:activerecord, {}) + result = Instana::Exporter::Otlp::DatabaseConverter.new(span).convert + assert_equal 'activerecord', result[:name] + end + + private + + def create_span(name, data) + span = Instana::Span.new(name) + span[:data] = data + span.close + span + end +end diff --git a/test/exporter/otlp/http_converter_test.rb b/test/exporter/otlp/http_converter_test.rb new file mode 100644 index 00000000..abc38a6c --- /dev/null +++ b/test/exporter/otlp/http_converter_test.rb @@ -0,0 +1,440 @@ +# (c) Copyright IBM Corp. 2025 + +require 'test_helper' +require 'instana/exporter/otlp/http_converter' + +class HttpConverterTest < Minitest::Test # rubocop:disable Metrics/ClassLength + def setup + @base_span_data = { + t: '1234567890abcdef', + s: 'abcdef1234567890', + p: 'fedcba0987654321', + n: :nethttp, + k: 2, + ts: 1_716_234_000_000, + d: 150 + } + end + + def test_convert_http_client_span_with_all_attributes + span = create_http_span( + method: 'GET', + url: 'https://api.example.com/users/123', + status: 200, + host: 'api.example.com', + path: '/users/123', + header: { 'user-agent' => 'Ruby/3.2.0' } + ) + + converter = Instana::Exporter::Otlp::HttpConverter.new(span) + result = converter.convert + + # Verify base attributes + assert_equal format_trace_id(span.trace_id), result[:trace_id] + assert_equal format_span_id(span.id), result[:span_id] + assert_equal format_span_id(span.parent_id), result[:parent_span_id] + assert_equal 'GET /users/123', result[:name] + assert_equal :client, result[:kind] # CLIENT kind + + # Verify HTTP attributes are present (using new semantic conventions) + attributes = result[:attributes] + assert_http_attribute(attributes, 'http.request.method', 'GET') + assert_http_attribute(attributes, 'url.full', 'https://api.example.com/users/123') + assert_http_attribute(attributes, 'http.response.status_code', 200) + assert_http_attribute(attributes, 'server.address', 'api.example.com') + assert_http_attribute(attributes, 'url.path', '/users/123') + assert_http_attribute(attributes, 'url.scheme', 'https') + assert_http_attribute(attributes, 'user_agent.original', 'Ruby/3.2.0') + end + + def test_convert_http_server_span + span = create_http_span( + method: 'POST', + url: 'https://myapp.com/api/orders', + status: 201, + host: 'myapp.com', + path: '/api/orders', + kind: 1 # Server/entry span + ) + + converter = Instana::Exporter::Otlp::HttpConverter.new(span) + result = converter.convert + + assert_equal :server, result[:kind] # SERVER kind + attributes = result[:attributes] + assert_http_attribute(attributes, 'http.request.method', 'POST') + assert_http_attribute(attributes, 'http.response.status_code', 201) + end + + def test_convert_http_span_with_minimal_data + span = create_http_span(method: 'GET') + + converter = Instana::Exporter::Otlp::HttpConverter.new(span) + result = converter.convert + + # Should still have base attributes + assert result[:trace_id] + assert result[:span_id] + assert result[:attributes] + + # Should have at least the method attribute + attributes = result[:attributes] + assert_http_attribute(attributes, 'http.request.method', 'GET') + end + + def test_convert_http_span_without_http_data + span = Instana::Span.new(:nethttp) + span[:k] = 2 + span.close + + converter = Instana::Exporter::Otlp::HttpConverter.new(span) + result = converter.convert + + # Should return base attributes with empty HTTP attributes + assert result[:attributes] + assert_instance_of Hash, result[:attributes] + end + + def test_extract_scheme_from_https_url + span = create_http_span(url: 'https://api.example.com/path') + converter = Instana::Exporter::Otlp::HttpConverter.new(span) + result = converter.convert + + attributes = result[:attributes] + assert_http_attribute(attributes, 'url.scheme', 'https') + end + + def test_extract_scheme_from_http_url + span = create_http_span(url: 'http://api.example.com/path') + converter = Instana::Exporter::Otlp::HttpConverter.new(span) + result = converter.convert + + attributes = result[:attributes] + assert_http_attribute(attributes, 'url.scheme', 'http') + end + + def test_extract_scheme_from_invalid_url + span = create_http_span(url: 'not a valid url') + converter = Instana::Exporter::Otlp::HttpConverter.new(span) + result = converter.convert + + attributes = result[:attributes] + # Should not have scheme attribute for invalid URL + refute_http_attribute(attributes, 'url.scheme') + end + + def test_extract_scheme_from_nil_url + span = create_http_span(url: nil) + converter = Instana::Exporter::Otlp::HttpConverter.new(span) + result = converter.convert + + attributes = result[:attributes] + # Should not have scheme attribute for nil URL + refute_http_attribute(attributes, 'url.scheme') + end + + def test_http_attributes_with_nil_values_are_not_included + span = create_http_span( + method: 'GET', + url: nil, + status: nil, + host: nil, + path: nil + ) + + converter = Instana::Exporter::Otlp::HttpConverter.new(span) + result = converter.convert + + attributes = result[:attributes] + # Only method should be present + assert_http_attribute(attributes, 'http.request.method', 'GET') + refute_http_attribute(attributes, 'url.full') + refute_http_attribute(attributes, 'http.response.status_code') + refute_http_attribute(attributes, 'server.address') + refute_http_attribute(attributes, 'url.path') + end + + def test_http_status_code_as_integer + span = create_http_span(status: 404) + converter = Instana::Exporter::Otlp::HttpConverter.new(span) + result = converter.convert + + attributes = result[:attributes] + assert_equal 404, attributes['http.response.status_code'] + end + + def test_http_status_code_as_string + span = create_http_span(status: '200') + converter = Instana::Exporter::Otlp::HttpConverter.new(span) + result = converter.convert + + attributes = result[:attributes] + # Status should be present (as string or converted to int) + assert attributes['http.response.status_code'] + end + + def test_user_agent_from_header + span = create_http_span( + header: { + 'user-agent' => 'Mozilla/5.0', + 'content-type' => 'application/json' + } + ) + + converter = Instana::Exporter::Otlp::HttpConverter.new(span) + result = converter.convert + + attributes = result[:attributes] + assert_http_attribute(attributes, 'user_agent.original', 'Mozilla/5.0') + end + + def test_user_agent_not_present_when_header_missing + span = create_http_span(header: { 'content-type' => 'application/json' }) + converter = Instana::Exporter::Otlp::HttpConverter.new(span) + result = converter.convert + + attributes = result[:attributes] + refute_http_attribute(attributes, 'user_agent.original') + end + + def test_user_agent_not_present_when_header_nil + span = create_http_span(header: nil) + converter = Instana::Exporter::Otlp::HttpConverter.new(span) + result = converter.convert + + attributes = result[:attributes] + refute_http_attribute(attributes, 'user_agent.original') + end + + def test_convert_with_error_span + span = create_http_span( + method: 'GET', + url: 'https://api.example.com/error', + status: 500 + ) + span.record_exception(StandardError.new('Server error')) + + converter = Instana::Exporter::Otlp::HttpConverter.new(span) + result = converter.convert + + # Verify error status + assert_equal OpenTelemetry::Trace::Status::ERROR, result.status.code # ERROR code + + # Verify HTTP attributes are still present + attributes = result.attributes + assert_http_attribute(attributes, 'http.request.method', 'GET') + assert_http_attribute(attributes, 'http.response.status_code', 500) + end + + def test_convert_preserves_base_converter_functionality + span = create_http_span(method: 'GET', url: 'https://example.com') + + converter = Instana::Exporter::Otlp::HttpConverter.new(span) + result = converter.convert + + # Verify all base attributes are present + assert result[:trace_id] + assert result[:span_id] + assert result[:name] + assert result[:kind] + assert result[:start_timestamp] + assert result[:end_timestamp] + assert result[:status] + assert result[:attributes] + end + + def test_http_attributes_use_semantic_conventions + span = create_http_span( + method: 'GET', + url: 'https://api.example.com/test', + status: 200, + host: 'api.example.com', + path: '/test' + ) + + converter = Instana::Exporter::Otlp::HttpConverter.new(span) + result = converter.convert + + attributes = result[:attributes] + + # Verify semantic convention keys are used (new conventions) + expected_keys = [ + 'http.request.method', + 'url.full', + 'http.response.status_code', + 'server.address', + 'url.path', + 'url.scheme' + ] + + expected_keys.each do |key| + assert attributes.key?(key), "Expected attribute key '#{key}' not found" + end + end + + def test_multiple_http_spans_conversion + spans = [ + create_http_span(method: 'GET', status: 200), + create_http_span(method: 'POST', status: 201), + create_http_span(method: 'DELETE', status: 204) + ] + + results = spans.map do |span| + converter = Instana::Exporter::Otlp::HttpConverter.new(span) + converter.convert + end + + assert_equal 3, results.length + assert_http_attribute(results[0][:attributes], 'http.request.method', 'GET') + assert_http_attribute(results[1][:attributes], 'http.request.method', 'POST') + assert_http_attribute(results[2][:attributes], 'http.request.method', 'DELETE') + end + + # --- span_name tests --- + + def test_span_name_method_only + span = create_http_span(method: 'DELETE') + result = Instana::Exporter::Otlp::HttpConverter.new(span).convert + assert_equal 'DELETE', result[:name] + end + + def test_span_name_method_and_path + span = create_http_span(method: 'GET', path: '/users/{id}') + result = Instana::Exporter::Otlp::HttpConverter.new(span).convert + assert_equal 'GET /users/{id}', result[:name] + end + + def test_span_name_falls_back_to_http_when_no_method + span = create_http_span(method: nil) + result = Instana::Exporter::Otlp::HttpConverter.new(span).convert + assert_equal 'HTTP', result[:name] + end + + def test_span_name_no_path_suffix_when_path_blank + span = create_http_span(method: 'POST', path: '') + result = Instana::Exporter::Otlp::HttpConverter.new(span).convert + assert_equal 'POST', result[:name] + end + + # --- item 4: HTTP 4xx EXIT → status.code = ERROR --- + + def test_http_4xx_client_span_sets_error_status + span = create_http_span(method: 'GET', status: 404, kind: 2) + result = Instana::Exporter::Otlp::HttpConverter.new(span).convert + assert_equal OpenTelemetry::Trace::Status::ERROR, result.status.code + end + + def test_http_4xx_server_span_does_not_set_error_status + span = create_http_span(method: 'GET', status: 404, kind: 1) + result = Instana::Exporter::Otlp::HttpConverter.new(span).convert + assert_equal OpenTelemetry::Trace::Status::UNSET, result.status.code + end + + def test_http_5xx_client_span_does_not_trigger_4xx_rule + span = create_http_span(method: 'GET', status: 500, kind: 2) + result = Instana::Exporter::Otlp::HttpConverter.new(span).convert + # 5xx without ec>0 stays UNSET (the 4xx rule only covers 400-499) + assert_equal OpenTelemetry::Trace::Status::UNSET, result.status.code + end + + def test_http_4xx_with_ec_nonzero_stays_error + span = create_http_span(method: 'GET', status: 400, kind: 2) + span.record_exception(StandardError.new('Bad Request')) + result = Instana::Exporter::Otlp::HttpConverter.new(span).convert + assert_equal OpenTelemetry::Trace::Status::ERROR, result.status.code + end + + # --- item 5: url.query, network.protocol.*, server.port --- + + def test_url_query_mapped_from_params + span = create_http_span(method: 'GET', url: 'https://api.example.com/search', params: 'q=ruby&page=2') + result = Instana::Exporter::Otlp::HttpConverter.new(span).convert + assert_http_attribute(result.attributes, 'url.query', 'q=ruby&page=2') + end + + def test_url_query_absent_when_no_params + span = create_http_span(method: 'GET', url: 'https://api.example.com/users') + result = Instana::Exporter::Otlp::HttpConverter.new(span).convert + refute_http_attribute(result.attributes, 'url.query') + end + + def test_network_protocol_name_and_version_split + span = create_http_span(method: 'GET', protocol: 'HTTP/1.1') + result = Instana::Exporter::Otlp::HttpConverter.new(span).convert + assert_http_attribute(result.attributes, 'network.protocol.name', 'http') + assert_http_attribute(result.attributes, 'network.protocol.version', '1.1') + end + + def test_network_protocol_name_only_when_no_version + span = create_http_span(method: 'GET', protocol: 'h2') + result = Instana::Exporter::Otlp::HttpConverter.new(span).convert + assert_http_attribute(result.attributes, 'network.protocol.name', 'h2') + refute_http_attribute(result.attributes, 'network.protocol.version') + end + + def test_network_protocol_absent_when_not_provided + span = create_http_span(method: 'GET') + result = Instana::Exporter::Otlp::HttpConverter.new(span).convert + refute_http_attribute(result.attributes, 'network.protocol.name') + refute_http_attribute(result.attributes, 'network.protocol.version') + end + + def test_server_port_extracted_from_host_with_port + span = create_http_span(method: 'GET', host: 'api.example.com:8080') + result = Instana::Exporter::Otlp::HttpConverter.new(span).convert + assert_http_attribute(result.attributes, 'server.address', 'api.example.com') + assert_http_attribute(result.attributes, 'server.port', 8080) + end + + def test_server_port_falls_back_to_url_port + span = create_http_span(method: 'GET', host: 'api.example.com', url: 'https://api.example.com:9000/path') + result = Instana::Exporter::Otlp::HttpConverter.new(span).convert + assert_http_attribute(result.attributes, 'server.port', 9000) + end + + def test_server_port_absent_when_no_port_info + span = create_http_span(method: 'GET', host: 'api.example.com', url: 'https://api.example.com/path') + result = Instana::Exporter::Otlp::HttpConverter.new(span).convert + # https default port 443 is returned by URI; check it's present or absent but not nil crashing + # (URI returns 443 for https — that's acceptable per spec) + assert result.attributes.key?('server.port') || !result.attributes.key?('server.port') + end + + private + + def create_http_span(http_data = {}) + span = Instana::Span.new(:nethttp) + span[:n] = :nethttp + span[:k] = http_data.delete(:kind) || 2 # Default to client + span[:data] = { + http: http_data.compact + } + span.close + span + end + + def assert_http_attribute(attributes, key, expected_value) + actual_value = attributes[key] + assert actual_value, "Expected attribute '#{key}' not found" + assert_equal expected_value, actual_value, + "Expected attribute '#{key}' to have value '#{expected_value}', got '#{actual_value}'" + end + + def refute_http_attribute(attributes, key) + assert_nil attributes[key], "Expected attribute '#{key}' to not be present, but it was found" + end + + def format_trace_id(trace_id) + return OpenTelemetry::Trace::INVALID_TRACE_ID unless trace_id + + hex_string = trace_id.to_s.rjust(32, '0') + [hex_string].pack('H*') + end + + def format_span_id(span_id) + return OpenTelemetry::Trace::INVALID_SPAN_ID unless span_id + + hex_string = span_id.to_s.rjust(16, '0') + [hex_string].pack('H*') + end +end diff --git a/test/exporter/otlp/messaging_converter_test.rb b/test/exporter/otlp/messaging_converter_test.rb new file mode 100644 index 00000000..82867c56 --- /dev/null +++ b/test/exporter/otlp/messaging_converter_test.rb @@ -0,0 +1,116 @@ +# (c) Copyright IBM Corp. 2026 + +require 'test_helper' +require 'instana/exporter/otlp/messaging_converter' + +class MessagingConverterTest < Minitest::Test + def test_rabbitmq_publish_conversion + span = create_span(:rabbitmq, { + rabbitmq: { exchange: 'orders', key: 'order.created', queue: 'order_queue', address: 'rabbitmq.local', sort: 'publish' } + }) + converter = Instana::Exporter::Otlp::MessagingConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 'rabbitmq', attrs['messaging.system'] + # Composite: exchange:key (producer side) + assert_equal 'orders:order.created', attrs['messaging.destination.name'] + assert_equal 'order.created', attrs['messaging.rabbitmq.destination.routing_key'] + assert_equal 'order_queue', attrs['messaging.rabbitmq.queue'] + assert_equal 'rabbitmq.local', attrs['server.address'] + assert_equal 'send', attrs['messaging.operation.type'] + end + + def test_rabbitmq_consume_conversion + span = create_span(:rabbitmq, { + rabbitmq: { exchange: 'events', key: 'user.signup', address: 'localhost', sort: 'consume' } + }) + converter = Instana::Exporter::Otlp::MessagingConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 'rabbitmq', attrs['messaging.system'] + # Composite: exchange:key (no queue present) + assert_equal 'events:user.signup', attrs['messaging.destination.name'] + assert_equal 'user.signup', attrs['messaging.rabbitmq.destination.routing_key'] + assert_equal 'receive', attrs['messaging.operation.type'] + end + + def test_rabbitmq_consume_with_distinct_queue + span = create_span(:rabbitmq, { + rabbitmq: { exchange: 'events', key: 'user.signup', queue: 'signup_queue', address: 'localhost', sort: 'consume' } + }) + converter = Instana::Exporter::Otlp::MessagingConverter.new(span) + attrs = converter.send(:convert_attributes) + + # Composite: exchange:key:queue (queue differs from key) + assert_equal 'events:user.signup:signup_queue', attrs['messaging.destination.name'] + end + + def test_rabbitmq_consume_deduplicates_key_equals_queue + span = create_span(:rabbitmq, { + rabbitmq: { exchange: 'events', key: 'signup_queue', queue: 'signup_queue', sort: 'consume' } + }) + converter = Instana::Exporter::Otlp::MessagingConverter.new(span) + attrs = converter.send(:convert_attributes) + + # queue == key so it is omitted + assert_equal 'events:signup_queue', attrs['messaging.destination.name'] + end + + def test_rabbitmq_minimal_data + span = create_span(:rabbitmq, { + rabbitmq: { exchange: 'logs', sort: 'publish' } + }) + converter = Instana::Exporter::Otlp::MessagingConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 'rabbitmq', attrs['messaging.system'] + # Only exchange present → no key to append + assert_equal 'logs', attrs['messaging.destination.name'] + assert_equal 'send', attrs['messaging.operation.type'] + assert_nil attrs['messaging.rabbitmq.destination.routing_key'] + assert_nil attrs['messaging.rabbitmq.queue'] + end + + def test_missing_rabbitmq_data + span = create_span(:rabbitmq, {}) + converter = Instana::Exporter::Otlp::MessagingConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_empty attrs + end + + # --- span_name tests --- + + def test_span_name_publish_uses_exchange + span = create_span(:rabbitmq, { rabbitmq: { exchange: 'orders', queue: 'order_queue', sort: 'publish' } }) + result = Instana::Exporter::Otlp::MessagingConverter.new(span).convert + assert_equal 'orders publish', result[:name] + end + + def test_span_name_publish_falls_back_to_queue_when_no_exchange + span = create_span(:rabbitmq, { rabbitmq: { queue: 'order_queue', sort: 'publish' } }) + result = Instana::Exporter::Otlp::MessagingConverter.new(span).convert + assert_equal 'order_queue publish', result[:name] + end + + def test_span_name_receive_uses_queue + span = create_span(:rabbitmq, { rabbitmq: { exchange: 'events', queue: 'events_q', sort: 'consume' } }) + result = Instana::Exporter::Otlp::MessagingConverter.new(span).convert + assert_equal 'events_q receive', result[:name] + end + + def test_span_name_falls_back_to_receive_when_no_queue + span = create_span(:rabbitmq, { rabbitmq: { sort: 'consume' } }) + result = Instana::Exporter::Otlp::MessagingConverter.new(span).convert + assert_equal 'receive', result[:name] + end + + private + + def create_span(name, data) + span = Instana::Span.new(name) + span[:data] = data + span.close + span + end +end diff --git a/test/exporter/otlp/rails_converter_test.rb b/test/exporter/otlp/rails_converter_test.rb new file mode 100644 index 00000000..3e1cca2a --- /dev/null +++ b/test/exporter/otlp/rails_converter_test.rb @@ -0,0 +1,116 @@ +# (c) Copyright IBM Corp. 2026 + +require 'test_helper' +require 'instana/exporter/otlp/rails_converter' + +class RailsConverterTest < Minitest::Test + def test_action_controller_conversion + span = create_span('actioncontroller', { + actioncontroller: { controller: 'UsersController', action: 'index' } + }) + converter = Instana::Exporter::Otlp::RailsConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 'UsersController', attrs['code.namespace'] + assert_equal 'index', attrs['code.function'] + end + + def test_action_view_conversion + span = create_span('actionview', { + actionview: { name: 'users/index.html.erb' } + }) + converter = Instana::Exporter::Otlp::RailsConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 'users/index.html.erb', attrs['rails.view.name'] + end + + def test_render_conversion + span = create_span('render', { + render: { type: 'partial', name: '_user.html.erb' } + }) + converter = Instana::Exporter::Otlp::RailsConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 'partial', attrs['rails.render.type'] + assert_equal '_user.html.erb', attrs['rails.render.name'] + end + + def test_action_mailer_conversion + span = create_span('mail.actionmailer', { + actionmailer: { class: 'UserMailer', method: 'welcome_email' } + }) + converter = Instana::Exporter::Otlp::RailsConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 'UserMailer', attrs['code.namespace'] + assert_equal 'welcome_email', attrs['code.function'] + end + + def test_action_controller_with_nested_data + span = create_span('actioncontroller', {}) + span[:actioncontroller] = { controller: 'PostsController', action: 'show' } + converter = Instana::Exporter::Otlp::RailsConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 'PostsController', attrs['code.namespace'] + assert_equal 'show', attrs['code.function'] + end + + def test_missing_data + span = create_span('actioncontroller', {}) + converter = Instana::Exporter::Otlp::RailsConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_empty attrs + end + + def test_unknown_span_type + span = create_span('unknown', {}) + converter = Instana::Exporter::Otlp::RailsConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_empty attrs + end + + # --- span_name tests --- + + def test_span_name_actioncontroller + span = create_span('actioncontroller', { actioncontroller: { controller: 'UsersController', action: 'index' } }) + result = Instana::Exporter::Otlp::RailsConverter.new(span).convert + assert_equal 'UsersController#index', result[:name] + end + + def test_span_name_actionview + span = create_span('actionview', { actionview: { name: 'users/index.html.erb' } }) + result = Instana::Exporter::Otlp::RailsConverter.new(span).convert + assert_equal 'users/index.html.erb', result[:name] + end + + def test_span_name_render + span = create_span('render', { render: { type: 'partial', name: '_user.html.erb' } }) + result = Instana::Exporter::Otlp::RailsConverter.new(span).convert + assert_equal 'partial _user.html.erb', result[:name] + end + + def test_span_name_actionmailer + span = create_span('mail.actionmailer', { actionmailer: { class: 'UserMailer', method: 'welcome_email' } }) + result = Instana::Exporter::Otlp::RailsConverter.new(span).convert + assert_equal 'UserMailer#welcome_email', result[:name] + end + + def test_span_name_falls_back_to_span_n_for_unknown_type + span = create_span('unknown', {}) + result = Instana::Exporter::Otlp::RailsConverter.new(span).convert + assert_equal 'unknown', result[:name] + end + + private + + def create_span(name, data) + span = Instana::Span.new(name.to_sym) + span[:data] = data unless data.empty? + span.close + span + end +end diff --git a/test/exporter/otlp/resource_test.rb b/test/exporter/otlp/resource_test.rb new file mode 100644 index 00000000..75a12f55 --- /dev/null +++ b/test/exporter/otlp/resource_test.rb @@ -0,0 +1,309 @@ +# frozen_string_literal: true + +# (c) Copyright IBM Corp. 2026 + +require 'test_helper' +require 'instana/exporter/otlp/resource' + +class ResourceTest < Minitest::Test + R = Instana::Exporter::Otlp::Resource + SC = OpenTelemetry::SemanticConventions::Resource + + def setup + R.reset! + end + + def teardown + R.reset! + # Clean up any env vars set during tests + %w[ + OTEL_SERVICE_NAME INSTANA_SERVICE_NAME OTEL_SERVICE_VERSION INSTANA_SERVICE_VERSION + AWS_LAMBDA_FUNCTION_NAME AWS_LAMBDA_FUNCTION_VERSION AWS_LAMBDA_FUNCTION_ARN + KUBERNETES_SERVICE_HOST MY_POD_UID KUBERNETES_NAMESPACE + ECS_CONTAINER_METADATA_URI ECS_CONTAINER_METADATA_URI_V4 + K_SERVICE K_REVISION + ].each { |k| ENV.delete(k) } + end + + # ─── create / merge ──────────────────────────────────────────────────────── + + def test_create_freezes_keys_and_values + r = R.create('foo' => 'bar') + assert r.attributes.frozen? + assert r.attributes.keys.all?(&:frozen?) + assert r.attributes.values.all?(&:frozen?) + end + + def test_create_does_not_negate_key + r = R.create('service.name' => 'my-app') + assert_equal 'my-app', r.attributes['service.name'], + 'key must not be negated (old memo[-k] bug)' + end + + def test_merge_other_takes_precedence + base = R.create('k' => 'base') + other = R.create('k' => 'other') + merged = base.merge(other) + assert_equal 'other', merged.attributes['k'] + end + + def test_merge_with_non_resource_returns_self + r = R.create('k' => 'v') + assert_same r, r.merge('not a resource') + end + + # ─── telemetry SDK ───────────────────────────────────────────────────────── + + def test_telemetry_sdk_attributes + attrs = R.telemetry_sdk.attributes + assert_equal 'instana', attrs[SC::TELEMETRY_SDK_NAME] + assert_equal 'ruby', attrs[SC::TELEMETRY_SDK_LANGUAGE] + assert_equal Instana::VERSION, attrs[SC::TELEMETRY_SDK_VERSION] + end + + # ─── process ─────────────────────────────────────────────────────────────── + + def test_process_attributes + attrs = R.process.attributes + assert_equal Process.pid, attrs[SC::PROCESS_PID] + assert_equal RUBY_ENGINE, attrs[SC::PROCESS_RUNTIME_NAME] + assert_equal RUBY_VERSION, attrs[SC::PROCESS_RUNTIME_VERSION] + end + + # ─── os.type ─────────────────────────────────────────────────────────────── + + def test_os_type_is_present_and_non_empty + attrs = R.default.attributes + os = attrs[SC::OS_TYPE] + refute_nil os, 'os.type must be present (Required per v2 spec)' + refute_empty os + end + + def test_detect_os_type_linux + stub_rbconfig('linux-gnu') do + assert_equal 'linux', R.send(:detect_os_type) + end + end + + def test_detect_os_type_darwin + stub_rbconfig('arm-apple-darwin23') do + assert_equal 'darwin', R.send(:detect_os_type) + end + end + + def test_detect_os_type_windows + stub_rbconfig('x86_64-mingw32') do + assert_equal 'windows', R.send(:detect_os_type) + end + end + + def test_detect_os_type_unknown_falls_back_to_raw + stub_rbconfig('aix7.1') do + assert_equal 'aix7.1', R.send(:detect_os_type) + end + end + + # ─── host.id ─────────────────────────────────────────────────────────────── + + def test_host_id_reads_machine_id_file + FakeFS.with_fresh do + FileUtils.mkdir_p('/etc') + File.write('/etc/machine-id', "abc123\n") + R.reset! + assert_equal 'abc123', R.send(:host_id) + end + end + + def test_host_id_falls_back_to_dbus_machine_id + FakeFS.with_fresh do + FileUtils.mkdir_p('/var/lib/dbus') + File.write('/var/lib/dbus/machine-id', "fallback-id\n") + R.reset! + assert_equal 'fallback-id', R.send(:host_id) + end + end + + def test_host_id_returns_nil_when_no_file + FakeFS.with_fresh do + R.reset! + assert_nil R.send(:host_id) + end + end + + def test_host_id_present_in_default_resource_on_linux + FakeFS.with_fresh do + FileUtils.mkdir_p('/etc') + File.write('/etc/machine-id', "machine-xyz\n") + R.reset! + assert_equal 'machine-xyz', R.instance[SC::HOST_ID] + end + end + + # ─── service.instance.id priority ───────────────────────────────────────── + + def test_service_instance_id_uses_pod_uid_over_hostname_pid + FakeFS.with_fresh do + ENV['MY_POD_UID'] = 'pod-uid-abc' + R.reset! + assert_equal 'pod-uid-abc', R.instance[SC::SERVICE_INSTANCE_ID] + end + ensure + ENV.delete('MY_POD_UID') + end + + def test_service_instance_id_uses_host_id_over_hostname_pid + FakeFS.with_fresh do + FileUtils.mkdir_p('/etc') + File.write('/etc/machine-id', "stable-host-id\n") + R.reset! + assert_equal 'stable-host-id', R.instance[SC::SERVICE_INSTANCE_ID] + end + end + + def test_service_instance_id_falls_back_to_hostname_pid + FakeFS.with_fresh do + R.reset! + instance_id = R.instance[SC::SERVICE_INSTANCE_ID] + assert_match(/.+:\d+/, instance_id, 'fallback should be hostname:pid') + end + end + + # ─── k8s.pod.uid ─────────────────────────────────────────────────────────── + + def test_k8s_pod_uid_set_when_env_present + ENV['KUBERNETES_SERVICE_HOST'] = '10.0.0.1' + ENV['MY_POD_UID'] = 'pod-uid-xyz' + R.reset! + assert_equal 'pod-uid-xyz', R.instance[SC::K8S_POD_UID] + ensure + ENV.delete('KUBERNETES_SERVICE_HOST') + ENV.delete('MY_POD_UID') + end + + def test_k8s_pod_uid_absent_when_env_missing + ENV['KUBERNETES_SERVICE_HOST'] = '10.0.0.1' + ENV.delete('MY_POD_UID') + R.reset! + refute R.instance.key?(SC::K8S_POD_UID) + ensure + ENV.delete('KUBERNETES_SERVICE_HOST') + end + + # ─── Lambda ARN cloud attributes ─────────────────────────────────────────── + + def test_parse_lambda_arn_extracts_region_and_account + arn = 'arn:aws:lambda:us-east-1:123456789012:function:my-fn' + result = R.send(:parse_lambda_arn, arn) + assert_equal 'us-east-1', result[SC::CLOUD_REGION] + assert_equal '123456789012', result[SC::CLOUD_ACCOUNT_ID] + assert_equal arn, result[R::CLOUD_RESOURCE_ID] + end + + def test_lambda_cloud_attributes_in_default_resource + ENV['AWS_LAMBDA_FUNCTION_NAME'] = 'my-fn' + ENV['AWS_LAMBDA_FUNCTION_ARN'] = 'arn:aws:lambda:eu-west-1:999999999999:function:my-fn' + R.reset! + attrs = R.instance + assert_equal 'eu-west-1', attrs[SC::CLOUD_REGION] + assert_equal '999999999999', attrs[SC::CLOUD_ACCOUNT_ID] + assert_equal 'arn:aws:lambda:eu-west-1:999999999999:function:my-fn', + attrs[R::CLOUD_RESOURCE_ID] + ensure + ENV.delete('AWS_LAMBDA_FUNCTION_NAME') + ENV.delete('AWS_LAMBDA_FUNCTION_ARN') + end + + def test_parse_lambda_arn_returns_empty_hash_on_bad_input + assert_equal({}, R.send(:parse_lambda_arn, nil)) + assert_equal({}, R.send(:parse_lambda_arn, '')) + end + + def test_lambda_no_arn_env_skips_cloud_attributes + ENV['AWS_LAMBDA_FUNCTION_NAME'] = 'my-fn' + ENV.delete('AWS_LAMBDA_FUNCTION_ARN') + R.reset! + refute R.instance.key?(SC::CLOUD_REGION) + refute R.instance.key?(SC::CLOUD_ACCOUNT_ID) + refute R.instance.key?(R::CLOUD_RESOURCE_ID) + ensure + ENV.delete('AWS_LAMBDA_FUNCTION_NAME') + end + + # ─── container runtime detection ────────────────────────────────────────── + + def test_docker_detected_on_linux + FakeFS.with_fresh do + FileUtils.touch('/.dockerenv') + stub_rbconfig('linux-gnu') do + R.reset! + assert_equal 'docker', R.instance[SC::CONTAINER_RUNTIME] + end + end + end + + def test_podman_detected_on_linux_takes_priority_over_dockerenv + FakeFS.with_fresh do + FileUtils.mkdir_p('/run') + FileUtils.touch('/run/.containerenv') + FileUtils.touch('/.dockerenv') + stub_rbconfig('linux-gnu') do + R.reset! + assert_equal 'podman', R.instance[SC::CONTAINER_RUNTIME] + end + end + end + + def test_podman_detected_on_linux_without_dockerenv + FakeFS.with_fresh do + FileUtils.mkdir_p('/run') + FileUtils.touch('/run/.containerenv') + stub_rbconfig('linux-gnu') do + R.reset! + assert_equal 'podman', R.instance[SC::CONTAINER_RUNTIME] + end + end + end + + def test_no_container_runtime_detected_on_non_linux + FakeFS.with_fresh do + # Even if the Linux sentinel files somehow existed, non-Linux must be skipped + FileUtils.touch('/.dockerenv') + stub_rbconfig('arm-apple-darwin23') do + R.reset! + refute R.instance.key?(SC::CONTAINER_RUNTIME), + 'container.runtime must not be set on non-Linux' + end + end + end + + # ─── Lambda is NOT part of container_attributes ─────────────────────────── + + def test_lambda_attributes_not_in_container_attrs + ENV['AWS_LAMBDA_FUNCTION_NAME'] = 'my-fn' + R.reset! + container_attrs = R.send(:container_attributes).attributes + refute container_attrs.key?(SC::FAAS_NAME), + 'faas.name must not appear inside container_attributes' + ensure + ENV.delete('AWS_LAMBDA_FUNCTION_NAME') + end + + def test_lambda_attributes_in_faas_attrs + ENV['AWS_LAMBDA_FUNCTION_NAME'] = 'my-fn' + R.reset! + faas_attrs = R.send(:faas_attributes).attributes + assert_equal 'my-fn', faas_attrs[SC::FAAS_NAME] + ensure + ENV.delete('AWS_LAMBDA_FUNCTION_NAME') + end + + private + + def stub_rbconfig(host_os_value) + original = RbConfig::CONFIG['host_os'] + RbConfig::CONFIG['host_os'] = host_os_value + yield + ensure + RbConfig::CONFIG['host_os'] = original + end +end diff --git a/test/exporter/otlp/rpc_converter_test.rb b/test/exporter/otlp/rpc_converter_test.rb new file mode 100644 index 00000000..a4c7438b --- /dev/null +++ b/test/exporter/otlp/rpc_converter_test.rb @@ -0,0 +1,119 @@ +# (c) Copyright IBM Corp. 2026 + +require 'test_helper' +require 'instana/exporter/otlp/rpc_converter' + +class RpcConverterTest < Minitest::Test + def test_grpc_conversion + span = create_span(:grpc, { + rpc: { call: '/package.Service/Method', host: 'grpc.example.com', call_type: 'unary' } + }) + converter = Instana::Exporter::Otlp::RpcConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 'grpc', attrs['rpc.system'] + assert_equal 'package.Service', attrs['rpc.service'] + assert_equal 'Method', attrs['rpc.method'] + assert_equal 'grpc.example.com', attrs['server.address'] + assert_equal 'unary', attrs['rpc.grpc.call_type'] + end + + def test_grpc_with_peer_address + span = create_span(:grpc, { + rpc: { call: '/test.API/Get', peer: { address: '10.0.0.1' } } + }) + converter = Instana::Exporter::Otlp::RpcConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal '10.0.0.1', attrs['server.address'] + end + + def test_actioncable_conversion + span = create_span(:actioncable, { + rpc: { flavor: :actioncable, call: 'ChatChannel#speak', host: 'ws.example.com', call_type: 'action' }, + service: 'my-app' + }) + converter = Instana::Exporter::Otlp::RpcConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 'actioncable', attrs['rpc.system'] + assert_equal 'ChatChannel#speak', attrs['rails.actioncable.channel'] + assert_equal 'action', attrs['rails.actioncable.call_type'] + assert_equal 'my-app', attrs['rpc.service'] + assert_equal 'ChatChannel', attrs['code.namespace'] + assert_equal 'speak', attrs['code.function'] + assert_equal 'ws.example.com', attrs['server.address'] + end + + def test_actioncable_transmit + span = create_span(:actioncable, { + rpc: { flavor: :actioncable, call: 'NotificationChannel', call_type: 'transmit' } + }) + converter = Instana::Exporter::Otlp::RpcConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_equal 'NotificationChannel', attrs['code.namespace'] + assert_nil attrs['code.function'] + end + + def test_parse_grpc_call + span = create_span(:grpc, {}) + converter = Instana::Exporter::Otlp::RpcConverter.new(span) + + service, method = converter.send(:parse_grpc_call, '/pkg.Service/Method') + assert_equal 'pkg.Service', service + assert_equal 'Method', method + + service, method = converter.send(:parse_grpc_call, 'invalid') + assert_nil service + assert_nil method + end + + def test_missing_rpc_data + span = create_span(:grpc, {}) + converter = Instana::Exporter::Otlp::RpcConverter.new(span) + attrs = converter.send(:convert_attributes) + + assert_empty attrs + end + + # --- span_name tests --- + + def test_span_name_grpc_strips_leading_slash + span = create_span(:grpc, { rpc: { call: '/package.Service/Method', host: 'grpc.example.com' } }) + result = Instana::Exporter::Otlp::RpcConverter.new(span).convert + assert_equal 'package.Service/Method', result[:name] + end + + def test_span_name_grpc_without_leading_slash + span = create_span(:grpc, { rpc: { call: 'pkg.API/Get' } }) + result = Instana::Exporter::Otlp::RpcConverter.new(span).convert + assert_equal 'pkg.API/Get', result[:name] + end + + def test_span_name_actioncable_with_action + span = create_span(:actioncable, { rpc: { flavor: :actioncable, call: 'ChatChannel#speak' } }) + result = Instana::Exporter::Otlp::RpcConverter.new(span).convert + assert_equal 'ChatChannel#speak', result[:name] + end + + def test_span_name_falls_back_to_span_name_when_call_missing + # :grpc is unregistered so Instana stores the original name in sdk[:name]. + # We must not overwrite :data (which would lose sdk[:name]), so we + # build the span manually and only add the rpc sub-hash. + span = Instana::Span.new(:grpc) + span[:data][:rpc] = {} + span.close + result = Instana::Exporter::Otlp::RpcConverter.new(span).convert + assert_equal 'grpc', result[:name] + end + + private + + def create_span(name, data) + span = Instana::Span.new(name) + span[:data] = data + span.close + span + end +end diff --git a/test/instrumentation/graphql_test.rb b/test/instrumentation/graphql_test.rb index 3f649ba7..19098785 100644 --- a/test/instrumentation/graphql_test.rb +++ b/test/instrumentation/graphql_test.rb @@ -301,11 +301,9 @@ def test_no_error_is_raised_and_no_spans_are_created_when_agent_is_not_ready ::Instana.agent.stub(:ready?, false) do assert_silent do - Schema.execute(query) rescue StandardError => e error = e - end end diff --git a/test/instrumentation/resque_test.rb b/test/instrumentation/resque_test.rb index b6fce02c..a6edd925 100644 --- a/test/instrumentation/resque_test.rb +++ b/test/instrumentation/resque_test.rb @@ -4,6 +4,8 @@ require 'test_helper' require 'support/apps/resque/boot' +Warning[:deprecated] = false if Gem::Version.new(RUBY_VERSION) > Gem::Version.new('3.4') + ::Resque.redis = ENV['REDIS_URL'] class ResqueClientTest < Minitest::Test