From 82177835353248494e9e3c824e08ae412dda4a25 Mon Sep 17 00:00:00 2001 From: Arjun Rajappa Date: Thu, 11 Jun 2026 11:15:20 +0530 Subject: [PATCH 1/4] feat(otlp-exporter): add basic otlp exporter Signed-off-by: Arjun Rajappa --- .../backend/host_agent_reporting_observer.rb | 29 +- lib/instana/exporter/otlp/base_converter.rb | 8 +- .../exporter/otlp/converter_factory.rb | 10 +- lib/instana/exporter/otlp/http_converter.rb | 1 - .../host_agent_reporting_observer_test.rb | 261 ++++++++++++++++++ 5 files changed, 294 insertions(+), 15 deletions(-) diff --git a/lib/instana/backend/host_agent_reporting_observer.rb b/lib/instana/backend/host_agent_reporting_observer.rb index cbb914e4..9349884c 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,11 @@ def initialize(client, discovery, logger: ::Instana.logger, timer_class: Concurr @timer_class = timer_class @nonce = Time.now @processor = processor - + @otlp_exporter = OpenTelemetry::Exporter::OTLP::Exporter.new( + endpoint: 'http://localhost:4318/v1/traces', + timeout: 5.0, # in seconds + compression: 'gzip' + ) if ENV["INSTANA_OTLP_ENABLED"] # 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 } @@ -87,13 +93,26 @@ def report_traces discovery = @discovery.value return unless discovery - path = format(TRACES_DATA_URL, discovery['pid']) + 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 + Instana.logger.info(converted_spans) + 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 diff --git a/lib/instana/exporter/otlp/base_converter.rb b/lib/instana/exporter/otlp/base_converter.rb index 1ba50d0a..23576525 100644 --- a/lib/instana/exporter/otlp/base_converter.rb +++ b/lib/instana/exporter/otlp/base_converter.rb @@ -259,7 +259,7 @@ def normalize_attribute_value(value) # # @return [String] The span name def span_name - span[:n].to_s + span[:n]&.to_s || '' end # Format parent span ID, returning INVALID_SPAN_ID if no parent @@ -282,9 +282,9 @@ def calculate_end_timestamp # # @return [Symbol] Inferred span kind def infer_span_kind_from_name - span_name = span[:n]&.to_sym - return :server if ::Instana::SpanKind::ENTRY_SPANS.include?(span_name) - return :client if ::Instana::SpanKind::EXIT_SPANS.include?(span_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 diff --git a/lib/instana/exporter/otlp/converter_factory.rb b/lib/instana/exporter/otlp/converter_factory.rb index f788c7af..8af76306 100644 --- a/lib/instana/exporter/otlp/converter_factory.rb +++ b/lib/instana/exporter/otlp/converter_factory.rb @@ -70,31 +70,31 @@ def get_converter_class(span_type) # 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.name&.to_sym) + 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.name&.match?(/sql|database|query|activerecord|sequel|mongo|redis|dalli/i) + span[:n]&.match?(/sql|database|query|activerecord|sequel|mongo|redis|dalli/i) end # Check if span is a messaging span # Instana native spans always have a name, so we only check the name def messaging_span?(span) - span.name&.match?(/kafka|rabbitmq|sqs|sns|message|bunny|shoryuken/i) + span[:n]&.match?(/kafka|rabbitmq|sqs|sns|message|bunny|shoryuken/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.name&.match?(/grpc|rpc/i) + span[:n]&.match?(/grpc|rpc/i) end # Check if span is a custom span # Instana native spans always have a name, so we only check the name def custom_span?(span) - span.name&.match?(/custom|sdk/i) + span[:n]&.match?(/custom|sdk/i) end end end diff --git a/lib/instana/exporter/otlp/http_converter.rb b/lib/instana/exporter/otlp/http_converter.rb index 481adb1b..b9d5e8f5 100644 --- a/lib/instana/exporter/otlp/http_converter.rb +++ b/lib/instana/exporter/otlp/http_converter.rb @@ -11,7 +11,6 @@ module Otlp # Converter for HTTP spans to OTLP format # Handles conversion of HTTP-related spans with specific attributes class HttpConverter < BaseConverter - private # Extract HTTP-specific attributes as plain key/value pairs # @return [Hash] HTTP attributes diff --git a/test/backend/host_agent_reporting_observer_test.rb b/test/backend/host_agent_reporting_observer_test.rb index c04b2be0..d0e1ae19 100644 --- a/test/backend/host_agent_reporting_observer_test.rb +++ b/test/backend/host_agent_reporting_observer_test.rb @@ -318,4 +318,265 @@ 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 (INSTANA_OTLP_ENABLED environment variable) + # ============================================================================ + + def test_otlp_export_enabled_with_env_variable + ENV['INSTANA_OTLP_ENABLED'] = 'true' + + 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 + end.new + + 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 + + refute_nil exported_spans, "OTLP exporter should have received spans" + assert exported_spans.is_a?(Array), "Exported spans should be an array" + assert_equal 1, exported_spans.length, "Should export 1 converted span" + otlp_exporter.verify + refute_nil discovery.value, "Discovery should remain valid after successful export" + ensure + ENV.delete('INSTANA_OTLP_ENABLED') + end + + def test_otlp_export_disabled_without_env_variable + ENV.delete('INSTANA_OTLP_ENABLED') + + 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 + end.new + + # Should not create OTLP exporter + 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 initialized without env variable" + + subject.traces_timer.block.call + refute_nil discovery.value, "Discovery should remain valid" + end + + def test_otlp_export_converts_spans_correctly + ENV['INSTANA_OTLP_ENABLED'] = 'true' + + 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: 1234567890000, + 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 + attr_reader :test_span + + def initialize(span) + @test_span = span + end + + def send + yield([@test_span]) + end + end.new(test_span) + + 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 + + refute_nil exported_spans, "Should export converted spans" + assert exported_spans.is_a?(Array), "Exported spans should be an array" + assert_equal 1, exported_spans.length, "Should export 1 converted span" + + # The converter returns OpenTelemetry::SDK::Trace::SpanData + # Just verify we got a converted span object + refute_nil exported_spans.first, "Converted span should not be nil" + + otlp_exporter.verify + ensure + ENV.delete('INSTANA_OTLP_ENABLED') + end + + def test_otlp_export_failure_triggers_rediscovery + ENV['INSTANA_OTLP_ENABLED'] = 'true' + + 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 + # Return FAILURE status code + 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 + end.new + + 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 + + otlp_exporter.verify + assert_nil discovery.value, "Discovery should be reset after export failure" + ensure + ENV.delete('INSTANA_OTLP_ENABLED') + end + + def test_otlp_export_with_multiple_spans + ENV['INSTANA_OTLP_ENABLED'] = 'true' + + 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 + attr_reader :test_spans + + def initialize(spans) + @test_spans = spans + end + + def send + yield(@test_spans) + end + end.new(test_spans) + + 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 + + refute_nil exported_spans, "Should export converted spans" + assert_equal 3, exported_spans.length, "Should export all 3 converted spans" + otlp_exporter.verify + ensure + ENV.delete('INSTANA_OTLP_ENABLED') + end + + def test_otlp_exporter_initialization_with_env_variable + ENV['INSTANA_OTLP_ENABLED'] = 'true' + + client = Instana::Backend::RequestClient.new('10.10.10.10', 9292) + discovery = Concurrent::Atom.new(nil) + + subject = Instana::Backend::HostAgentReportingObserver.new(client, discovery, timer_class: MockTimer) + + refute_nil subject.instance_variable_get(:@otlp_exporter), "OTLP exporter should be initialized when env variable is set" + ensure + ENV.delete('INSTANA_OTLP_ENABLED') + end + + def test_otlp_export_handles_empty_span_batch + ENV['INSTANA_OTLP_ENABLED'] = 'true' + + 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 + # Should not be called for empty batch + + processor = Class.new do + def send + yield([]) + end + end.new + + 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 + + # Discovery should remain valid even with empty batch + refute_nil discovery.value, "Discovery should remain valid with empty span batch" + ensure + ENV.delete('INSTANA_OTLP_ENABLED') + end end From 0e50632be011c1aa805219c28183897a6a029517 Mon Sep 17 00:00:00 2001 From: Arjun Rajappa Date: Thu, 11 Jun 2026 11:23:34 +0530 Subject: [PATCH 2/4] feat(otlp-exporter): add otlp exporter to gemspec Signed-off-by: Arjun Rajappa --- instana.gemspec | 2 ++ 1 file changed, 2 insertions(+) diff --git a/instana.gemspec b/instana.gemspec index eaebcd2d..a7f1076e 100644 --- a/instana.gemspec +++ b/instana.gemspec @@ -47,8 +47,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 From aa7e6342af8d767e4909e3bdc471e1bb3e3df4c0 Mon Sep 17 00:00:00 2001 From: Arjun Rajappa Date: Thu, 11 Jun 2026 14:13:15 +0530 Subject: [PATCH 3/4] feat(otlp-exporter): fix rubocop errors Signed-off-by: Arjun Rajappa --- instana.gemspec | 2 +- .../backend/host_agent_reporting_observer.rb | 14 ++++++++------ lib/instana/exporter/otlp/base_converter.rb | 2 +- lib/instana/exporter/otlp/http_converter.rb | 1 - test/backend/host_agent_reporting_observer_test.rb | 6 +++--- 5 files changed, 13 insertions(+), 12 deletions(-) diff --git a/instana.gemspec b/instana.gemspec index a7f1076e..0cd55361 100644 --- a/instana.gemspec +++ b/instana.gemspec @@ -47,7 +47,7 @@ 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 + # 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') diff --git a/lib/instana/backend/host_agent_reporting_observer.rb b/lib/instana/backend/host_agent_reporting_observer.rb index 9349884c..d3dfe688 100644 --- a/lib/instana/backend/host_agent_reporting_observer.rb +++ b/lib/instana/backend/host_agent_reporting_observer.rb @@ -24,11 +24,13 @@ def initialize(client, discovery, logger: ::Instana.logger, timer_class: Concurr @timer_class = timer_class @nonce = Time.now @processor = processor - @otlp_exporter = OpenTelemetry::Exporter::OTLP::Exporter.new( - endpoint: 'http://localhost:4318/v1/traces', - timeout: 5.0, # in seconds - compression: 'gzip' - ) if ENV["INSTANA_OTLP_ENABLED"] + if ENV["INSTANA_OTLP_ENABLED"] + @otlp_exporter = OpenTelemetry::Exporter::OTLP::Exporter.new( + endpoint: 'http://localhost:4318/v1/traces', + timeout: 5.0, # in seconds + compression: 'gzip' + ) + end # 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 } @@ -93,7 +95,7 @@ def report_traces discovery = @discovery.value return unless discovery - path=format(TRACES_DATA_URL, discovery['pid']) + path = format(TRACES_DATA_URL, discovery['pid']) @processor.send do |spans| success = false diff --git a/lib/instana/exporter/otlp/base_converter.rb b/lib/instana/exporter/otlp/base_converter.rb index 23576525..075a836c 100644 --- a/lib/instana/exporter/otlp/base_converter.rb +++ b/lib/instana/exporter/otlp/base_converter.rb @@ -259,7 +259,7 @@ def normalize_attribute_value(value) # # @return [String] The span name def span_name - span[:n]&.to_s || '' + span[:n].to_s end # Format parent span ID, returning INVALID_SPAN_ID if no parent diff --git a/lib/instana/exporter/otlp/http_converter.rb b/lib/instana/exporter/otlp/http_converter.rb index b9d5e8f5..06fc1df7 100644 --- a/lib/instana/exporter/otlp/http_converter.rb +++ b/lib/instana/exporter/otlp/http_converter.rb @@ -11,7 +11,6 @@ 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 diff --git a/test/backend/host_agent_reporting_observer_test.rb b/test/backend/host_agent_reporting_observer_test.rb index d0e1ae19..a05492fb 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) @@ -400,7 +400,7 @@ def test_otlp_export_converts_spans_correctly n: 'rack', t: '1234567890abcdef', s: 'fedcba0987654321', - ts: 1234567890000, + ts: Time.now.to_i * 1000, d: 100, k: 1, data: { @@ -468,7 +468,7 @@ def test_otlp_export_failure_triggers_rediscovery otlp_exporter = Minitest::Mock.new # Return FAILURE status code - otlp_exporter.expect(:export, OpenTelemetry::SDK::Trace::Export::FAILURE) do |spans| + otlp_exporter.expect(:export, OpenTelemetry::SDK::Trace::Export::FAILURE) do |_spans| OpenTelemetry::SDK::Trace::Export::FAILURE end From e4f7b211e45a84ff2004d57d9c4d997844d107da Mon Sep 17 00:00:00 2001 From: Arjun Rajappa Date: Fri, 12 Jun 2026 19:07:20 +0530 Subject: [PATCH 4/4] feat(otlp-exporter): return span name as string Signed-off-by: Arjun Rajappa --- test/exporter/otlp/converter_factory_test.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/test/exporter/otlp/converter_factory_test.rb b/test/exporter/otlp/converter_factory_test.rb index cd3c96f5..c48a0c16 100644 --- a/test/exporter/otlp/converter_factory_test.rb +++ b/test/exporter/otlp/converter_factory_test.rb @@ -279,6 +279,7 @@ def test_converter_has_reference_to_span def create_test_span(name: :test, kind: 3) span = Instana::Span.new(name) + span[:n] = name&.to_s span[:k] = kind span.close span