diff --git a/instana.gemspec b/instana.gemspec index eaebcd2d..0cd55361 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 diff --git a/lib/instana/backend/host_agent_reporting_observer.rb b/lib/instana/backend/host_agent_reporting_observer.rb index cbb914e4..d3dfe688 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,13 @@ def initialize(client, discovery, logger: ::Instana.logger, timer_class: Concurr @timer_class = timer_class @nonce = Time.now @processor = processor - + 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 } @@ -90,10 +98,23 @@ 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 + 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..075a836c 100644 --- a/lib/instana/exporter/otlp/base_converter.rb +++ b/lib/instana/exporter/otlp/base_converter.rb @@ -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..06fc1df7 100644 --- a/lib/instana/exporter/otlp/http_converter.rb +++ b/lib/instana/exporter/otlp/http_converter.rb @@ -11,8 +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 def convert_attributes diff --git a/test/backend/host_agent_reporting_observer_test.rb b/test/backend/host_agent_reporting_observer_test.rb index c04b2be0..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) @@ -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: 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 + 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 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