From 9ebccc8cfce7d1ad7594c0f755ecdbae4b90a36c Mon Sep 17 00:00:00 2001 From: xuan-cao-swi Date: Tue, 17 Mar 2026 09:41:30 -0400 Subject: [PATCH 01/10] update simplecov config --- test/initest_helper.rb | 1 + test/minitest_helper.rb | 1 + test/run_tests.sh | 5 ++++- test/sampling_test_helper.rb | 1 + 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/test/initest_helper.rb b/test/initest_helper.rb index bdc26fad..4d95efbd 100644 --- a/test/initest_helper.rb +++ b/test/initest_helper.rb @@ -10,6 +10,7 @@ require './lib/solarwinds_apm/logger' require 'simplecov' SimpleCov.start +SimpleCov.command_name ENV.fetch('SIMPLECOV_COMMAND_NAME', 'minitest') ENV['SW_APM_SERVICE_KEY'] = 'this-is-a-dummy-api-token-for-testing-111111111111111111111111111111111:test-service' diff --git a/test/minitest_helper.rb b/test/minitest_helper.rb index 9d4b46da..58edc638 100644 --- a/test/minitest_helper.rb +++ b/test/minitest_helper.rb @@ -27,6 +27,7 @@ require 'simplecov' SimpleCov.start +SimpleCov.command_name ENV.fetch('SIMPLECOV_COMMAND_NAME', 'minitest') # needed by most tests ENV['SW_APM_SERVICE_KEY'] = 'this-is-a-dummy-api-token-for-testing-111111111111111111111111111111111:test-service' diff --git a/test/run_tests.sh b/test/run_tests.sh index a2de28b9..9b8fde7a 100755 --- a/test/run_tests.sh +++ b/test/run_tests.sh @@ -66,7 +66,7 @@ run_test_file() { local output local status - if output=$(bundle exec ruby -I test "$test_file"); then + if output=$(SIMPLECOV_COMMAND_NAME="$test_file" bundle exec ruby -I test "$test_file"); then status=0 else status=$? @@ -82,6 +82,9 @@ export TEST_RUNS_FILE_NAME="./log/testrun_$time.log" # Remove previous log files rm -f ./log/*.log +# Clear SimpleCov resultset so coverage merges fresh across all test files +rm -f coverage/.resultset.json + log_message "=== SolarWinds APM Ruby Test Runner ===" log_message "Test pattern: $test_pattern" log_message "Ruby version: $(ruby -e 'print(RUBY_VERSION)'); $(bundle -v)" diff --git a/test/sampling_test_helper.rb b/test/sampling_test_helper.rb index 677c4853..ea22f315 100644 --- a/test/sampling_test_helper.rb +++ b/test/sampling_test_helper.rb @@ -9,6 +9,7 @@ require './lib/solarwinds_apm/sampling' require 'simplecov' SimpleCov.start +SimpleCov.command_name ENV.fetch('SIMPLECOV_COMMAND_NAME', 'minitest') ENV['OTEL_METRICS_EXPORTER'] = 'none' From 987cd439830bb2ba8d9b32f0bf91d2533f8d4c89 Mon Sep 17 00:00:00 2001 From: xuan-cao-swi Date: Wed, 18 Mar 2026 10:54:09 -0400 Subject: [PATCH 02/10] update for coverage --- test/api/api_test.rb | 99 +++ test/api/current_trace_info_test.rb | 143 ++++ test/api/custom_instrumentation_test.rb | 6 +- test/api/opentelemetry_inspan_test.rb | 2 +- test/api/set_transaction_name_test.rb | 14 +- test/api/tracing_ready_test.rb | 6 +- test/api/transaction_name_test.rb | 135 ++++ test/minitest_helper.rb | 8 +- .../otlp_processor_sampled_test.rb | 4 +- test/opentelemetry/otlp_processor_test.rb | 467 ++++++++++++- .../otlp_processor_unsampled_test.rb | 2 +- .../solarwinds_propagator_test.rb | 141 +++- .../solarwinds_response_propagator_test.rb | 90 +++ test/patch/sw_dbo_utils_test.rb | 48 ++ test/patch/sw_mysql2_patch_integrate_test.rb | 2 +- test/patch/sw_mysql2_patch_test.rb | 2 +- test/patch/sw_pg_patch_integrate_test.rb | 2 +- test/patch/sw_pg_patch_test.rb | 2 +- test/sampling/dice_test.rb | 74 +++ test/sampling/http_sampler_test.rb | 4 +- test/sampling/json_sampler_test.rb | 85 +++ test/sampling/metrics_test.rb | 26 + test/sampling/oboe_sampler_test.rb | 621 ++++++++++++++++++ test/sampling/sampler_test.rb | 301 +++++++++ test/sampling/sampling_patch_test.rb | 49 ++ test/sampling/settings_test.rb | 170 +++++ test/sampling/trace_options_test.rb | 231 ++++++- test/solarwinds_apm/config_test.rb | 365 +++++++++- test/solarwinds_apm/init_test/init_1_test.rb | 2 +- test/solarwinds_apm/init_test/init_2_test.rb | 2 +- test/solarwinds_apm/init_test/init_3_test.rb | 2 +- test/solarwinds_apm/init_test/init_4_test.rb | 2 +- test/solarwinds_apm/noop_test.rb | 88 +++ .../otel_config_propagator_test.rb | 6 +- test/solarwinds_apm/otel_config_test.rb | 132 ++++ test/support/log_formatters_test.rb | 55 ++ test/support/logger_formatter_test.rb | 80 +++ test/support/otlp_endpoint_test.rb | 147 +++++ test/support/resource_detector_test.rb | 243 ++++++- test/support/service_key_checker_test.rb | 113 +++- test/support/trace_context_log_test.rb | 74 +-- test/support/transaction_settings_test.rb | 76 +++ test/support/txn_name_manager_test.rb | 82 ++- test/support/utils_test.rb | 93 ++- 44 files changed, 4100 insertions(+), 196 deletions(-) create mode 100644 test/api/api_test.rb create mode 100644 test/api/current_trace_info_test.rb create mode 100644 test/api/transaction_name_test.rb create mode 100644 test/opentelemetry/solarwinds_response_propagator_test.rb create mode 100644 test/patch/sw_dbo_utils_test.rb create mode 100644 test/sampling/metrics_test.rb create mode 100644 test/sampling/sampling_patch_test.rb create mode 100644 test/solarwinds_apm/noop_test.rb create mode 100644 test/solarwinds_apm/otel_config_test.rb create mode 100644 test/support/log_formatters_test.rb create mode 100644 test/support/logger_formatter_test.rb diff --git a/test/api/api_test.rb b/test/api/api_test.rb new file mode 100644 index 00000000..19b6288a --- /dev/null +++ b/test/api/api_test.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +# Copyright (c) 2025 SolarWinds, LLC. +# All rights reserved. + +require 'minitest_helper' +require_relative '../../lib/solarwinds_apm/config' +require_relative '../../lib/solarwinds_apm/otel_config' +require './lib/solarwinds_apm/api' + +describe 'API::OpenTelemetry#in_span delegation to OpenTelemetry tracer' do + it 'returns nil and warns when block is nil' do + result = SolarWindsAPM::API.in_span('test_span') + assert_nil result + end + + it 'calls OpenTelemetry tracer in_span with a block' do + OpenTelemetry::SDK.configure + result = SolarWindsAPM::API.in_span('test_span') do |span| + refute_nil span + 42 + end + assert_equal 42, result + end + + it 'passes attributes, kind and other options to in_span' do + OpenTelemetry::SDK.configure + result = SolarWindsAPM::API.in_span('test_span', attributes: { 'key' => 'value' }, kind: :internal) do |span| + refute_nil span + 'done' + end + assert_equal 'done', result + end +end + +describe 'API::CustomMetrics deprecated methods return false' do + it 'increment_metric returns false with deprecation' do + result = SolarWindsAPM::API.increment_metric('test_metric', 1, false, {}) + assert_equal false, result + end + + it 'summary_metric returns false with deprecation' do + result = SolarWindsAPM::API.summary_metric('test_metric', 5.0, 1, false, {}) + assert_equal false, result + end +end + +describe 'API::Tracer#add_tracer method wrapping with span instrumentation' do + it 'add_tracer wraps an instance method with in_span' do + klass = Class.new do + include SolarWindsAPM::API::Tracer + + def greeting + 'hello' + end + add_tracer :greeting, 'greeting_span' + end + + OpenTelemetry::SDK.configure + + instance = klass.new + result = instance.greeting + assert_equal 'hello', result + end + + it 'add_tracer uses default span name when nil' do + klass = Class.new do + include SolarWindsAPM::API::Tracer + + def work + 'done' + end + add_tracer :work + end + + OpenTelemetry::SDK.configure + + instance = klass.new + result = instance.work + assert_equal 'done', result + end + + it 'add_tracer passes options to in_span' do + klass = Class.new do + include SolarWindsAPM::API::Tracer + + def compute + 100 + end + add_tracer :compute, 'compute_span', { attributes: { 'foo' => 'bar' }, kind: :consumer } + end + + OpenTelemetry::SDK.configure + + instance = klass.new + result = instance.compute + assert_equal 100, result + end +end diff --git a/test/api/current_trace_info_test.rb b/test/api/current_trace_info_test.rb new file mode 100644 index 00000000..15c49a94 --- /dev/null +++ b/test/api/current_trace_info_test.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +# Copyright (c) 2025 SolarWinds, LLC. +# All rights reserved. + +require 'minitest_helper' +require_relative '../../lib/solarwinds_apm/config' +require_relative '../../lib/solarwinds_apm/otel_config' +require './lib/solarwinds_apm/api' + +describe 'API::CurrentTraceInfo#for_log and #hash_for_log with log_traceId configuration' do + describe 'TraceInfo' do + it 'returns empty string for_log when log_traceId is :never' do + original = SolarWindsAPM::Config[:log_traceId] + SolarWindsAPM::Config[:log_traceId] = :never + + trace = SolarWindsAPM::API.current_trace_info + assert_equal '', trace.for_log + ensure + SolarWindsAPM::Config[:log_traceId] = original + end + + it 'returns empty hash for hash_for_log when log_traceId is :never' do + original = SolarWindsAPM::Config[:log_traceId] + SolarWindsAPM::Config[:log_traceId] = :never + + trace = SolarWindsAPM::API.current_trace_info + assert_equal({}, trace.hash_for_log) + ensure + SolarWindsAPM::Config[:log_traceId] = original + end + + it 'returns trace info for_log when log_traceId is :always' do + original = SolarWindsAPM::Config[:log_traceId] + SolarWindsAPM::Config[:log_traceId] = :always + + trace = SolarWindsAPM::API.current_trace_info + result = trace.for_log + assert_includes result, 'trace_id=' + assert_includes result, 'span_id=' + assert_includes result, 'trace_flags=' + ensure + SolarWindsAPM::Config[:log_traceId] = original + end + + it 'returns hash for hash_for_log when log_traceId is :always' do + original = SolarWindsAPM::Config[:log_traceId] + SolarWindsAPM::Config[:log_traceId] = :always + + trace = SolarWindsAPM::API.current_trace_info + result = trace.hash_for_log + assert result.key?('trace_id') + assert result.key?('span_id') + assert result.key?('trace_flags') + assert result.key?('resource.service.name') + ensure + SolarWindsAPM::Config[:log_traceId] = original + end + + it 'returns empty for_log when :traced and no active trace' do + original = SolarWindsAPM::Config[:log_traceId] + SolarWindsAPM::Config[:log_traceId] = :traced + + trace = SolarWindsAPM::API.current_trace_info + # Without an active trace, trace_id is all zeros, so valid? returns false + assert_equal '', trace.for_log + ensure + SolarWindsAPM::Config[:log_traceId] = original + end + + it 'returns empty for_log when :sampled and not sampled' do + original = SolarWindsAPM::Config[:log_traceId] + SolarWindsAPM::Config[:log_traceId] = :sampled + + trace = SolarWindsAPM::API.current_trace_info + # Without an active sampled trace, should return empty + assert_equal '', trace.for_log + ensure + SolarWindsAPM::Config[:log_traceId] = original + end + + it 'returns trace info within an active span for :traced' do + original = SolarWindsAPM::Config[:log_traceId] + SolarWindsAPM::Config[:log_traceId] = :traced + + OpenTelemetry::SDK.configure + tracer = OpenTelemetry.tracer_provider.tracer('test') + tracer.in_span('test_span') do + trace = SolarWindsAPM::API.current_trace_info + result = trace.for_log + assert_includes result, 'trace_id=' + end + ensure + SolarWindsAPM::Config[:log_traceId] = original + end + + it 'returns trace info within a sampled span for :sampled' do + original = SolarWindsAPM::Config[:log_traceId] + SolarWindsAPM::Config[:log_traceId] = :sampled + + OpenTelemetry::SDK.configure + tracer = OpenTelemetry.tracer_provider.tracer('test') + tracer.in_span('test_span') do + trace = SolarWindsAPM::API.current_trace_info + result = trace.for_log + # The default sampler records & samples, so this should have trace info + assert_includes result, 'trace_id=' + end + ensure + SolarWindsAPM::Config[:log_traceId] = original + end + + it 'has boolean do_log attribute' do + original = SolarWindsAPM::Config[:log_traceId] + SolarWindsAPM::Config[:log_traceId] = :always + + trace = SolarWindsAPM::API.current_trace_info + assert trace.do_log + ensure + SolarWindsAPM::Config[:log_traceId] = original + end + + it 'has trace_id, span_id, trace_flags, tracestring attributes' do + trace = SolarWindsAPM::API.current_trace_info + refute_nil trace.trace_id + refute_nil trace.span_id + refute_nil trace.trace_flags + refute_nil trace.tracestring + end + + it 'for_log is memoized' do + original = SolarWindsAPM::Config[:log_traceId] + SolarWindsAPM::Config[:log_traceId] = :always + + trace = SolarWindsAPM::API.current_trace_info + result1 = trace.for_log + result2 = trace.for_log + assert_equal result1, result2 + ensure + SolarWindsAPM::Config[:log_traceId] = original + end + end +end diff --git a/test/api/custom_instrumentation_test.rb b/test/api/custom_instrumentation_test.rb index 5b266afe..c3a93e7c 100644 --- a/test/api/custom_instrumentation_test.rb +++ b/test/api/custom_instrumentation_test.rb @@ -27,7 +27,7 @@ ENV.delete('OTEL_SERVICE_NAME') end - it 'test_custom_instrumentation_simple_case' do + it 'creates a span with default name and internal kind when add_tracer wraps a method' do class MyClass include SolarWindsAPM::API::Tracer @@ -52,7 +52,7 @@ def new_method(param1, param2) _(finished_spans[0].kind).must_equal(:internal) end - it 'test_custom_instrumentation_simple_case_with_custom_name_and_options' do + it 'creates a span with custom name, attributes, and kind when options are provided' do class MyClass include SolarWindsAPM::API::Tracer @@ -77,7 +77,7 @@ def new_method(param1, param2) _(finished_spans[0].kind).must_equal(:consumer) end - it 'test_custom_instrumentation_instance_method' do + it 'wraps a class method with add_tracer and applies custom span options' do class MyClass def self.new_method(param1, param2) param1 + param2 diff --git a/test/api/opentelemetry_inspan_test.rb b/test/api/opentelemetry_inspan_test.rb index 43afac34..8baa83bd 100644 --- a/test/api/opentelemetry_inspan_test.rb +++ b/test/api/opentelemetry_inspan_test.rb @@ -39,7 +39,7 @@ end describe 'test_in_span_wrapper_from_solarwinds_apm' do - it 'test_in_span' do + it 'creates child spans with correct names and attributes via in_span wrapper' do skip if finished_spans.empty? _(finished_spans.size).must_equal(4) diff --git a/test/api/set_transaction_name_test.rb b/test/api/set_transaction_name_test.rb index f45e7648..8fe6da6d 100644 --- a/test/api/set_transaction_name_test.rb +++ b/test/api/set_transaction_name_test.rb @@ -23,7 +23,7 @@ ENV.delete('SW_APM_ENABLED') end - it 'set_transaction_name_when_not_sampled' do + it 'stores transaction name in txn_manager when span is not sampled' do @solarwinds_processor.on_start(@span, OpenTelemetry::Context.current) OpenTelemetry::Trace.stub(:current_span, @dummy_span) do result = SolarWindsAPM::API.set_transaction_name('abcdf') @@ -32,7 +32,7 @@ _(@solarwinds_processor.txn_manager.get('77cb6ccc522d3106114dd6ecbb70036a-31e175128efc4018')).must_equal 'abcdf' end - it 'set_multiple_transaction_names_when_sampled' do + it 'overwrites earlier transaction name with the most recent one when sampled' do @span.context.trace_flags.instance_variable_set(:@flags, 1) @solarwinds_processor.on_start(@span, OpenTelemetry::Context.current) OpenTelemetry::Trace.stub(:current_span, @dummy_span) do @@ -42,7 +42,7 @@ _(@solarwinds_processor.txn_manager.get('77cb6ccc522d3106114dd6ecbb70036a-31e175128efc4018')).must_equal 'newer-name' end - it 'set_empty_transaction_name' do + it 'returns false and does not store when transaction name is empty' do @solarwinds_processor.on_start(@span, OpenTelemetry::Context.current) OpenTelemetry::Trace.stub(:current_span, @dummy_span) do result = SolarWindsAPM::API.set_transaction_name('') @@ -51,7 +51,7 @@ assert_nil(@solarwinds_processor.txn_manager.get('77cb6ccc522d3106114dd6ecbb70036a-31e175128efc4018')) end - it 'set_transaction_name_when_current_span_invalid' do + it 'returns false when current span is invalid' do @solarwinds_processor.on_start(@span, OpenTelemetry::Context.current) OpenTelemetry::Trace.stub(:current_span, OpenTelemetry::Trace::Span::INVALID) do result = SolarWindsAPM::API.set_transaction_name('abcdf') @@ -60,7 +60,7 @@ assert_nil(@solarwinds_processor.txn_manager.get('77cb6ccc522d3106114dd6ecbb70036a-31e175128efc4018')) end - it 'set_transaction_name_when_library_noop' do + it 'returns true and stores name when library is in noop mode' do @solarwinds_processor.on_start(@span, OpenTelemetry::Context.current) OpenTelemetry::Trace.stub(:current_span, @dummy_span) do result = SolarWindsAPM::API.set_transaction_name('abcdf') @@ -69,13 +69,13 @@ _(@solarwinds_processor.txn_manager.get('77cb6ccc522d3106114dd6ecbb70036a-31e175128efc4018')).must_equal 'abcdf' end - it 'set_transaction_name_when_library_disabled' do + it 'returns true without error when library is disabled' do ENV['SW_APM_ENABLED'] = 'false' result = SolarWindsAPM::API.set_transaction_name('abcdf') _(result).must_equal true end - it 'set_transaction_name_truncated_to_256_chars' do + it 'truncates transaction name to 256 characters when name exceeds limit' do @solarwinds_processor.on_start(@span, OpenTelemetry::Context.current) OpenTelemetry::Trace.stub(:current_span, @dummy_span) do long_name = 'a' * 500 diff --git a/test/api/tracing_ready_test.rb b/test/api/tracing_ready_test.rb index 39bae4ed..6d10c230 100644 --- a/test/api/tracing_ready_test.rb +++ b/test/api/tracing_ready_test.rb @@ -40,21 +40,21 @@ @memory_exporter.reset end - it 'default_test_solarwinds_ready' do + it 'returns true with valid default configuration' do new_config = @config.dup sampler = SolarWindsAPM::HttpSampler.new(new_config) replace_sampler(sampler) _(SolarWindsAPM::API.solarwinds_ready?).must_equal true end - it 'solarwinds_ready_with_5000_wait_time' do + it 'returns true when given a 5000ms wait time' do new_config = @config.dup sampler = SolarWindsAPM::HttpSampler.new(new_config) replace_sampler(sampler) _(SolarWindsAPM::API.solarwinds_ready?(5000)).must_equal true end - it 'solarwinds_ready_with_invalid_collector' do + it 'returns false when collector endpoint is invalid' do new_config = @config.merge(collector: URI('https://collector.invalid')) sampler = SolarWindsAPM::HttpSampler.new(new_config) replace_sampler(sampler) diff --git a/test/api/transaction_name_test.rb b/test/api/transaction_name_test.rb new file mode 100644 index 00000000..a288c075 --- /dev/null +++ b/test/api/transaction_name_test.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +# Copyright (c) 2025 SolarWinds, LLC. +# All rights reserved. + +require 'minitest_helper' +require_relative '../../lib/solarwinds_apm/config' +require_relative '../../lib/solarwinds_apm/support/txn_name_manager' +require_relative '../../lib/solarwinds_apm/otel_config' +require './lib/solarwinds_apm/api' + +describe 'API::TransactionName#set_transaction_name input validation and early returns' do + before do + @original_enabled = ENV['SW_APM_ENABLED'] + end + + after do + if @original_enabled + ENV['SW_APM_ENABLED'] = @original_enabled + else + ENV.delete('SW_APM_ENABLED') + end + end + + it 'returns true and logs debug when SW_APM_ENABLED is false' do + ENV['SW_APM_ENABLED'] = 'false' + result = SolarWindsAPM::API.set_transaction_name('test_txn') + assert_equal true, result + end + + it 'returns true when metrics_processor is nil' do + ENV['SW_APM_ENABLED'] = 'true' + + original_proc = SolarWindsAPM::OTelConfig[:metrics_processor] + SolarWindsAPM::OTelConfig.class_variable_get(:@@config)[:metrics_processor] = nil + + result = SolarWindsAPM::API.set_transaction_name('test_txn') + assert_equal true, result + ensure + SolarWindsAPM::OTelConfig.class_variable_get(:@@config)[:metrics_processor] = original_proc + end + + it 'returns false for nil custom_name' do + ENV['SW_APM_ENABLED'] = 'true' + + # Make metrics_processor non-nil for this branch + stub_processor = Object.new + original_proc = SolarWindsAPM::OTelConfig[:metrics_processor] + SolarWindsAPM::OTelConfig.class_variable_get(:@@config)[:metrics_processor] = stub_processor + + result = SolarWindsAPM::API.set_transaction_name(nil) + assert_equal false, result + ensure + SolarWindsAPM::OTelConfig.class_variable_get(:@@config)[:metrics_processor] = original_proc + end + + it 'returns false for empty custom_name' do + ENV['SW_APM_ENABLED'] = 'true' + + stub_processor = Object.new + original_proc = SolarWindsAPM::OTelConfig[:metrics_processor] + SolarWindsAPM::OTelConfig.class_variable_get(:@@config)[:metrics_processor] = stub_processor + + result = SolarWindsAPM::API.set_transaction_name('') + assert_equal false, result + ensure + SolarWindsAPM::OTelConfig.class_variable_get(:@@config)[:metrics_processor] = original_proc + end + + it 'returns false for invalid span context with valid processor and name' do + ENV['SW_APM_ENABLED'] = 'true' + + stub_processor = Object.new + original_proc = SolarWindsAPM::OTelConfig[:metrics_processor] + SolarWindsAPM::OTelConfig.class_variable_get(:@@config)[:metrics_processor] = stub_processor + + # Without an active span, the span context is invalid + result = SolarWindsAPM::API.set_transaction_name('valid_name') + assert_equal false, result + ensure + SolarWindsAPM::OTelConfig.class_variable_get(:@@config)[:metrics_processor] = original_proc + end + + it 'sets transaction name within a valid span context' do + ENV['SW_APM_ENABLED'] = 'true' + + mock_txn_manager = Object.new + def mock_txn_manager.get_root_context_h(_trace_id) + 'abcdef1234567890-01' + end + + def mock_txn_manager.set(_key, _value); end + + mock_processor = Object.new + mock_processor.define_singleton_method(:txn_manager) { mock_txn_manager } + + original_proc = SolarWindsAPM::OTelConfig[:metrics_processor] + SolarWindsAPM::OTelConfig.class_variable_get(:@@config)[:metrics_processor] = mock_processor + + OpenTelemetry::SDK.configure + tracer = OpenTelemetry.tracer_provider.tracer('test') + result = nil + tracer.in_span('test_span') do + result = SolarWindsAPM::API.set_transaction_name('custom_txn') + end + assert_equal true, result + ensure + SolarWindsAPM::OTelConfig.class_variable_get(:@@config)[:metrics_processor] = original_proc + end + + it 'returns false when root context record not found in txn_manager' do + ENV['SW_APM_ENABLED'] = 'true' + + mock_txn_manager = Object.new + def mock_txn_manager.get_root_context_h(_trace_id) + nil + end + + mock_processor = Object.new + mock_processor.define_singleton_method(:txn_manager) { mock_txn_manager } + + original_proc = SolarWindsAPM::OTelConfig[:metrics_processor] + SolarWindsAPM::OTelConfig.class_variable_get(:@@config)[:metrics_processor] = mock_processor + + OpenTelemetry::SDK.configure + tracer = OpenTelemetry.tracer_provider.tracer('test') + result = nil + tracer.in_span('test_span') do + result = SolarWindsAPM::API.set_transaction_name('custom_txn') + end + assert_equal false, result + ensure + SolarWindsAPM::OTelConfig.class_variable_get(:@@config)[:metrics_processor] = original_proc + end +end diff --git a/test/minitest_helper.rb b/test/minitest_helper.rb index 58edc638..f44a87d6 100644 --- a/test/minitest_helper.rb +++ b/test/minitest_helper.rb @@ -3,6 +3,10 @@ # Copyright (c) 2016 SolarWinds, LLC. # All rights reserved. +require 'simplecov' +SimpleCov.start +SimpleCov.command_name ENV.fetch('SIMPLECOV_COMMAND_NAME', 'minitest') + require 'minitest/autorun' require 'minitest/spec' require 'minitest/reporters' @@ -25,10 +29,6 @@ require './lib/solarwinds_apm/constants' require 'opentelemetry-exporter-otlp-metrics' -require 'simplecov' -SimpleCov.start -SimpleCov.command_name ENV.fetch('SIMPLECOV_COMMAND_NAME', 'minitest') - # needed by most tests ENV['SW_APM_SERVICE_KEY'] = 'this-is-a-dummy-api-token-for-testing-111111111111111111111111111111111:test-service' ENV['RACK_ENV'] = 'test' diff --git a/test/opentelemetry/otlp_processor_sampled_test.rb b/test/opentelemetry/otlp_processor_sampled_test.rb index 5d962cf2..3fc364ac 100644 --- a/test/opentelemetry/otlp_processor_sampled_test.rb +++ b/test/opentelemetry/otlp_processor_sampled_test.rb @@ -19,11 +19,11 @@ @processor = SolarWindsAPM::OpenTelemetry::OTLPProcessor.new(txn_manager) end - it 'initializes_metrics' do + it 'initializes with exactly one metric instrument' do _(@processor.instance_variable_get(:@metrics).size).must_equal 1 end - it 'ensure_both_span_and_metrics_have_transaction_name' do + it 'sets sw.transaction on both span data and metric data points for sampled spans' do OpenTelemetry::SDK.configure metric_exporter = OpenTelemetry::SDK::Metrics::Export::InMemoryMetricPullExporter.new trace_exporter = OpenTelemetry::SDK::Trace::Export::InMemorySpanExporter.new diff --git a/test/opentelemetry/otlp_processor_test.rb b/test/opentelemetry/otlp_processor_test.rb index 078d4212..8f654367 100644 --- a/test/opentelemetry/otlp_processor_test.rb +++ b/test/opentelemetry/otlp_processor_test.rb @@ -9,6 +9,10 @@ require './lib/solarwinds_apm/support/txn_name_manager' require './lib/solarwinds_apm/otel_config' require './lib/solarwinds_apm/api' +require 'opentelemetry-metrics-sdk' +require './lib/solarwinds_apm/constants' +require './lib/solarwinds_apm/support/utils' +require './lib/solarwinds_apm/opentelemetry/otlp_processor' describe 'SolarWindsOTLPProcessor' do before do @@ -17,17 +21,17 @@ @processor = SolarWindsAPM::OpenTelemetry::OTLPProcessor.new(@txn_manager) end - it 'initializes_metrics' do + it 'initializes with exactly one metric instrument' do _(@processor.instance_variable_get(:@metrics).size).must_equal 1 end - it 'does_not_have_transaction_manager' do + it 'has a transaction manager instance after initialization' do # currently otlp processor is only used in lambda which does not support transaction naming via SDK # this assumption may change when we introduce otlp export for non-lambda environments assert(@processor.txn_manager) end - it 'test_calculate_span_time' do + it 'calculates span duration in microseconds and returns 0 for nil timestamps' do span_data = create_span_data result = @processor.send(:calculate_span_time, start_time: span_data.start_timestamp, @@ -41,13 +45,13 @@ _(result).must_equal 0 end - it 'test_calculate_transaction_names' do + it 'returns the span name as the default transaction name' do span = create_span result = @processor.send(:calculate_transaction_names, span) _(result).must_equal 'name' end - it 'test_calculate_transaction_names_with_SW_APM_TRANSACTION_NAME' do + it 'returns SW_APM_TRANSACTION_NAME env var value when set' do ENV['SW_APM_TRANSACTION_NAME'] = 'another_name' span = create_span @@ -56,7 +60,7 @@ ENV.delete('SW_APM_TRANSACTION_NAME') end - it 'test_calculate_transaction_names_with_SW_APM_TRANSACTION_NAME_nil' do + it 'falls back to span name when SW_APM_TRANSACTION_NAME is nil' do ENV['SW_APM_TRANSACTION_NAME'] = nil span = create_span @@ -64,7 +68,7 @@ _(result).must_equal 'name' end - it 'test_get_http_status_code' do + it 'returns 0 when no status code attribute exists and the value when present' do span_data = create_span_data result = @processor.send(:get_http_status_code, span_data) _(result).must_equal 0 @@ -74,19 +78,19 @@ _(result).must_equal 200 end - it 'test_error?' do + it 'returns 0 for a span with non-error status' do span_data = create_span_data result = @processor.send(:error?, span_data) _(result).must_equal 0 end - it 'test_span_http?' do + it 'returns false for a span without HTTP attributes' do span_data = create_span_data result = @processor.send(:span_http?, span_data) _(result).must_equal false end - it 'test_on_start' do + it 'stores root context in txn_manager when processing entry span' do span = create_span processor = SolarWindsAPM::OpenTelemetry::OTLPProcessor.new(@txn_manager) processor.on_start(span, OpenTelemetry::Context.current) @@ -94,14 +98,14 @@ end describe 'HTTP semantic convention tests' do - it 'test_get_http_status_code_with_new_semantic_convention' do + it 'returns status code from http.response.status_code attribute' do span_data = create_span_data span_data.attributes['http.response.status_code'] = 201 result = @processor.send(:get_http_status_code, span_data) _(result).must_equal 201 end - it 'test_span_http_with_old_http_method' do + it 'returns true for SERVER span with legacy http.method attribute' do span_limits = OpenTelemetry::SDK::Trace::SpanLimits.new attributes = { 'http.method' => 'GET' } span_context = OpenTelemetry::Trace::SpanContext.new( @@ -129,7 +133,7 @@ _(result).must_equal true end - it 'test_span_http_with_new_http_request_method' do + it 'returns true for SERVER span with http.request.method attribute' do span_limits = OpenTelemetry::SDK::Trace::SpanLimits.new attributes = { 'http.request.method' => 'POST' } span_context = OpenTelemetry::Trace::SpanContext.new( @@ -157,7 +161,7 @@ _(result).must_equal true end - it 'test_span_http_with_both_old_and_new_conventions' do + it 'returns true when both legacy and new HTTP method attributes are present' do span_limits = OpenTelemetry::SDK::Trace::SpanLimits.new attributes = { 'http.method' => 'GET', 'http.request.method' => 'POST' } span_context = OpenTelemetry::Trace::SpanContext.new( @@ -185,7 +189,7 @@ _(result).must_equal true end - it 'test_span_http_returns_false_for_non_server_span' do + it 'returns false for CLIENT span even with HTTP attributes' do span_limits = OpenTelemetry::SDK::Trace::SpanLimits.new attributes = { 'http.request.method' => 'GET' } span_context = OpenTelemetry::Trace::SpanContext.new( @@ -213,7 +217,7 @@ _(result).must_equal false end - it 'test_meter_attributes_with_old_http_method' do + it 'builds meter attributes using legacy http.method and http.status_code' do span_limits = OpenTelemetry::SDK::Trace::SpanLimits.new attributes = { 'http.method' => 'GET', 'http.status_code' => 200 } span_context = OpenTelemetry::Trace::SpanContext.new( @@ -245,7 +249,7 @@ _(result['sw.is_error']).must_equal false end - it 'test_meter_attributes_with_new_http_request_method' do + it 'builds meter attributes using http.request.method and http.response.status_code' do span_limits = OpenTelemetry::SDK::Trace::SpanLimits.new attributes = { 'http.request.method' => 'POST', 'http.response.status_code' => 201 } span_context = OpenTelemetry::Trace::SpanContext.new( @@ -277,7 +281,7 @@ _(result['sw.is_error']).must_equal false end - it 'test_meter_attributes_with_new_and_old_status_code_as_same_code' do + it 'uses new status code when old and new status codes are identical' do span_limits = OpenTelemetry::SDK::Trace::SpanLimits.new attributes = { 'http.request.method' => 'POST', 'http.status_code' => 201, 'http.response.status_code' => 201 } span_context = OpenTelemetry::Trace::SpanContext.new( @@ -309,7 +313,7 @@ _(result['sw.is_error']).must_equal false end - it 'test_meter_attributes_with_new_and_old_status_code_as_different_code' do + it 'prefers new http.response.status_code when old and new values differ' do span_limits = OpenTelemetry::SDK::Trace::SpanLimits.new attributes = { 'http.request.method' => 'POST', 'http.status_code' => 200, 'http.response.status_code' => 201 } span_context = OpenTelemetry::Trace::SpanContext.new( @@ -341,7 +345,7 @@ _(result['sw.is_error']).must_equal false end - it 'test_meter_attributes_non_http_span' do + it 'excludes HTTP attributes from meter_attributes for non-HTTP spans' do span_limits = OpenTelemetry::SDK::Trace::SpanLimits.new attributes = {} span_context = OpenTelemetry::Trace::SpanContext.new( @@ -377,3 +381,426 @@ end end end + +describe 'OTLPProcessor lifecycle, entry span detection, transaction naming, and metric attributes' do + before do + SolarWindsAPM::OpenTelemetry::OTLPProcessor.prepend(DisableAddView) + @txn_manager = SolarWindsAPM::TxnNameManager.new + @processor = SolarWindsAPM::OpenTelemetry::OTLPProcessor.new(@txn_manager) + end + + describe 'force_flush' do + it 'returns SUCCESS' do + result = @processor.force_flush + assert_equal ::OpenTelemetry::SDK::Trace::Export::SUCCESS, result + end + end + + describe 'shutdown' do + it 'returns SUCCESS' do + result = @processor.shutdown + assert_equal ::OpenTelemetry::SDK::Trace::Export::SUCCESS, result + end + end + + describe 'txn_manager' do + it 'returns the transaction name manager' do + assert_equal @txn_manager, @processor.txn_manager + end + end + + describe 'on_start' do + it 'does nothing for non-entry spans' do + parent_span = create_span + parent_context = OpenTelemetry::Trace.context_with_span(parent_span) + + span = create_span + @processor.on_start(span, parent_context) + + # Non-entry span should not have sw.is_entry_span + refute span.attributes&.key?('sw.is_entry_span') + end + + it 'sets entry span attributes for root spans' do + span_limits = OpenTelemetry::SDK::Trace::SpanLimits.new(attribute_count_limit: 10) + span_context = OpenTelemetry::Trace::SpanContext.new( + span_id: Random.bytes(8), + trace_id: Random.bytes(16) + ) + span = OpenTelemetry::SDK::Trace::Span.new( + span_context, + OpenTelemetry::Context.empty, + OpenTelemetry::Trace::Span::INVALID, + 'test_entry', + OpenTelemetry::Trace::SpanKind::SERVER, + nil, + span_limits, + [], + {}, + nil, + Time.now, + nil, + nil + ) + parent_context = OpenTelemetry::Context.empty + + @processor.on_start(span, parent_context) + + assert span.attributes['sw.is_entry_span'] + end + + it 'handles exceptions gracefully' do + # Pass something that would cause an error + span = create_span + bad_context = nil + + # Should not raise, should handle gracefully + @processor.on_start(span, bad_context) + end + end + + describe 'on_finish' do + it 'does nothing for non-entry spans' do + span_data = create_span_data + # Does not raise + @processor.on_finish(span_data) + end + end + + describe 'on_finishing' do + it 'does nothing for non-entry spans' do + span = create_span + @processor.on_finishing(span) + end + end + + describe 'private methods via send' do + describe 'calculate_span_time' do + it 'returns 0 when start_time is nil' do + result = @processor.send(:calculate_span_time, start_time: nil, end_time: 1000) + assert_equal 0, result + end + + it 'returns 0 when end_time is nil' do + result = @processor.send(:calculate_span_time, start_time: 1000, end_time: nil) + assert_equal 0, result + end + + it 'calculates time difference in microseconds' do + result = @processor.send(:calculate_span_time, start_time: 1_000_000_000, end_time: 2_000_000_000) + assert result > 0 + end + end + + describe 'error?' do + it 'returns 1 for error status' do + span_data = create_span_data + # Override status to error + error_status = OpenTelemetry::Trace::Status.error('error') + span_data_with_error = OpenTelemetry::SDK::Trace::SpanData.new( + 'test', :internal, error_status, + ("\0" * 8).b, 2, 2, 0, + 1_669_317_386_253_789_212, 1_669_317_386_298_642_087, + {}, nil, nil, + OpenTelemetry::SDK::Resources::Resource.create({}), + OpenTelemetry::SDK::InstrumentationScope.new('test', '1.0'), + Random.bytes(8), Random.bytes(16), + OpenTelemetry::Trace::TraceFlags.from_byte(0x01), + OpenTelemetry::Trace::Tracestate::DEFAULT + ) + + result = @processor.send(:error?, span_data_with_error) + assert_equal 1, result + end + + it 'returns 0 for ok status' do + span_data = create_span_data + result = @processor.send(:error?, span_data) + assert_equal 0, result + end + end + + describe 'span_http?' do + it 'returns true for server span with http.method' do + span_data = OpenTelemetry::SDK::Trace::SpanData.new( + 'test', :internal, OpenTelemetry::Trace::Status.ok, + ("\0" * 8).b, 2, 2, 0, + 1_669_317_386_253_789_212, 1_669_317_386_298_642_087, + { 'http.method' => 'GET' }, nil, nil, + OpenTelemetry::SDK::Resources::Resource.create({}), + OpenTelemetry::SDK::InstrumentationScope.new('test', '1.0'), + Random.bytes(8), Random.bytes(16), + OpenTelemetry::Trace::TraceFlags.from_byte(0x01), + OpenTelemetry::Trace::Tracestate::DEFAULT + ) + # Override kind to SERVER + span_data.define_singleton_method(:kind) { ::OpenTelemetry::Trace::SpanKind::SERVER } + + result = @processor.send(:span_http?, span_data) + assert result + end + + it 'returns true for server span with http.request.method' do + span_data = OpenTelemetry::SDK::Trace::SpanData.new( + 'test', :internal, OpenTelemetry::Trace::Status.ok, + ("\0" * 8).b, 2, 2, 0, + 1_669_317_386_253_789_212, 1_669_317_386_298_642_087, + { 'http.request.method' => 'POST' }, nil, nil, + OpenTelemetry::SDK::Resources::Resource.create({}), + OpenTelemetry::SDK::InstrumentationScope.new('test', '1.0'), + Random.bytes(8), Random.bytes(16), + OpenTelemetry::Trace::TraceFlags.from_byte(0x01), + OpenTelemetry::Trace::Tracestate::DEFAULT + ) + span_data.define_singleton_method(:kind) { ::OpenTelemetry::Trace::SpanKind::SERVER } + + result = @processor.send(:span_http?, span_data) + assert result + end + + it 'returns false for non-server span' do + span_data = create_span_data + result = @processor.send(:span_http?, span_data) + refute result + end + end + + describe 'get_http_status_code' do + it 'returns http.response.status_code when present' do + span_data = OpenTelemetry::SDK::Trace::SpanData.new( + 'test', :internal, OpenTelemetry::Trace::Status.ok, + ("\0" * 8).b, 2, 2, 0, + 1_669_317_386_253_789_212, 1_669_317_386_298_642_087, + { 'http.response.status_code' => 201 }, nil, nil, + OpenTelemetry::SDK::Resources::Resource.create({}), + OpenTelemetry::SDK::InstrumentationScope.new('test', '1.0'), + Random.bytes(8), Random.bytes(16), + OpenTelemetry::Trace::TraceFlags.from_byte(0x01), + OpenTelemetry::Trace::Tracestate::DEFAULT + ) + + result = @processor.send(:get_http_status_code, span_data) + assert_equal 201, result + end + + it 'returns http.status_code fallback' do + span_data = OpenTelemetry::SDK::Trace::SpanData.new( + 'test', :internal, OpenTelemetry::Trace::Status.ok, + ("\0" * 8).b, 2, 2, 0, + 1_669_317_386_253_789_212, 1_669_317_386_298_642_087, + { 'http.status_code' => 404 }, nil, nil, + OpenTelemetry::SDK::Resources::Resource.create({}), + OpenTelemetry::SDK::InstrumentationScope.new('test', '1.0'), + Random.bytes(8), Random.bytes(16), + OpenTelemetry::Trace::TraceFlags.from_byte(0x01), + OpenTelemetry::Trace::Tracestate::DEFAULT + ) + + result = @processor.send(:get_http_status_code, span_data) + assert_equal 404, result + end + + it 'returns INVALID_HTTP_STATUS_CODE when no status code' do + span_data = create_span_data + result = @processor.send(:get_http_status_code, span_data) + assert_equal 0, result + end + end + + describe 'non_entry_span' do + it 'returns true for span without sw.is_entry_span' do + span_data = create_span_data + result = @processor.send(:non_entry_span, span: span_data) + assert result + end + + it 'returns false for parent context with invalid parent span' do + context = OpenTelemetry::Context.empty + result = @processor.send(:non_entry_span, parent_context: context) + refute result + end + + it 'returns true for local parent context' do + parent_span = create_span + parent_context = OpenTelemetry::Trace.context_with_span(parent_span) + result = @processor.send(:non_entry_span, parent_context: parent_context) + assert result + end + end + + describe 'calculate_transaction_names' do + it 'uses txn_manager name when available' do + span = create_span + trace_span_id = "#{span.context.hex_trace_id}-#{span.context.hex_span_id}" + @txn_manager.set(trace_span_id, 'custom_txn') + + result = @processor.send(:calculate_transaction_names, span) + assert_equal 'custom_txn', result + end + + it 'uses env var SW_APM_TRANSACTION_NAME when set' do + ENV['SW_APM_TRANSACTION_NAME'] = 'env_txn_name' + span = create_span + + result = @processor.send(:calculate_transaction_names, span) + assert_equal 'env_txn_name', result + ensure + ENV.delete('SW_APM_TRANSACTION_NAME') + end + + it 'uses http.route from span attributes' do + span_context = OpenTelemetry::Trace::SpanContext.new( + span_id: Random.bytes(8), + trace_id: Random.bytes(16) + ) + span = OpenTelemetry::SDK::Trace::Span.new( + span_context, + OpenTelemetry::Context.empty, + OpenTelemetry::Trace::Span::INVALID, + 'default_name', + OpenTelemetry::Trace::SpanKind::SERVER, + nil, + OpenTelemetry::SDK::Trace::SpanLimits.new, + [], + { 'http.route' => '/api/v1/users' }, + nil, + Time.now, + nil, + nil + ) + + result = @processor.send(:calculate_transaction_names, span) + assert_equal '/api/v1/users', result + end + + it 'falls back to span name when no http.route' do + span_context = OpenTelemetry::Trace::SpanContext.new( + span_id: Random.bytes(8), + trace_id: Random.bytes(16) + ) + span = OpenTelemetry::SDK::Trace::Span.new( + span_context, + OpenTelemetry::Context.empty, + OpenTelemetry::Trace::Span::INVALID, + 'my_span_name', + OpenTelemetry::Trace::SpanKind::SERVER, + nil, + OpenTelemetry::SDK::Trace::SpanLimits.new, + [], + {}, + nil, + Time.now, + nil, + nil + ) + + result = @processor.send(:calculate_transaction_names, span) + assert_equal 'my_span_name', result + end + + it 'uses lambda transaction name in lambda mode' do + @processor.instance_variable_set(:@is_lambda, true) + ENV['AWS_LAMBDA_FUNCTION_NAME'] = 'my-lambda' + + span = create_span + result = @processor.send(:calculate_transaction_names, span) + assert_equal 'my-lambda', result + ensure + @processor.instance_variable_set(:@is_lambda, false) + ENV.delete('AWS_LAMBDA_FUNCTION_NAME') + end + end + + describe 'calculate_lambda_transaction_name' do + it 'uses SW_APM_TRANSACTION_NAME env var first' do + ENV['SW_APM_TRANSACTION_NAME'] = 'custom_lambda' + result = @processor.send(:calculate_lambda_transaction_name, 'span_name') + assert_equal 'custom_lambda', result + ensure + ENV.delete('SW_APM_TRANSACTION_NAME') + end + + it 'uses AWS_LAMBDA_FUNCTION_NAME when no custom name' do + ENV['AWS_LAMBDA_FUNCTION_NAME'] = 'my-lambda-func' + result = @processor.send(:calculate_lambda_transaction_name, 'span_name') + assert_equal 'my-lambda-func', result + ensure + ENV.delete('AWS_LAMBDA_FUNCTION_NAME') + end + + it 'falls back to span_name' do + result = @processor.send(:calculate_lambda_transaction_name, 'the_span') + assert_equal 'the_span', result + end + + it 'falls back to unknown when no name' do + result = @processor.send(:calculate_lambda_transaction_name, nil) + assert_equal 'unknown', result + end + end + + describe 'meter_attributes' do + it 'includes http status code and method for http spans' do + @processor.instance_variable_set(:@transaction_name, 'test_txn') + + span_data = OpenTelemetry::SDK::Trace::SpanData.new( + 'test', :internal, OpenTelemetry::Trace::Status.ok, + ("\0" * 8).b, 2, 2, 0, + 1_669_317_386_253_789_212, 1_669_317_386_298_642_087, + { 'http.method' => 'GET', 'http.status_code' => 200, 'sw.is_entry_span' => true }, nil, nil, + OpenTelemetry::SDK::Resources::Resource.create({}), + OpenTelemetry::SDK::InstrumentationScope.new('test', '1.0'), + Random.bytes(8), Random.bytes(16), + OpenTelemetry::Trace::TraceFlags.from_byte(0x01), + OpenTelemetry::Trace::Tracestate::DEFAULT + ) + span_data.define_singleton_method(:kind) { ::OpenTelemetry::Trace::SpanKind::SERVER } + + result = @processor.send(:meter_attributes, span_data) + assert_equal 200, result['http.status_code'] + assert_equal 'GET', result['http.method'] + assert_equal 'test_txn', result['sw.transaction'] + end + + it 'uses http.request.method over http.method' do + @processor.instance_variable_set(:@transaction_name, 'test_txn') + + span_data = OpenTelemetry::SDK::Trace::SpanData.new( + 'test', :internal, OpenTelemetry::Trace::Status.ok, + ("\0" * 8).b, 2, 2, 0, + 1_669_317_386_253_789_212, 1_669_317_386_298_642_087, + { 'http.method' => 'GET', 'http.request.method' => 'POST', 'sw.is_entry_span' => true }, nil, nil, + OpenTelemetry::SDK::Resources::Resource.create({}), + OpenTelemetry::SDK::InstrumentationScope.new('test', '1.0'), + Random.bytes(8), Random.bytes(16), + OpenTelemetry::Trace::TraceFlags.from_byte(0x01), + OpenTelemetry::Trace::Tracestate::DEFAULT + ) + span_data.define_singleton_method(:kind) { ::OpenTelemetry::Trace::SpanKind::SERVER } + + result = @processor.send(:meter_attributes, span_data) + assert_equal 'POST', result['http.method'] + end + + it 'omits http status code when 0' do + @processor.instance_variable_set(:@transaction_name, 'test_txn') + + span_data = OpenTelemetry::SDK::Trace::SpanData.new( + 'test', :internal, OpenTelemetry::Trace::Status.ok, + ("\0" * 8).b, 2, 2, 0, + 1_669_317_386_253_789_212, 1_669_317_386_298_642_087, + { 'http.method' => 'GET', 'sw.is_entry_span' => true }, nil, nil, + OpenTelemetry::SDK::Resources::Resource.create({}), + OpenTelemetry::SDK::InstrumentationScope.new('test', '1.0'), + Random.bytes(8), Random.bytes(16), + OpenTelemetry::Trace::TraceFlags.from_byte(0x01), + OpenTelemetry::Trace::Tracestate::DEFAULT + ) + span_data.define_singleton_method(:kind) { ::OpenTelemetry::Trace::SpanKind::SERVER } + + result = @processor.send(:meter_attributes, span_data) + refute result.key?('http.status_code') + end + end + end +end diff --git a/test/opentelemetry/otlp_processor_unsampled_test.rb b/test/opentelemetry/otlp_processor_unsampled_test.rb index fd8a487f..b5b54101 100644 --- a/test/opentelemetry/otlp_processor_unsampled_test.rb +++ b/test/opentelemetry/otlp_processor_unsampled_test.rb @@ -19,7 +19,7 @@ @processor = SolarWindsAPM::OpenTelemetry::OTLPProcessor.new(txn_manager) end - it 'unsampled_span_but_metrics_have_transaction_name' do + it 'records sw.transaction in metrics even when span is unsampled and not exported' do OpenTelemetry::SDK.configure metric_exporter = OpenTelemetry::SDK::Metrics::Export::InMemoryMetricPullExporter.new trace_exporter = OpenTelemetry::SDK::Trace::Export::InMemorySpanExporter.new diff --git a/test/opentelemetry/solarwinds_propagator_test.rb b/test/opentelemetry/solarwinds_propagator_test.rb index 3cb2619c..3ff58276 100644 --- a/test/opentelemetry/solarwinds_propagator_test.rb +++ b/test/opentelemetry/solarwinds_propagator_test.rb @@ -9,6 +9,8 @@ require './lib/solarwinds_apm/support/utils' require './lib/solarwinds_apm/support/transaction_settings' require './lib/solarwinds_apm/config' +require './lib/solarwinds_apm/constants' +require './lib/solarwinds_apm/opentelemetry/solarwinds_propagator' describe 'SolarWindsPropagatorTest' do before do @@ -16,13 +18,13 @@ @mock = Minitest::Mock.new end - it 'test extract for empty carrier' do + it 'returns a valid context when carrier is empty' do carrier = {} result = @text_map_propagator.extract(carrier) _(result.class.to_s).must_equal 'OpenTelemetry::Context' end - it 'test extract for non-empty carrier' do + it 'extracts x-trace-options and signature from carrier into context' do carrier = {} carrier['x-trace-options'] = 'foo' carrier['x-trace-options-signature'] = 'bar' @@ -32,7 +34,7 @@ _(result.value('sw_signature')).must_equal 'bar' end - it 'test extract for non-empty carrier and context' do + it 'overwrites existing context values with new carrier headers' do carrier = {} carrier['x-trace-options'] = 'foo' carrier['x-trace-options-signature'] = 'bar' @@ -48,7 +50,7 @@ _(result.value('sw_signature')).must_equal 'bar' end - it 'test inject for empty carrier and valid context' do + it 'calls current_span with context when injecting into empty carrier' do @mock.expect(:call, nil, [OpenTelemetry::Context]) OpenTelemetry::Trace.stub(:current_span, @mock) do @@ -65,7 +67,7 @@ _(@mock.verify).must_equal true end - it 'test inject for trace_state_header is nil (create new trace state)' do + it 'creates new tracestate when no tracestate header exists' do @mock.expect(:call, OpenTelemetry::Trace::Tracestate.create({}), [Hash]) OpenTelemetry::Trace::Tracestate.stub(:create, @mock) do @@ -82,7 +84,7 @@ _(@mock.verify).must_equal true end - it 'test inject for trace_state_header is not nil trace state set_values' do + it 'parses existing tracestate header and updates sw value' do @mock.expect(:call, OpenTelemetry::Trace::Tracestate.create({}), [String]) OpenTelemetry::Trace::Tracestate.stub(:from_string, @mock) do @@ -100,7 +102,7 @@ _(@mock.verify).must_equal true end - it 'test inject for check setter' do + it 'uses text_map_setter to set tracestate on carrier' do @mock.expect(:call, nil, [Hash, String, String]) OpenTelemetry::Context::Propagation.text_map_setter.stub(:set, @mock) do @@ -118,3 +120,128 @@ _(@mock.verify).must_equal true end end + +describe 'SolarWindsPropagator extract x-trace-options and inject sw tracestate' do + before do + @propagator = SolarWindsAPM::OpenTelemetry::SolarWindsPropagator::TextMapPropagator.new + end + + describe 'extract' do + it 'extracts x-trace-options header into context' do + carrier = { 'x-trace-options' => 'trigger-trace;ts=12345' } + context = @propagator.extract(carrier, context: OpenTelemetry::Context.empty) + + assert_equal 'trigger-trace;ts=12345', context.value('sw_xtraceoptions') + end + + it 'extracts x-trace-options-signature header into context' do + carrier = { + 'x-trace-options' => 'trigger-trace', + 'x-trace-options-signature' => 'abc123' + } + context = @propagator.extract(carrier, context: OpenTelemetry::Context.empty) + + assert_equal 'trigger-trace', context.value('sw_xtraceoptions') + assert_equal 'abc123', context.value('sw_signature') + end + + it 'returns context unchanged when no headers present' do + carrier = {} + original_context = OpenTelemetry::Context.empty + context = @propagator.extract(carrier, context: original_context) + + assert_nil context.value('sw_xtraceoptions') + assert_nil context.value('sw_signature') + end + + it 'handles nil context gracefully' do + carrier = { 'x-trace-options' => 'trigger-trace' } + context = @propagator.extract(carrier, context: nil) + refute_nil context + end + + it 'handles exceptions gracefully' do + carrier = nil + context = @propagator.extract(carrier, context: OpenTelemetry::Context.empty) + refute_nil context + end + end + + describe 'inject' do + it 'injects sw tracestate when no existing tracestate' do + span_context = OpenTelemetry::Trace::SpanContext.new( + span_id: Random.bytes(8), + trace_id: Random.bytes(16), + trace_flags: OpenTelemetry::Trace::TraceFlags::SAMPLED + ) + span = OpenTelemetry::Trace.non_recording_span(span_context) + context = OpenTelemetry::Trace.context_with_span(span) + + carrier = {} + @propagator.inject(carrier, context: context) + + refute_nil carrier['tracestate'] + assert_includes carrier['tracestate'], 'sw=' + end + + it 'updates existing tracestate with sw value' do + span_context = OpenTelemetry::Trace::SpanContext.new( + span_id: Random.bytes(8), + trace_id: Random.bytes(16), + trace_flags: OpenTelemetry::Trace::TraceFlags::SAMPLED + ) + span = OpenTelemetry::Trace.non_recording_span(span_context) + context = OpenTelemetry::Trace.context_with_span(span) + + carrier = { 'tracestate' => 'other=value' } + @propagator.inject(carrier, context: context) + + assert_includes carrier['tracestate'], 'sw=' + assert_includes carrier['tracestate'], 'other=value' + end + + it 'does not inject when span context is invalid' do + context = OpenTelemetry::Context.empty + carrier = {} + @propagator.inject(carrier, context: context) + + assert_nil carrier['tracestate'] + end + + it 'sets trace flag 01 for sampled spans' do + span_context = OpenTelemetry::Trace::SpanContext.new( + span_id: Random.bytes(8), + trace_id: Random.bytes(16), + trace_flags: OpenTelemetry::Trace::TraceFlags::SAMPLED + ) + span = OpenTelemetry::Trace.non_recording_span(span_context) + context = OpenTelemetry::Trace.context_with_span(span) + + carrier = {} + @propagator.inject(carrier, context: context) + + assert_includes carrier['tracestate'], '-01' + end + + it 'sets trace flag 00 for non-sampled spans' do + span_context = OpenTelemetry::Trace::SpanContext.new( + span_id: Random.bytes(8), + trace_id: Random.bytes(16), + trace_flags: OpenTelemetry::Trace::TraceFlags::DEFAULT + ) + span = OpenTelemetry::Trace.non_recording_span(span_context) + context = OpenTelemetry::Trace.context_with_span(span) + + carrier = {} + @propagator.inject(carrier, context: context) + + assert_includes carrier['tracestate'], '-00' + end + end + + describe 'fields' do + it 'returns tracestate' do + assert_equal 'tracestate', @propagator.fields + end + end +end diff --git a/test/opentelemetry/solarwinds_response_propagator_test.rb b/test/opentelemetry/solarwinds_response_propagator_test.rb new file mode 100644 index 00000000..4cc63142 --- /dev/null +++ b/test/opentelemetry/solarwinds_response_propagator_test.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +# Copyright (c) 2025 SolarWinds, LLC. +# All rights reserved. + +require 'minitest_helper' +require_relative '../../lib/solarwinds_apm/config' +require './lib/solarwinds_apm/constants' +require './lib/solarwinds_apm/support/utils' +require './lib/solarwinds_apm/opentelemetry/solarwinds_response_propagator' + +describe 'SolarWindsResponsePropagator extract passthrough and inject x-trace headers' do + before do + @propagator = SolarWindsAPM::OpenTelemetry::SolarWindsResponsePropagator::TextMapPropagator.new + end + + describe 'extract' do + it 'returns context unchanged' do + context = OpenTelemetry::Context.empty + result = @propagator.extract({}, context: context) + assert_equal context, result + end + end + + describe 'inject' do + it 'injects x-trace header for valid span context' do + span_context = OpenTelemetry::Trace::SpanContext.new( + span_id: Random.bytes(8), + trace_id: Random.bytes(16), + trace_flags: OpenTelemetry::Trace::TraceFlags::SAMPLED, + tracestate: OpenTelemetry::Trace::Tracestate.from_hash({}) + ) + span = OpenTelemetry::Trace.non_recording_span(span_context) + context = OpenTelemetry::Trace.context_with_span(span) + + carrier = {} + @propagator.inject(carrier, context: context) + + assert carrier.key?('x-trace') + assert_includes carrier['x-trace'], '00-' + assert carrier.key?('Access-Control-Expose-Headers') + assert_includes carrier['Access-Control-Expose-Headers'], 'x-trace' + end + + it 'injects x-trace-options-response when xtrace_options_response in tracestate' do + tracestate = OpenTelemetry::Trace::Tracestate.from_hash({ + 'xtrace_options_response' => 'auth:ok;trigger-trace:ok' + }) + span_context = OpenTelemetry::Trace::SpanContext.new( + span_id: Random.bytes(8), + trace_id: Random.bytes(16), + trace_flags: OpenTelemetry::Trace::TraceFlags::SAMPLED, + tracestate: tracestate + ) + span = OpenTelemetry::Trace.non_recording_span(span_context) + context = OpenTelemetry::Trace.context_with_span(span) + + carrier = {} + @propagator.inject(carrier, context: context) + + assert carrier.key?('x-trace-options-response') + assert_includes carrier['Access-Control-Expose-Headers'], 'x-trace-options-response' + end + + it 'does not inject for invalid span context' do + context = OpenTelemetry::Context.empty + carrier = {} + @propagator.inject(carrier, context: context) + + refute carrier.key?('x-trace') + end + + it 'does not include x-trace-options-response when empty' do + tracestate = OpenTelemetry::Trace::Tracestate.from_hash({}) + span_context = OpenTelemetry::Trace::SpanContext.new( + span_id: Random.bytes(8), + trace_id: Random.bytes(16), + trace_flags: OpenTelemetry::Trace::TraceFlags::SAMPLED, + tracestate: tracestate + ) + span = OpenTelemetry::Trace.non_recording_span(span_context) + context = OpenTelemetry::Trace.context_with_span(span) + + carrier = {} + @propagator.inject(carrier, context: context) + + refute carrier.key?('x-trace-options-response') + end + end +end diff --git a/test/patch/sw_dbo_utils_test.rb b/test/patch/sw_dbo_utils_test.rb new file mode 100644 index 00000000..f72131aa --- /dev/null +++ b/test/patch/sw_dbo_utils_test.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +# Copyright (c) 2025 SolarWinds, LLC. +# All rights reserved. + +require 'minitest_helper' +require_relative '../../lib/solarwinds_apm/config' +require './lib/solarwinds_apm/support' +require './lib/solarwinds_apm/patch/tag_sql/sw_dbo_utils' + +describe 'SWODboUtils#annotate_span_and_sql traceparent injection based on sampling state' do + it 'returns sql unchanged for empty sql' do + result = SolarWindsAPM::Patch::TagSql::SWODboUtils.annotate_span_and_sql('') + assert_equal '', result + end + + it 'returns sql unchanged for nil sql' do + result = SolarWindsAPM::Patch::TagSql::SWODboUtils.annotate_span_and_sql(nil) + assert_equal nil, result + end + + it 'annotates sql with traceparent when span is sampled' do + OpenTelemetry::SDK.configure + tracer = OpenTelemetry.tracer_provider.tracer('test') + + result = nil + tracer.in_span('test_span') do + result = SolarWindsAPM::Patch::TagSql::SWODboUtils.annotate_span_and_sql('SELECT 1') + end + + assert_includes result, 'SELECT 1' + assert_includes result, "/*traceparent='" + end + + it 'returns sql unchanged when span is not sampled' do + OpenTelemetry::SDK.configure + + # Create unsampled span context + trace_flags = OpenTelemetry::Trace::TraceFlags.from_byte(0x00) + span_context = OpenTelemetry::Trace::SpanContext.new(trace_flags: trace_flags) + span = OpenTelemetry::Trace::Span.new(span_context: span_context) + + OpenTelemetry::Context.with_value(OpenTelemetry::Trace.const_get(:CURRENT_SPAN_KEY), span) do + result = SolarWindsAPM::Patch::TagSql::SWODboUtils.annotate_span_and_sql('SELECT 1') + assert_equal 'SELECT 1', result + end + end +end diff --git a/test/patch/sw_mysql2_patch_integrate_test.rb b/test/patch/sw_mysql2_patch_integrate_test.rb index 3c8cfaa2..0715c703 100644 --- a/test/patch/sw_mysql2_patch_integrate_test.rb +++ b/test/patch/sw_mysql2_patch_integrate_test.rb @@ -28,7 +28,7 @@ def self.createSpan(trans_name, domain, span_time, has_error); end let(:span_processor) { sdk::Trace::Export::SimpleSpanProcessor.new(exporter) } let(:finished_spans) { exporter.finished_spans } - it 'tag_sql_mysql2_integrate_test' do + it 'injects traceparent comment into MySQL2 queries and sets sw.query_tag attribute' do require './lib/solarwinds_apm/patch/tag_sql/sw_mysql2_patch' OpenTelemetry::SDK.configure(&:use_all) diff --git a/test/patch/sw_mysql2_patch_test.rb b/test/patch/sw_mysql2_patch_test.rb index ccfb76de..5a56075d 100644 --- a/test/patch/sw_mysql2_patch_test.rb +++ b/test/patch/sw_mysql2_patch_test.rb @@ -12,7 +12,7 @@ describe 'mysql2 patch test' do puts "\n\033[1m=== TEST RUN MYSQL2 PATCH TEST: #{RUBY_VERSION} #{File.basename(__FILE__)} #{Time.now.strftime('%Y-%m-%d %H:%M')} ===\033[0m\n" - it 'mysql_patch_order_test_when_tag_sql_is_false' do + it 'places OTel patch before Mysql2::Client in ancestors when tag_sql is false' do SolarWindsAPM::Config[:tag_sql] = false SolarWindsAPM::OTelConfig.initialize diff --git a/test/patch/sw_pg_patch_integrate_test.rb b/test/patch/sw_pg_patch_integrate_test.rb index 6b1a029c..1fa58aa0 100644 --- a/test/patch/sw_pg_patch_integrate_test.rb +++ b/test/patch/sw_pg_patch_integrate_test.rb @@ -35,7 +35,7 @@ def pg_dbo_integration_verification(sql, finished_spans) let(:exporter) { sdk::Trace::Export::InMemorySpanExporter.new } let(:span_processor) { sdk::Trace::Export::SimpleSpanProcessor.new(exporter) } - it 'tag_sql_pg_integrate_test' do + it 'injects traceparent comment into PG queries across query, exec, sync_exec, and exec_params' do require './lib/solarwinds_apm/patch/tag_sql/sw_pg_patch' OpenTelemetry::SDK.configure(&:use_all) diff --git a/test/patch/sw_pg_patch_test.rb b/test/patch/sw_pg_patch_test.rb index c19a561b..e9e4abeb 100644 --- a/test/patch/sw_pg_patch_test.rb +++ b/test/patch/sw_pg_patch_test.rb @@ -12,7 +12,7 @@ describe 'pg patch test' do puts "\n\033[1m=== TEST RUN PG PATCH TEST: #{RUBY_VERSION} #{File.basename(__FILE__)} #{Time.now.strftime('%Y-%m-%d %H:%M')} ===\033[0m\n" - it 'pg_patch_order_test_when_tag_sql_is_false' do + it 'places OTel patch before PG::Connection in ancestors when tag_sql is false' do SolarWindsAPM::Config[:tag_sql] = false SolarWindsAPM::OTelConfig.initialize diff --git a/test/sampling/dice_test.rb b/test/sampling/dice_test.rb index 72211f0c..a1a395ef 100644 --- a/test/sampling/dice_test.rb +++ b/test/sampling/dice_test.rb @@ -7,6 +7,8 @@ require 'minitest_helper' require './lib/solarwinds_apm/sampling/dice' +require './lib/solarwinds_apm/sampling/sampling_constants' +require './lib/solarwinds_apm/sampling/token_bucket' describe 'SolarWindsAPM Dice Test' do it 'gives sensible rate over time' do @@ -49,3 +51,75 @@ 500.times { refute dice.roll } end end + +describe 'Dice rate clamping, update behavior, and default scale' do + it 'rate setter clamps to scale' do + dice = SolarWindsAPM::Dice.new(scale: 100, rate: 50) + dice.rate = 200 + assert_equal 100, dice.rate + + dice.rate = -10 + assert_equal 0, dice.rate + end + + it 'update changes both rate and scale' do + dice = SolarWindsAPM::Dice.new(scale: 100, rate: 50) + dice.update(scale: 200, rate: 150) + assert_equal 200, dice.scale + assert_equal 150, dice.rate + end + + it 'defaults to scale 1_000_000' do + dice = SolarWindsAPM::Dice.new({}) + assert_equal 1_000_000, dice.scale + assert_equal 0, dice.rate + end +end + +describe 'TokenBucket accessors and update with various input types and edge cases' do + it 'tokens accessor returns current tokens' do + bucket = SolarWindsAPM::TokenBucket.new(SolarWindsAPM::TokenBucketSettings.new(5, 1, 'test')) + tokens = bucket.tokens + assert tokens <= 5 + assert tokens >= 0 + end + + it 'type accessor returns type' do + bucket = SolarWindsAPM::TokenBucket.new(SolarWindsAPM::TokenBucketSettings.new(5, 1, 'MY_TYPE')) + assert_equal 'MY_TYPE', bucket.type + end + + it 'update with TokenBucketSettings object' do + bucket = SolarWindsAPM::TokenBucket.new(SolarWindsAPM::TokenBucketSettings.new(5, 1, 'test')) + new_settings = SolarWindsAPM::TokenBucketSettings.new(10, 2, 'test') + bucket.update(new_settings) + assert_equal 10, bucket.capacity + assert_equal 2, bucket.rate + end + + it 'update with hash' do + bucket = SolarWindsAPM::TokenBucket.new(SolarWindsAPM::TokenBucketSettings.new(5, 1, 'test')) + bucket.update({ capacity: 20, rate: 5 }) + assert_equal 20, bucket.capacity + assert_equal 5, bucket.rate + end + + it 'update handles only rate change' do + bucket = SolarWindsAPM::TokenBucket.new(SolarWindsAPM::TokenBucketSettings.new(5, 1, 'test')) + bucket.update({ rate: 10 }) + assert_equal 5, bucket.capacity + assert_equal 10, bucket.rate + end + + it 'update handles negative rate gracefully' do + bucket = SolarWindsAPM::TokenBucket.new(SolarWindsAPM::TokenBucketSettings.new(5, 1, 'test')) + bucket.update({ rate: -5 }) + assert_equal 0, bucket.rate + end + + it 'update handles negative capacity gracefully' do + bucket = SolarWindsAPM::TokenBucket.new(SolarWindsAPM::TokenBucketSettings.new(5, 0, 'test')) + bucket.update({ capacity: -5 }) + assert_equal 0, bucket.capacity + end +end diff --git a/test/sampling/http_sampler_test.rb b/test/sampling/http_sampler_test.rb index 601d55bd..34e0de19 100644 --- a/test/sampling/http_sampler_test.rb +++ b/test/sampling/http_sampler_test.rb @@ -73,7 +73,7 @@ end describe 'invalid collector' do - it 'does not sample created spans xuan' do + it 'does not sample spans when collector endpoint is invalid' do new_config = @config.merge(collector: URI('https://collector.invalid')) sampler = SolarWindsAPM::HttpSampler.new(new_config) replace_sampler(sampler) @@ -87,7 +87,7 @@ assert_empty spans end - it 'retries with backoff' do + it 'retries failed settings requests with backoff delay' do sleep 1 # Simulating backoff delay end end diff --git a/test/sampling/json_sampler_test.rb b/test/sampling/json_sampler_test.rb index e9565fa7..870d2c39 100644 --- a/test/sampling/json_sampler_test.rb +++ b/test/sampling/json_sampler_test.rb @@ -145,3 +145,88 @@ end end end + +describe 'JsonSampler settings file reading with malformed/missing/expired input handling' do + before do + @temp_path = '/tmp/solarwinds-apm-test-settings.json' + ENV['OTEL_TRACES_EXPORTER'] = 'none' + OpenTelemetry::SDK.configure + + @memory_exporter = OpenTelemetry::SDK::Trace::Export::InMemorySpanExporter.new + OpenTelemetry.tracer_provider.add_span_processor( + OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(@memory_exporter) + ) + end + + after do + OpenTelemetry::TestHelpers.reset_opentelemetry + @memory_exporter.reset + File.delete(@temp_path) if File.exist?(@temp_path) + end + + it 'handles missing settings file gracefully' do + File.delete(@temp_path) if File.exist?(@temp_path) + sampler = SolarWindsAPM::JsonSampler.new({}, @temp_path) + refute_nil sampler + end + + it 'handles invalid JSON file content' do + File.write(@temp_path, 'not valid json{{{') + sampler = SolarWindsAPM::JsonSampler.new({}, @temp_path) + refute_nil sampler + end + + it 'handles invalid settings structure (not single element array)' do + File.write(@temp_path, JSON.dump([{ flags: 'a' }, { flags: 'b' }])) + sampler = SolarWindsAPM::JsonSampler.new({}, @temp_path) + refute_nil sampler + end + + it 'handles empty array in settings file' do + File.write(@temp_path, JSON.dump([])) + sampler = SolarWindsAPM::JsonSampler.new({}, @temp_path) + refute_nil sampler + end + + it 'handles non-array in settings file' do + File.write(@temp_path, JSON.dump({ 'key' => 'value' })) + sampler = SolarWindsAPM::JsonSampler.new({}, @temp_path) + refute_nil sampler + end + + it 'skips loop_check when settings not expired' do + File.write(@temp_path, JSON.dump([ + { + 'flags' => 'SAMPLE_START,SAMPLE_THROUGH_ALWAYS', + 'value' => 1_000_000, + 'arguments' => { 'BucketCapacity' => 100, 'BucketRate' => 10 }, + 'timestamp' => Time.now.to_i, + 'ttl' => 600 + } + ])) + sampler = SolarWindsAPM::JsonSampler.new({}, @temp_path) + + # Second call should skip due to not expired + params = make_sample_params + sampler.should_sample?(params) + end + + it 'does not re-read when file mtime unchanged' do + File.write(@temp_path, JSON.dump([ + { + 'flags' => 'SAMPLE_START,SAMPLE_THROUGH_ALWAYS', + 'value' => 500_000, + 'arguments' => { 'BucketCapacity' => 50, 'BucketRate' => 5 }, + 'timestamp' => Time.now.to_i - 60, + 'ttl' => 10 + } + ])) + sampler = SolarWindsAPM::JsonSampler.new({}, @temp_path) + sleep(0.1) + + # Force expired to re-read, but mtime is the same + sampler.instance_variable_set(:@expiry, Time.now.to_i - 100) + params = make_sample_params + sampler.should_sample?(params) + end +end diff --git a/test/sampling/metrics_test.rb b/test/sampling/metrics_test.rb new file mode 100644 index 00000000..d8c60779 --- /dev/null +++ b/test/sampling/metrics_test.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +# Copyright (c) 2025 SolarWinds, LLC. +# All rights reserved. + +require 'minitest_helper' +require_relative '../../lib/solarwinds_apm/config' +require 'opentelemetry-metrics-sdk' +require './lib/solarwinds_apm/sampling' + +describe 'Metrics::Counter initialization and key access' do + it 'initializes counters' do + counter = SolarWindsAPM::Metrics::Counter.new + refute_nil counter[:request_count] + refute_nil counter[:sample_count] + refute_nil counter[:trace_count] + refute_nil counter[:through_trace_count] + refute_nil counter[:triggered_trace_count] + refute_nil counter[:token_bucket_exhaustion_count] + end + + it 'returns nil for unknown key' do + counter = SolarWindsAPM::Metrics::Counter.new + assert_nil counter[:nonexistent] + end +end diff --git a/test/sampling/oboe_sampler_test.rb b/test/sampling/oboe_sampler_test.rb index 98baf882..a0e2d594 100644 --- a/test/sampling/oboe_sampler_test.rb +++ b/test/sampling/oboe_sampler_test.rb @@ -830,3 +830,624 @@ end end end + +describe 'OboeSampler sampling algorithms (parent-based, trigger-trace, dice-roll, disabled) and settings lifecycle' do + before do + OpenTelemetry::SDK.configure + @metric_exporter = OpenTelemetry::SDK::Metrics::Export::InMemoryMetricPullExporter.new + OpenTelemetry.meter_provider.add_metric_reader(@metric_exporter) + end + + describe 'parent_based_algo' do + it 'records and samples when SAMPLE_THROUGH_ALWAYS set and parent sampled' do + sampler = OboeTestSampler.new( + settings: { + sample_rate: 1_000_000, + sample_source: SolarWindsAPM::SampleSource::REMOTE, + flags: SolarWindsAPM::Flags::SAMPLE_START | SolarWindsAPM::Flags::SAMPLE_THROUGH_ALWAYS, + buckets: {}, + timestamp: Time.now.to_i, + ttl: 120, + signature_key: nil + }, + local_settings: { tracing_mode: SolarWindsAPM::TracingMode::ALWAYS }, + request_headers: {} + ) + + parent = make_span({ remote: true, sampled: true, sw: true }) + params = make_sample_params(parent: parent) + + result = sampler.should_sample?(params) + assert_equal TEST_OTEL_SAMPLING_DECISION::RECORD_AND_SAMPLE, result.instance_variable_get(:@decision) + end + + it 'records only when SAMPLE_THROUGH_ALWAYS set and parent not sampled' do + sampler = OboeTestSampler.new( + settings: { + sample_rate: 1_000_000, + sample_source: SolarWindsAPM::SampleSource::REMOTE, + flags: SolarWindsAPM::Flags::SAMPLE_START | SolarWindsAPM::Flags::SAMPLE_THROUGH_ALWAYS, + buckets: {}, + timestamp: Time.now.to_i, + ttl: 120, + signature_key: nil + }, + local_settings: { tracing_mode: SolarWindsAPM::TracingMode::ALWAYS }, + request_headers: {} + ) + + parent = make_span({ remote: true, sampled: false, sw: true }) + params = make_sample_params(parent: parent) + + result = sampler.should_sample?(params) + assert_equal TEST_OTEL_SAMPLING_DECISION::RECORD_ONLY, result.instance_variable_get(:@decision) + end + + it 'drops when SAMPLE_THROUGH_ALWAYS unset and SAMPLE_START unset' do + sampler = OboeTestSampler.new( + settings: { + sample_rate: 1_000_000, + sample_source: SolarWindsAPM::SampleSource::REMOTE, + flags: SolarWindsAPM::Flags::OK, + buckets: {}, + timestamp: Time.now.to_i, + ttl: 120, + signature_key: nil + }, + local_settings: { tracing_mode: SolarWindsAPM::TracingMode::NEVER }, + request_headers: {} + ) + + parent = make_span({ remote: true, sampled: true, sw: true }) + params = make_sample_params(parent: parent) + + result = sampler.should_sample?(params) + assert_equal TEST_OTEL_SAMPLING_DECISION::DROP, result.instance_variable_get(:@decision) + end + + it 'records only when SAMPLE_THROUGH_ALWAYS unset and SAMPLE_START set' do + sampler = OboeTestSampler.new( + settings: { + sample_rate: 1_000_000, + sample_source: SolarWindsAPM::SampleSource::REMOTE, + flags: SolarWindsAPM::Flags::SAMPLE_START, + buckets: {}, + timestamp: Time.now.to_i, + ttl: 120, + signature_key: nil + }, + local_settings: { tracing_mode: nil }, + request_headers: {} + ) + + parent = make_span({ remote: true, sampled: true, sw: true }) + params = make_sample_params(parent: parent) + + result = sampler.should_sample?(params) + assert_equal TEST_OTEL_SAMPLING_DECISION::RECORD_ONLY, result.instance_variable_get(:@decision) + end + + it 'ignores trigger trace in parent-based algo' do + sig_key = SecureRandom.random_bytes(8) + headers = make_request_headers(trigger_trace: true, signature: true, signature_key: sig_key) + + sampler = OboeTestSampler.new( + settings: { + sample_rate: 1_000_000, + sample_source: SolarWindsAPM::SampleSource::REMOTE, + flags: SolarWindsAPM::Flags::SAMPLE_START | SolarWindsAPM::Flags::SAMPLE_THROUGH_ALWAYS | SolarWindsAPM::Flags::TRIGGERED_TRACE, + buckets: { + SolarWindsAPM::BucketType::DEFAULT => { capacity: 100, rate: 10 }, + SolarWindsAPM::BucketType::TRIGGER_RELAXED => { capacity: 100, rate: 10 }, + SolarWindsAPM::BucketType::TRIGGER_STRICT => { capacity: 100, rate: 10 } + }, + timestamp: Time.now.to_i, + ttl: 120, + signature_key: sig_key + }, + local_settings: { tracing_mode: SolarWindsAPM::TracingMode::ALWAYS, trigger_mode: :enabled }, + request_headers: headers + ) + + parent = make_span({ remote: true, sampled: true, sw: true }) + params = make_sample_params(parent: parent) + + result = sampler.should_sample?(params) + assert_includes result.tracestate['xtrace_options_response'], 'trigger-trace:ignored' + end + end + + describe 'trigger_trace_algo' do + it 'records and samples with signed trigger trace (relaxed bucket)' do + sig_key = SecureRandom.random_bytes(8) + headers = make_request_headers(trigger_trace: true, signature: true, signature_key: sig_key) + + sampler = OboeTestSampler.new( + settings: { + sample_rate: 0, + sample_source: SolarWindsAPM::SampleSource::REMOTE, + flags: SolarWindsAPM::Flags::SAMPLE_START | SolarWindsAPM::Flags::TRIGGERED_TRACE, + buckets: { + SolarWindsAPM::BucketType::DEFAULT => { capacity: 100, rate: 10 }, + SolarWindsAPM::BucketType::TRIGGER_RELAXED => { capacity: 100, rate: 10 }, + SolarWindsAPM::BucketType::TRIGGER_STRICT => { capacity: 100, rate: 10 } + }, + timestamp: Time.now.to_i, + ttl: 120, + signature_key: sig_key + }, + local_settings: { tracing_mode: SolarWindsAPM::TracingMode::ALWAYS, trigger_mode: :enabled }, + request_headers: headers + ) + + parent = make_span({ remote: true, sampled: false }) + params = make_sample_params(parent: parent) + + result = sampler.should_sample?(params) + assert_equal TEST_OTEL_SAMPLING_DECISION::RECORD_AND_SAMPLE, result.instance_variable_get(:@decision) + assert_equal true, result.attributes['TriggeredTrace'] + assert_includes result.tracestate['xtrace_options_response'], 'trigger-trace:ok' + end + + it 'records and samples with unsigned trigger trace (strict bucket)' do + headers = make_request_headers(trigger_trace: true) + + sampler = OboeTestSampler.new( + settings: { + sample_rate: 0, + sample_source: SolarWindsAPM::SampleSource::REMOTE, + flags: SolarWindsAPM::Flags::SAMPLE_START | SolarWindsAPM::Flags::TRIGGERED_TRACE, + buckets: { + SolarWindsAPM::BucketType::DEFAULT => { capacity: 100, rate: 10 }, + SolarWindsAPM::BucketType::TRIGGER_RELAXED => { capacity: 100, rate: 10 }, + SolarWindsAPM::BucketType::TRIGGER_STRICT => { capacity: 100, rate: 10 } + }, + timestamp: Time.now.to_i, + ttl: 120, + signature_key: nil + }, + local_settings: { tracing_mode: SolarWindsAPM::TracingMode::ALWAYS, trigger_mode: :enabled }, + request_headers: headers + ) + + parent = make_span({ remote: true, sampled: false }) + params = make_sample_params(parent: parent) + + result = sampler.should_sample?(params) + assert_equal TEST_OTEL_SAMPLING_DECISION::RECORD_AND_SAMPLE, result.instance_variable_get(:@decision) + assert_equal true, result.attributes['TriggeredTrace'] + end + + it 'records only when TRIGGERED_TRACE flag unset' do + headers = make_request_headers(trigger_trace: true) + + sampler = OboeTestSampler.new( + settings: { + sample_rate: 0, + sample_source: SolarWindsAPM::SampleSource::REMOTE, + flags: SolarWindsAPM::Flags::SAMPLE_START, + buckets: {}, + timestamp: Time.now.to_i, + ttl: 120, + signature_key: nil + }, + local_settings: { tracing_mode: SolarWindsAPM::TracingMode::ALWAYS }, + request_headers: headers + ) + + parent = make_span({ remote: true, sampled: false }) + params = make_sample_params(parent: parent) + + result = sampler.should_sample?(params) + assert_equal TEST_OTEL_SAMPLING_DECISION::RECORD_ONLY, result.instance_variable_get(:@decision) + assert_includes result.tracestate['xtrace_options_response'], 'trigger-trace:trigger-tracing-disabled' + end + + it 'records only when trigger trace bucket rate exceeded' do + headers = make_request_headers(trigger_trace: true) + + sampler = OboeTestSampler.new( + settings: { + sample_rate: 0, + sample_source: SolarWindsAPM::SampleSource::REMOTE, + flags: SolarWindsAPM::Flags::SAMPLE_START | SolarWindsAPM::Flags::TRIGGERED_TRACE, + buckets: { + SolarWindsAPM::BucketType::DEFAULT => { capacity: 0, rate: 0 }, + SolarWindsAPM::BucketType::TRIGGER_RELAXED => { capacity: 0, rate: 0 }, + SolarWindsAPM::BucketType::TRIGGER_STRICT => { capacity: 0, rate: 0 } + }, + timestamp: Time.now.to_i, + ttl: 120, + signature_key: nil + }, + local_settings: { tracing_mode: SolarWindsAPM::TracingMode::ALWAYS, trigger_mode: :enabled }, + request_headers: headers + ) + + parent = make_span({ remote: true, sampled: false }) + params = make_sample_params(parent: parent) + + result = sampler.should_sample?(params) + assert_equal TEST_OTEL_SAMPLING_DECISION::RECORD_ONLY, result.instance_variable_get(:@decision) + assert_includes result.tracestate['xtrace_options_response'], 'trigger-trace:rate-exceeded' + end + end + + describe 'dice_roll_algo' do + it 'records and samples when dice roll succeeds and bucket has capacity' do + sampler = OboeTestSampler.new( + settings: { + sample_rate: 1_000_000, + sample_source: SolarWindsAPM::SampleSource::REMOTE, + flags: SolarWindsAPM::Flags::SAMPLE_START | SolarWindsAPM::Flags::SAMPLE_THROUGH_ALWAYS, + buckets: { + SolarWindsAPM::BucketType::DEFAULT => { capacity: 100, rate: 10 }, + SolarWindsAPM::BucketType::TRIGGER_RELAXED => { capacity: 100, rate: 10 }, + SolarWindsAPM::BucketType::TRIGGER_STRICT => { capacity: 100, rate: 10 } + }, + timestamp: Time.now.to_i, + ttl: 120, + signature_key: nil + }, + local_settings: { tracing_mode: SolarWindsAPM::TracingMode::ALWAYS }, + request_headers: {} + ) + + parent = make_span({ remote: true, sampled: false }) + params = make_sample_params(parent: parent) + + result = sampler.should_sample?(params) + assert_equal TEST_OTEL_SAMPLING_DECISION::RECORD_AND_SAMPLE, result.instance_variable_get(:@decision) + assert_equal 1_000_000, result.attributes['SampleRate'] + assert_equal SolarWindsAPM::SampleSource::REMOTE, result.attributes['SampleSource'] + end + + it 'records only when dice roll succeeds but bucket exhausted' do + sampler = OboeTestSampler.new( + settings: { + sample_rate: 1_000_000, + sample_source: SolarWindsAPM::SampleSource::REMOTE, + flags: SolarWindsAPM::Flags::SAMPLE_START | SolarWindsAPM::Flags::SAMPLE_THROUGH_ALWAYS, + buckets: { + SolarWindsAPM::BucketType::DEFAULT => { capacity: 0, rate: 0 }, + SolarWindsAPM::BucketType::TRIGGER_RELAXED => { capacity: 100, rate: 10 }, + SolarWindsAPM::BucketType::TRIGGER_STRICT => { capacity: 100, rate: 10 } + }, + timestamp: Time.now.to_i, + ttl: 120, + signature_key: nil + }, + local_settings: { tracing_mode: SolarWindsAPM::TracingMode::ALWAYS }, + request_headers: {} + ) + + parent = make_span({ remote: true, sampled: false }) + params = make_sample_params(parent: parent) + + result = sampler.should_sample?(params) + assert_equal TEST_OTEL_SAMPLING_DECISION::RECORD_ONLY, result.instance_variable_get(:@decision) + end + + it 'records only when dice roll fails' do + sampler = OboeTestSampler.new( + settings: { + sample_rate: 0, + sample_source: SolarWindsAPM::SampleSource::REMOTE, + flags: SolarWindsAPM::Flags::SAMPLE_START | SolarWindsAPM::Flags::SAMPLE_THROUGH_ALWAYS, + buckets: { + SolarWindsAPM::BucketType::DEFAULT => { capacity: 100, rate: 10 }, + SolarWindsAPM::BucketType::TRIGGER_RELAXED => { capacity: 100, rate: 10 }, + SolarWindsAPM::BucketType::TRIGGER_STRICT => { capacity: 100, rate: 10 } + }, + timestamp: Time.now.to_i, + ttl: 120, + signature_key: nil + }, + local_settings: { tracing_mode: SolarWindsAPM::TracingMode::ALWAYS }, + request_headers: {} + ) + + parent = make_span({ remote: true, sampled: false }) + params = make_sample_params(parent: parent) + + result = sampler.should_sample?(params) + assert_equal TEST_OTEL_SAMPLING_DECISION::RECORD_ONLY, result.instance_variable_get(:@decision) + end + end + + describe 'disabled_algo' do + it 'drops when SAMPLE_THROUGH_ALWAYS unset' do + sampler = OboeTestSampler.new( + settings: { + sample_rate: 0, + sample_source: SolarWindsAPM::SampleSource::REMOTE, + flags: SolarWindsAPM::Flags::OK, + buckets: {}, + timestamp: Time.now.to_i, + ttl: 120, + signature_key: nil + }, + local_settings: { tracing_mode: SolarWindsAPM::TracingMode::NEVER }, + request_headers: {} + ) + + parent = make_span({ remote: true, sampled: false }) + params = make_sample_params(parent: parent) + + result = sampler.should_sample?(params) + assert_equal TEST_OTEL_SAMPLING_DECISION::DROP, result.instance_variable_get(:@decision) + end + + it 'records only when SAMPLE_THROUGH_ALWAYS set' do + sampler = OboeTestSampler.new( + settings: { + sample_rate: 0, + sample_source: SolarWindsAPM::SampleSource::REMOTE, + flags: SolarWindsAPM::Flags::SAMPLE_THROUGH_ALWAYS, + buckets: {}, + timestamp: Time.now.to_i, + ttl: 120, + signature_key: nil + }, + local_settings: { tracing_mode: nil }, + request_headers: {} + ) + + parent = make_span({ remote: true, sampled: false }) + params = make_sample_params(parent: parent) + + result = sampler.should_sample?(params) + assert_equal TEST_OTEL_SAMPLING_DECISION::RECORD_ONLY, result.instance_variable_get(:@decision) + end + + it 'reports tracing disabled for trigger trace in disabled algo' do + headers = make_request_headers(trigger_trace: true) + + sampler = OboeTestSampler.new( + settings: { + sample_rate: 0, + sample_source: SolarWindsAPM::SampleSource::REMOTE, + flags: SolarWindsAPM::Flags::OK, + buckets: {}, + timestamp: Time.now.to_i, + ttl: 120, + signature_key: nil + }, + local_settings: { tracing_mode: SolarWindsAPM::TracingMode::NEVER }, + request_headers: headers + ) + + parent = make_span({ remote: true, sampled: false }) + params = make_sample_params(parent: parent) + + result = sampler.should_sample?(params) + assert_equal TEST_OTEL_SAMPLING_DECISION::DROP, result.instance_variable_get(:@decision) + assert_includes result.tracestate['xtrace_options_response'], 'trigger-trace:tracing-disabled' + end + end + + describe 'settings management' do + it 'drops when settings are unavailable' do + sampler = OboeTestSampler.new( + local_settings: { tracing_mode: SolarWindsAPM::TracingMode::ALWAYS }, + request_headers: {} + ) + + parent = make_span({ remote: true, sampled: false }) + params = make_sample_params(parent: parent) + + result = sampler.should_sample?(params) + assert_equal TEST_OTEL_SAMPLING_DECISION::DROP, result.instance_variable_get(:@decision) + end + + it 'reports settings not available for trigger trace when settings missing' do + headers = make_request_headers(trigger_trace: true) + + sampler = OboeTestSampler.new( + local_settings: { tracing_mode: SolarWindsAPM::TracingMode::ALWAYS }, + request_headers: headers + ) + + parent = make_span({ remote: true, sampled: false }) + params = make_sample_params(parent: parent) + + result = sampler.should_sample?(params) + assert_equal TEST_OTEL_SAMPLING_DECISION::DROP, result.instance_variable_get(:@decision) + assert_includes result.tracestate['xtrace_options_response'], 'trigger-trace:settings-not-available' + end + + it 'expires old settings' do + sampler = OboeTestSampler.new( + settings: { + sample_rate: 1_000_000, + sample_source: SolarWindsAPM::SampleSource::REMOTE, + flags: SolarWindsAPM::Flags::SAMPLE_START | SolarWindsAPM::Flags::SAMPLE_THROUGH_ALWAYS, + buckets: {}, + timestamp: Time.now.to_i - 200, + ttl: 10, + signature_key: nil + }, + local_settings: { tracing_mode: SolarWindsAPM::TracingMode::ALWAYS }, + request_headers: {} + ) + + parent = make_span({ remote: true, sampled: false }) + params = make_sample_params(parent: parent) + + result = sampler.should_sample?(params) + assert_equal TEST_OTEL_SAMPLING_DECISION::DROP, result.instance_variable_get(:@decision) + end + + it 'rejects older settings' do + sampler = OboeTestSampler.new( + settings: { + sample_rate: 1_000_000, + sample_source: SolarWindsAPM::SampleSource::REMOTE, + flags: SolarWindsAPM::Flags::SAMPLE_START, + buckets: {}, + timestamp: Time.now.to_i, + ttl: 120, + signature_key: nil + }, + local_settings: { tracing_mode: SolarWindsAPM::TracingMode::ALWAYS }, + request_headers: {} + ) + + # Attempt to update with older timestamp + sampler.update_settings({ + sample_rate: 0, + sample_source: SolarWindsAPM::SampleSource::LOCAL_DEFAULT, + flags: SolarWindsAPM::Flags::OK, + buckets: {}, + timestamp: 1, + ttl: 1, + signature_key: nil + }) + + parent = make_span({ remote: true, sampled: false }) + params = make_sample_params(parent: parent) + + result = sampler.should_sample?(params) + # Should still use the original settings (SAMPLE_START set) + assert_equal TEST_OTEL_SAMPLING_DECISION::RECORD_ONLY, result.instance_variable_get(:@decision) + end + end + + describe 'X-Trace-Options with sw-keys and custom keys' do + it 'sets sw_keys and custom attributes' do + headers = make_request_headers(trigger_trace: true, kvs: { 'sw-keys' => 'check-id:123,website:google', 'custom-key1' => 'value1' }) + + sampler = OboeTestSampler.new( + settings: { + sample_rate: 0, + sample_source: SolarWindsAPM::SampleSource::REMOTE, + flags: SolarWindsAPM::Flags::SAMPLE_START | SolarWindsAPM::Flags::TRIGGERED_TRACE, + buckets: { + SolarWindsAPM::BucketType::DEFAULT => { capacity: 100, rate: 10 }, + SolarWindsAPM::BucketType::TRIGGER_RELAXED => { capacity: 100, rate: 10 }, + SolarWindsAPM::BucketType::TRIGGER_STRICT => { capacity: 100, rate: 10 } + }, + timestamp: Time.now.to_i, + ttl: 120, + signature_key: nil + }, + local_settings: { tracing_mode: SolarWindsAPM::TracingMode::ALWAYS, trigger_mode: :enabled }, + request_headers: headers + ) + + parent = make_span({ remote: true, sampled: false }) + params = make_sample_params(parent: parent) + + result = sampler.should_sample?(params) + assert_equal 'check-id:123,website:google', result.attributes['SWKeys'] + assert_equal 'value1', result.attributes['custom-key1'] + end + + it 'sets not-requested trigger trace when no trigger-trace header' do + headers = make_request_headers(kvs: { 'sw-keys' => 'check-id:123' }) + + sampler = OboeTestSampler.new( + settings: { + sample_rate: 1_000_000, + sample_source: SolarWindsAPM::SampleSource::REMOTE, + flags: SolarWindsAPM::Flags::SAMPLE_START | SolarWindsAPM::Flags::SAMPLE_THROUGH_ALWAYS, + buckets: { + SolarWindsAPM::BucketType::DEFAULT => { capacity: 100, rate: 10 }, + SolarWindsAPM::BucketType::TRIGGER_RELAXED => { capacity: 100, rate: 10 }, + SolarWindsAPM::BucketType::TRIGGER_STRICT => { capacity: 100, rate: 10 } + }, + timestamp: Time.now.to_i, + ttl: 120, + signature_key: nil + }, + local_settings: { tracing_mode: SolarWindsAPM::TracingMode::ALWAYS }, + request_headers: headers + ) + + parent = make_span({ remote: true, sampled: false }) + params = make_sample_params(parent: parent) + + result = sampler.should_sample?(params) + assert_includes result.tracestate['xtrace_options_response'], 'trigger-trace:not-requested' + end + end + + describe 'generate_new_tracestate' do + it 'creates new tracestate for invalid parent' do + sampler = OboeTestSampler.new( + settings: { + sample_rate: 1_000_000, + sample_source: SolarWindsAPM::SampleSource::REMOTE, + flags: SolarWindsAPM::Flags::SAMPLE_START | SolarWindsAPM::Flags::SAMPLE_THROUGH_ALWAYS, + buckets: { + SolarWindsAPM::BucketType::DEFAULT => { capacity: 100, rate: 10 }, + SolarWindsAPM::BucketType::TRIGGER_RELAXED => { capacity: 100, rate: 10 }, + SolarWindsAPM::BucketType::TRIGGER_STRICT => { capacity: 100, rate: 10 } + }, + timestamp: Time.now.to_i, + ttl: 120, + signature_key: nil + }, + local_settings: { tracing_mode: SolarWindsAPM::TracingMode::ALWAYS }, + request_headers: {} + ) + + # ROOT span (no valid parent) + params = make_sample_params(parent: nil) + + result = sampler.should_sample?(params) + refute_nil result.tracestate + assert result.tracestate['sw'] + end + + it 'updates existing tracestate for valid parent' do + sampler = OboeTestSampler.new( + settings: { + sample_rate: 1_000_000, + sample_source: SolarWindsAPM::SampleSource::REMOTE, + flags: SolarWindsAPM::Flags::SAMPLE_START | SolarWindsAPM::Flags::SAMPLE_THROUGH_ALWAYS, + buckets: { + SolarWindsAPM::BucketType::DEFAULT => { capacity: 100, rate: 10 }, + SolarWindsAPM::BucketType::TRIGGER_RELAXED => { capacity: 100, rate: 10 }, + SolarWindsAPM::BucketType::TRIGGER_STRICT => { capacity: 100, rate: 10 } + }, + timestamp: Time.now.to_i, + ttl: 120, + signature_key: nil + }, + local_settings: { tracing_mode: SolarWindsAPM::TracingMode::ALWAYS }, + request_headers: {} + ) + + parent = make_span({ remote: true, sampled: true, sw: true }) + params = make_sample_params(parent: parent) + + result = sampler.should_sample?(params) + refute_nil result.tracestate['sw'] + end + end + + describe 'sw_from_span_and_decision' do + it 'formats span_id-01 for RECORD_AND_SAMPLE' do + sampler = OboeTestSampler.new( + local_settings: {}, + request_headers: {} + ) + parent = make_span({ remote: true, sampled: true }) + decision = TEST_OTEL_SAMPLING_DECISION::RECORD_AND_SAMPLE + result = sampler.sw_from_span_and_decision(parent, decision) + assert result.end_with?('-01') + end + + it 'formats span_id-00 for DROP' do + sampler = OboeTestSampler.new( + local_settings: {}, + request_headers: {} + ) + parent = make_span({ remote: true, sampled: false }) + decision = TEST_OTEL_SAMPLING_DECISION::DROP + result = sampler.sw_from_span_and_decision(parent, decision) + assert result.end_with?('-00') + end + end +end diff --git a/test/sampling/sampler_test.rb b/test/sampling/sampler_test.rb index b2967f8b..0a09bd10 100644 --- a/test/sampling/sampler_test.rb +++ b/test/sampling/sampler_test.rb @@ -466,3 +466,304 @@ def replace_sampler(sampler) end end end + +describe 'Sampler settings parsing, tracing mode resolution, HTTP metadata, and readiness' do + describe 'parse_settings' do + before do + @sampler = TestSampler.new({ local_settings: {} }) + end + + it 'parses valid settings with all fields' do + unparsed = { + 'value' => 500_000, + 'timestamp' => Time.now.to_i, + 'ttl' => 120, + 'flags' => 'SAMPLE_START,SAMPLE_THROUGH_ALWAYS,TRIGGER_TRACE,OVERRIDE', + 'arguments' => { + 'BucketCapacity' => 100, + 'BucketRate' => 10, + 'TriggerRelaxedBucketCapacity' => 50, + 'TriggerRelaxedBucketRate' => 5, + 'TriggerStrictBucketCapacity' => 25, + 'TriggerStrictBucketRate' => 2, + 'SignatureKey' => 'test-key' + }, + 'warning' => 'Test warning message' + } + + result = @sampler.parse_settings(unparsed) + refute_nil result + assert_equal 500_000, result[:sample_rate] + assert_equal SolarWindsAPM::SampleSource::REMOTE, result[:sample_source] + assert result[:flags].anybits?(SolarWindsAPM::Flags::SAMPLE_START) + assert result[:flags].anybits?(SolarWindsAPM::Flags::SAMPLE_THROUGH_ALWAYS) + assert result[:flags].anybits?(SolarWindsAPM::Flags::TRIGGERED_TRACE) + assert result[:flags].anybits?(SolarWindsAPM::Flags::OVERRIDE) + assert_equal 'test-key', result[:signature_key] + assert_equal 'Test warning message', result[:warning] + assert_equal({ capacity: 100, rate: 10 }, result[:buckets][SolarWindsAPM::BucketType::DEFAULT]) + assert_equal({ capacity: 50, rate: 5 }, result[:buckets]['trigger_relaxed']) + assert_equal({ capacity: 25, rate: 2 }, result[:buckets]['trigger_strict']) + end + + it 'returns nil for non-hash input' do + assert_nil @sampler.parse_settings('not a hash') + assert_nil @sampler.parse_settings(nil) + assert_nil @sampler.parse_settings(42) + end + + it 'returns nil for missing numeric fields' do + assert_nil @sampler.parse_settings({ 'value' => 'not_a_number', 'timestamp' => 1, 'ttl' => 1, 'flags' => 'SAMPLE_START' }) + assert_nil @sampler.parse_settings({ 'value' => 1, 'timestamp' => 'bad', 'ttl' => 1, 'flags' => 'SAMPLE_START' }) + assert_nil @sampler.parse_settings({ 'value' => 1, 'timestamp' => 1, 'ttl' => 'bad', 'flags' => 'SAMPLE_START' }) + end + + it 'returns nil for non-string flags' do + assert_nil @sampler.parse_settings({ 'value' => 1, 'timestamp' => 1, 'ttl' => 1, 'flags' => 123 }) + end + + it 'handles unknown flags gracefully' do + result = @sampler.parse_settings({ 'value' => 1, 'timestamp' => 1, 'ttl' => 1, 'flags' => 'UNKNOWN_FLAG' }) + refute_nil result + assert_equal SolarWindsAPM::Flags::OK, result[:flags] + end + + it 'handles empty arguments hash' do + result = @sampler.parse_settings({ + 'value' => 1, + 'timestamp' => 1, + 'ttl' => 1, + 'flags' => 'SAMPLE_START', + 'arguments' => {} + }) + refute_nil result + assert_empty result[:buckets] + assert_nil result[:signature_key] + end + + it 'handles non-hash arguments' do + result = @sampler.parse_settings({ + 'value' => 1, + 'timestamp' => 1, + 'ttl' => 1, + 'flags' => 'SAMPLE_START', + 'arguments' => 'not_a_hash' + }) + refute_nil result + assert_empty result[:buckets] + end + + it 'parses settings without warning' do + result = @sampler.parse_settings({ + 'value' => 1, + 'timestamp' => 1, + 'ttl' => 1, + 'flags' => 'SAMPLE_START' + }) + refute_nil result + assert_nil result[:warning] + end + end + + describe 'update_settings' do + before do + @sampler = TestSampler.new({ local_settings: {} }) + end + + it 'updates with valid settings and returns parsed' do + result = @sampler.update_settings({ + 'value' => 500_000, + 'timestamp' => Time.now.to_i, + 'ttl' => 120, + 'flags' => 'SAMPLE_START,SAMPLE_THROUGH_ALWAYS' + }) + refute_nil result + assert_equal 500_000, result[:sample_rate] + end + + it 'returns nil for invalid settings' do + result = @sampler.update_settings('invalid') + assert_nil result + end + + it 'logs warning from parsed settings' do + result = @sampler.update_settings({ + 'value' => 1, + 'timestamp' => Time.now.to_i, + 'ttl' => 120, + 'flags' => 'SAMPLE_START', + 'warning' => 'Some warning' + }) + refute_nil result + assert_equal 'Some warning', result[:warning] + end + end + + describe 'resolve_tracing_mode' do + it 'returns ALWAYS when tracing_mode is true' do + sampler = TestSampler.new({ local_settings: { tracing_mode: true } }) + assert_equal SolarWindsAPM::TracingMode::ALWAYS, sampler.instance_variable_get(:@tracing_mode) + end + + it 'returns NEVER when tracing_mode is false' do + sampler = TestSampler.new({ local_settings: { tracing_mode: false } }) + assert_equal SolarWindsAPM::TracingMode::NEVER, sampler.instance_variable_get(:@tracing_mode) + end + + it 'returns nil when tracing_mode not in config' do + sampler = TestSampler.new({ local_settings: {} }) + assert_nil sampler.instance_variable_get(:@tracing_mode) + end + + it 'returns nil when tracing_mode is nil' do + sampler = TestSampler.new({ local_settings: { tracing_mode: nil } }) + assert_nil sampler.instance_variable_get(:@tracing_mode) + end + end + + describe 'local_settings with transaction_settings' do + it 'uses default settings when no transaction_settings configured' do + sampler = TestSampler.new({ local_settings: { tracing_mode: true } }) + params = make_sample_params + settings = sampler.local_settings(params) + assert_equal SolarWindsAPM::TracingMode::ALWAYS, settings[:tracing_mode] + end + + it 'applies transaction settings filter for http spans' do + SolarWindsAPM::Config[:tracing_mode] = :enabled + SolarWindsAPM::Config[:transaction_settings] = [ + { regexp: '/health', tracing: :disabled } + ] + + sampler = TestSampler.new({ + local_settings: { + tracing_mode: true, + transaction_settings: SolarWindsAPM::Config[:transaction_settings] + } + }) + + attrs = { + 'http.request.method' => 'GET', + 'url.scheme' => 'https', + 'server.address' => 'localhost', + 'url.path' => '/health' + } + params = make_sample_params(kind: OpenTelemetry::Trace::SpanKind::SERVER) + params[:attributes] = attrs + + settings = sampler.local_settings(params) + refute_nil settings[:tracing_mode] + ensure + SolarWindsAPM::Config[:transaction_settings] = nil + end + end + + describe 'http_span_metadata' do + before do + @sampler = TestSampler.new({ local_settings: {} }) + end + + it 'returns http false for non-server spans' do + result = @sampler.http_span_metadata(OpenTelemetry::Trace::SpanKind::CLIENT, + { 'http.request.method' => 'GET' }) + assert_equal({ http: false }, result) + end + + it 'returns http false for server spans without http method' do + result = @sampler.http_span_metadata(OpenTelemetry::Trace::SpanKind::SERVER, + { 'network.transport' => 'udp' }) + assert_equal({ http: false }, result) + end + + it 'returns full metadata for new semconv server http spans' do + attrs = { + 'http.request.method' => 'POST', + 'http.response.status_code' => 201, + 'url.scheme' => 'https', + 'server.address' => 'example.com', + 'url.path' => '/api/v1/items' + } + + result = @sampler.http_span_metadata(OpenTelemetry::Trace::SpanKind::SERVER, attrs) + assert result[:http] + assert_equal 'POST', result[:method] + assert_equal 201, result[:status] + assert_equal 'https', result[:scheme] + assert_equal 'example.com', result[:hostname] + assert_equal '/api/v1/items', result[:path] + assert_equal 'https://example.com/api/v1/items', result[:url] + end + + it 'returns full metadata for old semconv server http spans' do + attrs = { + OpenTelemetry::SemanticConventions::Trace::HTTP_METHOD => 'GET', + OpenTelemetry::SemanticConventions::Trace::HTTP_STATUS_CODE => 200, + OpenTelemetry::SemanticConventions::Trace::HTTP_SCHEME => 'http', + OpenTelemetry::SemanticConventions::Trace::NET_HOST_NAME => 'old.example.com', + OpenTelemetry::SemanticConventions::Trace::HTTP_TARGET => '/old/path' + } + + result = @sampler.http_span_metadata(OpenTelemetry::Trace::SpanKind::SERVER, attrs) + assert result[:http] + assert_equal 'GET', result[:method] + assert_equal 200, result[:status] + end + + it 'uses defaults when attributes are missing' do + attrs = { 'http.request.method' => 'GET' } + result = @sampler.http_span_metadata(OpenTelemetry::Trace::SpanKind::SERVER, attrs) + assert result[:http] + assert_equal 'http', result[:scheme] + assert_equal 'localhost', result[:hostname] + assert_equal 0, result[:status] + end + end + + describe 'wait_until_ready' do + it 'returns false on timeout when no settings' do + sampler = TestSampler.new({ local_settings: {} }) + result = sampler.wait_until_ready(0.1) + refute result + end + + it 'returns true when settings are available with signature_key' do + sampler = TestSampler.new({ + local_settings: {}, + settings: { + 'value' => 1_000_000, + 'timestamp' => Time.now.to_i, + 'ttl' => 120, + 'flags' => 'SAMPLE_START', + 'arguments' => { 'SignatureKey' => 'test-key' } + } + }) + result = sampler.wait_until_ready(1) + assert result + end + end + + describe 'request_headers' do + it 'extracts trace options from parent context' do + sampler = TestSampler.new({ local_settings: {} }) + context = OpenTelemetry::Context.empty + context = context.set_value('sw_xtraceoptions', 'trigger-trace;ts=12345') + context = context.set_value('sw_signature', 'abc123') + + params = { parent_context: context } + headers = sampler.request_headers(params) + + assert_equal 'trigger-trace;ts=12345', headers['X-Trace-Options'] + assert_equal 'abc123', headers['X-Trace-Options-Signature'] + end + + it 'returns nil values when context has no trace options' do + sampler = TestSampler.new({ local_settings: {} }) + context = OpenTelemetry::Context.empty + params = { parent_context: context } + headers = sampler.request_headers(params) + + assert_nil headers['X-Trace-Options'] + assert_nil headers['X-Trace-Options-Signature'] + end + end +end diff --git a/test/sampling/sampling_patch_test.rb b/test/sampling/sampling_patch_test.rb new file mode 100644 index 00000000..aa6f7a7e --- /dev/null +++ b/test/sampling/sampling_patch_test.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +# Copyright (c) 2025 SolarWinds, LLC. +# All rights reserved. + +require 'minitest_helper' +require_relative '../../lib/solarwinds_apm/config' +require './lib/solarwinds_apm/sampling/sampling_patch' + +describe 'MetricsExporter::Patch#export returns SUCCESS for empty data points' do + it 'returns SUCCESS when all metrics have empty data_points' do + metric1 = Minitest::Mock.new + metric1.expect(:data_points, []) + + metrics = [metric1] + # After reject!, empty metrics remain so we need the exporter + # The patch calls super if any data_points are present + # When all are empty it should return SUCCESS + exporter = ::OpenTelemetry::Exporter::OTLP::Metrics::MetricsExporter.new + result = exporter.export([]) + assert_equal ::OpenTelemetry::SDK::Metrics::Export::SUCCESS, result + end +end + +describe 'Span::Patch on_finishing callback and double-finish warning' do + it 'calls on_finishing on processors that respond to it' do + OpenTelemetry::SDK.configure + + tracer = OpenTelemetry.tracer_provider.tracer('test') + span = nil + tracer.in_span('test_span') do |s| + span = s + end + # Span should have finished + refute_nil span + end + + it 'warns on double finish of span' do + OpenTelemetry::SDK.configure + + tracer = OpenTelemetry.tracer_provider.tracer('test') + span = nil + tracer.in_span('test_span') do |s| + span = s + end + # Calling finish again should just warn + span.finish + end +end diff --git a/test/sampling/settings_test.rb b/test/sampling/settings_test.rb index 2e44078c..5958303e 100644 --- a/test/sampling/settings_test.rb +++ b/test/sampling/settings_test.rb @@ -122,3 +122,173 @@ end end end + +describe 'SamplingSettings.merge remote/local flag merging and tracing mode precedence' do + describe 'merge' do + it 'merges remote and local settings with trigger mode enabled' do + remote = { + sample_rate: 500_000, + sample_source: SolarWindsAPM::SampleSource::REMOTE, + flags: SolarWindsAPM::Flags::SAMPLE_START | SolarWindsAPM::Flags::SAMPLE_THROUGH_ALWAYS, + timestamp: Time.now.to_i, + ttl: 120 + } + local = { tracing_mode: nil, trigger_mode: :enabled } + + result = SolarWindsAPM::SamplingSettings.merge(remote, local) + assert result[:flags].anybits?(SolarWindsAPM::Flags::TRIGGERED_TRACE) + end + + it 'merges remote and local settings with trigger mode disabled' do + remote = { + sample_rate: 500_000, + flags: SolarWindsAPM::Flags::SAMPLE_START | SolarWindsAPM::Flags::TRIGGERED_TRACE, + timestamp: Time.now.to_i, + ttl: 120 + } + local = { tracing_mode: nil, trigger_mode: :disabled } + + result = SolarWindsAPM::SamplingSettings.merge(remote, local) + refute result[:flags].anybits?(SolarWindsAPM::Flags::TRIGGERED_TRACE) + end + + it 'applies OVERRIDE flag from remote' do + remote = { + sample_rate: 500_000, + flags: SolarWindsAPM::Flags::OVERRIDE | SolarWindsAPM::Flags::SAMPLE_START, + timestamp: Time.now.to_i, + ttl: 120 + } + local = { + tracing_mode: SolarWindsAPM::Flags::SAMPLE_START | SolarWindsAPM::Flags::SAMPLE_THROUGH_ALWAYS, + trigger_mode: nil + } + + result = SolarWindsAPM::SamplingSettings.merge(remote, local) + assert result[:flags].anybits?(SolarWindsAPM::Flags::OVERRIDE) + end + + it 'uses local tracing_mode when provided' do + remote = { + sample_rate: 500_000, + flags: SolarWindsAPM::Flags::SAMPLE_START, + timestamp: Time.now.to_i, + ttl: 120 + } + local = { tracing_mode: SolarWindsAPM::TracingMode::NEVER, trigger_mode: nil } + + result = SolarWindsAPM::SamplingSettings.merge(remote, local) + assert_equal SolarWindsAPM::TracingMode::NEVER, result[:flags] + end + + it 'uses remote flags when local tracing_mode is nil' do + remote = { + sample_rate: 500_000, + flags: SolarWindsAPM::Flags::SAMPLE_START | SolarWindsAPM::Flags::SAMPLE_THROUGH_ALWAYS, + timestamp: Time.now.to_i, + ttl: 120 + } + local = { tracing_mode: nil, trigger_mode: nil } + + result = SolarWindsAPM::SamplingSettings.merge(remote, local) + assert result[:flags].anybits?(SolarWindsAPM::Flags::SAMPLE_START) + assert result[:flags].anybits?(SolarWindsAPM::Flags::SAMPLE_THROUGH_ALWAYS) + end + end +end + +describe 'SpanType classification (ROOT/ENTRY/LOCAL) and trace_id/span_id validation' do + describe 'SpanType' do + it 'returns ROOT for nil parent span context' do + result = SolarWindsAPM::SpanType.span_type(nil) + assert_equal SolarWindsAPM::SpanType::ROOT, result + end + + it 'returns ROOT for invalid span context' do + span = OpenTelemetry::Trace::Span::INVALID + result = SolarWindsAPM::SpanType.span_type(span) + assert_equal SolarWindsAPM::SpanType::ROOT, result + end + + it 'returns ENTRY for remote span context' do + span_context = OpenTelemetry::Trace::SpanContext.new( + span_id: Random.bytes(8), + trace_id: Random.bytes(16), + remote: true, + trace_flags: OpenTelemetry::Trace::TraceFlags::SAMPLED + ) + span = OpenTelemetry::Trace.non_recording_span(span_context) + result = SolarWindsAPM::SpanType.span_type(span) + assert_equal SolarWindsAPM::SpanType::ENTRY, result + end + + it 'returns LOCAL for non-remote valid span context' do + span_context = OpenTelemetry::Trace::SpanContext.new( + span_id: Random.bytes(8), + trace_id: Random.bytes(16), + remote: false, + trace_flags: OpenTelemetry::Trace::TraceFlags::SAMPLED + ) + span = OpenTelemetry::Trace.non_recording_span(span_context) + result = SolarWindsAPM::SpanType.span_type(span) + assert_equal SolarWindsAPM::SpanType::LOCAL, result + end + + it 'valid_trace_id? returns true for valid ids' do + assert SolarWindsAPM::SpanType.valid_trace_id?('a' * 32) + assert SolarWindsAPM::SpanType.valid_trace_id?('0123456789abcdef' * 2) + end + + it 'valid_trace_id? returns false for invalid ids' do + refute SolarWindsAPM::SpanType.valid_trace_id?('0' * 32) + refute SolarWindsAPM::SpanType.valid_trace_id?('xyz') + refute SolarWindsAPM::SpanType.valid_trace_id?('short') + end + + it 'valid_span_id? returns true for valid ids' do + assert SolarWindsAPM::SpanType.valid_span_id?('a' * 16) + end + + it 'valid_span_id? returns false for invalid ids' do + refute SolarWindsAPM::SpanType.valid_span_id?('0' * 16) + refute SolarWindsAPM::SpanType.valid_span_id?('xyz') + end + + it 'span_context_valid? checks both trace_id and span_id' do + span_context = OpenTelemetry::Trace::SpanContext.new( + span_id: Random.bytes(8), + trace_id: Random.bytes(16) + ) + assert SolarWindsAPM::SpanType.span_context_valid?(span_context) + end + end + + describe 'Structs' do + it 'TriggerTraceOptions has the right members' do + opts = SolarWindsAPM::TriggerTraceOptions.new(true, 12345, 'keys', {}, [], nil) + assert opts.trigger_trace + assert_equal 12345, opts.timestamp + assert_equal 'keys', opts.sw_keys + end + + it 'TraceOptionsResponse has the right members' do + resp = SolarWindsAPM::TraceOptionsResponse.new('ok', 'ok', %w[a b]) + assert_equal 'ok', resp.auth + assert_equal 'ok', resp.trigger_trace + assert_equal %w[a b], resp.ignored + end + + it 'TokenBucketSettings has defaults' do + tbs = SolarWindsAPM::TokenBucketSettings.new(nil, nil, 'DEFAULT') + assert_nil tbs.capacity + assert_nil tbs.rate + assert_equal 'DEFAULT', tbs.type + end + + it 'SampleState struct works' do + state = SolarWindsAPM::SampleState.new(:drop, {}, {}, {}, 'sw=abc', {}, nil) + assert_equal :drop, state.decision + assert_equal 'sw=abc', state.trace_state + end + end +end diff --git a/test/sampling/trace_options_test.rb b/test/sampling/trace_options_test.rb index 3c4973cd..0ed497f4 100644 --- a/test/sampling/trace_options_test.rb +++ b/test/sampling/trace_options_test.rb @@ -7,18 +7,20 @@ require './lib/solarwinds_apm/sampling/sampling_constants' require './lib/solarwinds_apm/sampling/trace_options' require 'sampling_test_helper' +require 'openssl' +require 'securerandom' describe 'parseTraceOptions' do let(:logger) { Logger.new($STDOUT) } - it 'no key no value' do + it 'returns empty custom and ignored keys for bare equals sign' do header = '=' result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) assert_equal({}, result.custom) _(result.ignored).must_equal [] end - it 'orphan value' do + it 'ignores value without a key prefix' do header = '=value' result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) @@ -35,7 +37,7 @@ _(result.ignored).must_equal [] end - it 'trigger trace no value' do + it 'parses trigger-trace flag with no value' do header = 'trigger-trace=value' result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) @@ -43,7 +45,7 @@ _(result.ignored).must_equal [%w[trigger-trace value]] end - it 'trigger trace duplicate' do + it 'keeps first trigger-trace and ignores duplicate' do header = 'trigger-trace;trigger-trace' result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) @@ -52,7 +54,7 @@ _(result.ignored).must_equal [['trigger-trace', nil]] end - it 'timestamp no value' do + it 'ignores ts key when it has no value' do header = 'ts' result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) @@ -60,7 +62,7 @@ _(result.ignored).must_equal [['ts', nil]] end - it 'timestamp duplicate' do + it 'keeps first timestamp and ignores duplicate' do header = 'ts=1234;ts=5678' result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) @@ -69,7 +71,7 @@ _(result.ignored).must_equal [%w[ts 5678]] end - it 'timestamp invalid' do + it 'ignores non-integer timestamp value' do header = 'ts=value' result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) @@ -77,7 +79,7 @@ _(result.ignored).must_equal [%w[ts value]] end - it 'timestamp float' do + it 'ignores float timestamp as non-integer' do header = 'ts=12.34' result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) @@ -85,7 +87,7 @@ _(result.ignored).must_equal [['ts', '12.34']] end - it 'timestamp trim' do + it 'trims whitespace from timestamp value' do header = 'ts = 1234567890 ' result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) @@ -94,7 +96,7 @@ _(result.ignored).must_equal [] end - it 'sw-keys no value' do + it 'ignores sw-keys when it has no value' do header = 'sw-keys' result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) @@ -102,7 +104,7 @@ _(result.ignored).must_equal [['sw-keys', nil]] end - it 'sw-keys duplicate' do + it 'keeps first sw-keys and ignores duplicate' do header = 'sw-keys=keys1;sw-keys=keys2' result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) @@ -111,7 +113,7 @@ _(result.ignored).must_equal [%w[sw-keys keys2]] end - it 'sw-keys trim' do + it 'trims whitespace from sw-keys value' do header = 'sw-keys= name:value ' result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) @@ -120,7 +122,7 @@ _(result.ignored).must_equal [] end - it 'sw-keys ignore after semi' do + it 'splits sw-keys value at semicolon boundary' do header = 'sw-keys=check-id:check-1013,website-id;booking-demo' result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) @@ -129,7 +131,7 @@ _(result.ignored).must_equal [['booking-demo', nil]] end - it 'custom keys trim' do + it 'trims whitespace from custom key values' do header = 'custom-key= value ' result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) @@ -137,7 +139,7 @@ _(result.ignored).must_equal [] end - it 'custom keys no value' do + it 'ignores custom key without a value' do header = 'custom-key' result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) @@ -145,7 +147,7 @@ _(result.ignored).must_equal [['custom-key', nil]] end - it 'custom keys duplicate' do + it 'keeps first custom key and ignores duplicate' do header = 'custom-key=value1;custom-key=value2' result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) @@ -153,7 +155,7 @@ _(result.ignored).must_equal [%w[custom-key value2]] end - it 'custom keys equals in value' do + it 'preserves equals sign within custom key value' do header = 'custom-key=name=value' result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) @@ -161,7 +163,7 @@ _(result.ignored).must_equal [] end - it 'custom keys spaces in key' do + it 'rejects custom keys with spaces in the key name' do header = 'custom- key=value;custom-ke y=value' result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) @@ -169,7 +171,7 @@ _(result.ignored).must_equal [['custom- key', 'value'], ['custom-ke y', 'value']] end - it 'other ignored' do + it 'ignores unknown non-custom non-reserved keys' do header = 'key=value' result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) @@ -177,7 +179,7 @@ _(result.ignored).must_equal [%w[key value]] end - it 'trim everything' do + it 'trims whitespace from all option types in a complex header' do header = 'trigger-trace ; custom-something=value; custom-OtherThing = other val ; sw-keys = 029734wr70:9wqj21,0d9j1 ; ts = 12345 ; foo = bar' result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) @@ -189,7 +191,7 @@ _(result.ignored).must_equal [%w[foo bar]] end - it 'semi everywhere' do + it 'handles multiple consecutive semicolons gracefully' do header = ';foo=bar;;;custom-something=value_thing;;sw-keys=02973r70:1b2a3;;;;custom-key=val;ts=12345;;;;;;;trigger-trace;;;' result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) @@ -201,7 +203,7 @@ _(result.ignored).must_equal [%w[foo bar]] end - it 'single quotes' do + it 'splits on semicolons inside single-quoted custom values' do header = "trigger-trace;custom-foo='bar;bar';custom-bar=foo" result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) @@ -211,7 +213,7 @@ _(result.ignored).must_equal [["bar'", nil]] end - it 'missing values and semi' do + it 'ignores entries with missing keys between semicolons' do header = ';trigger-trace;custom-something=value_thing;sw-keys=02973r70:9wqj21,0d9j1;1;2;3;4;5;=custom-key=val?;=' result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) @@ -223,45 +225,212 @@ end describe 'stringifyTraceOptionsResponse' do - it 'basic' do + it 'formats auth and trigger-trace status into semicolon-delimited string' do result = SolarWindsAPM::TraceOptions.stringify_trace_options_response(SolarWindsAPM::TraceOptionsResponse.new(SolarWindsAPM::Auth::OK, SolarWindsAPM::TriggerTrace::OK, [])) _(result).must_equal('auth:ok;trigger-trace:ok') end - it 'ignored values' do + it 'appends ignored keys to the response string' do result = SolarWindsAPM::TraceOptions.stringify_trace_options_response(SolarWindsAPM::TraceOptionsResponse.new(SolarWindsAPM::Auth::OK, SolarWindsAPM::TriggerTrace::TRIGGER_TRACING_DISABLED, %w[invalid-key1 invalid_key2])) _(result).must_equal('auth:ok;trigger-trace:trigger-tracing-disabled;ignored:invalid-key1,invalid_key2') end end describe 'validateSignature' do - it 'valid signature' do + it 'returns OK for valid HMAC signature within time window' do result = SolarWindsAPM::TraceOptions.validate_signature('trigger-trace;pd-keys=lo:se,check-id:123;ts=1564597681', '2c1c398c3e6be898f47f74bf74f035903b48b59c', '8mZ98ZnZhhggcsUmdMbS'.b, Time.now.to_i - 60) _(result).must_equal(SolarWindsAPM::Auth::OK) end - it 'invalid signature' do + it 'returns BAD_SIGNATURE for tampered signature' do result = SolarWindsAPM::TraceOptions.validate_signature('trigger-trace;pd-keys=lo:se,check-id:123;ts=1564597681', '2c1c398c3e6be898f47f74bf74f035903b48b59d', '8mZ98ZnZhhggcsUmdMbS'.b, Time.now.to_i - 60) _(result).must_equal(SolarWindsAPM::Auth::BAD_SIGNATURE) end - it 'missing signature key' do + it 'returns NO_SIGNATURE_KEY when key is nil' do result = SolarWindsAPM::TraceOptions.validate_signature('trigger-trace;pd-keys=lo:se,check-id:123;ts=1564597681', '2c1c398c3e6be898f47f74bf74f035903b48b59c', nil, Time.now.to_i - 60) _(result).must_equal(SolarWindsAPM::Auth::NO_SIGNATURE_KEY) end - it 'timestamp past' do + it 'returns BAD_TIMESTAMP when timestamp is too far in the past' do result = SolarWindsAPM::TraceOptions.validate_signature('trigger-trace;pd-keys=lo:se,check-id:123;ts=1564597681', '2c1c398c3e6be898f47f74bf74f035903b48b59c', '8mZ98ZnZhhggcsUmdMbS'.b, Time.now.to_i - 600) _(result).must_equal(SolarWindsAPM::Auth::BAD_TIMESTAMP) end - it 'timestamp future' do + it 'returns BAD_TIMESTAMP when timestamp is in the future' do result = SolarWindsAPM::TraceOptions.validate_signature('trigger-trace;pd-keys=lo:se,check-id:123;ts=1564597681', '2c1c398c3e6be898f47f74bf74f035903b48b59c', '8mZ98ZnZhhggcsUmdMbS'.b, Time.now.to_i + 600) _(result).must_equal(SolarWindsAPM::Auth::BAD_TIMESTAMP) end - it 'missing timestamp' do + it 'returns BAD_TIMESTAMP when timestamp is nil' do result = SolarWindsAPM::TraceOptions.validate_signature('trigger-trace;pd-keys=lo:se,check-id:123;ts=1564597681', '2c1c398c3e6be898f47f74bf74f035903b48b59c', '8mZ98ZnZhhggcsUmdMbS'.b, nil) _(result).must_equal(SolarWindsAPM::Auth::BAD_TIMESTAMP) end end + +describe 'TraceOptions signature validation, response stringification, and header parsing' do + let(:logger) { Logger.new($stdout) } + + describe 'validate_signature' do + it 'returns ok for valid signature' do + key = SecureRandom.random_bytes(16) + header = 'trigger-trace;ts=12345' + timestamp = Time.now.to_i + digest = OpenSSL::HMAC.hexdigest('SHA1', key, header) + + result = SolarWindsAPM::TraceOptions.validate_signature(header, digest, key, timestamp) + assert_equal SolarWindsAPM::Auth::OK, result + end + + it 'returns no-signature-key when key is nil' do + result = SolarWindsAPM::TraceOptions.validate_signature('header', 'sig', nil, Time.now.to_i) + assert_equal SolarWindsAPM::Auth::NO_SIGNATURE_KEY, result + end + + it 'returns bad-timestamp when timestamp is nil' do + result = SolarWindsAPM::TraceOptions.validate_signature('header', 'sig', 'key', nil) + assert_equal SolarWindsAPM::Auth::BAD_TIMESTAMP, result + end + + it 'returns bad-timestamp when timestamp is too old' do + old_timestamp = Time.now.to_i - (6 * 60) + result = SolarWindsAPM::TraceOptions.validate_signature('header', 'sig', 'key', old_timestamp) + assert_equal SolarWindsAPM::Auth::BAD_TIMESTAMP, result + end + + it 'returns bad-signature for wrong signature' do + key = SecureRandom.random_bytes(16) + result = SolarWindsAPM::TraceOptions.validate_signature('header', 'wrong_signature', key, Time.now.to_i) + assert_equal SolarWindsAPM::Auth::BAD_SIGNATURE, result + end + end + + describe 'stringify_trace_options_response' do + it 'returns nil for nil response' do + assert_nil SolarWindsAPM::TraceOptions.stringify_trace_options_response(nil) + end + + it 'stringifies auth and trigger trace' do + response = SolarWindsAPM::TraceOptionsResponse.new('ok', 'ok', []) + result = SolarWindsAPM::TraceOptions.stringify_trace_options_response(response) + assert_includes result, 'auth:ok' + assert_includes result, 'trigger-trace:ok' + end + + it 'stringifies ignored keys' do + response = SolarWindsAPM::TraceOptionsResponse.new(nil, nil, %w[key1 key2]) + result = SolarWindsAPM::TraceOptions.stringify_trace_options_response(response) + assert_includes result, 'ignored:key1,key2' + end + + it 'omits nil fields' do + response = SolarWindsAPM::TraceOptionsResponse.new(nil, 'ok', []) + result = SolarWindsAPM::TraceOptions.stringify_trace_options_response(response) + refute_includes result, 'auth' + assert_includes result, 'trigger-trace:ok' + end + + it 'returns empty string when all nil and empty' do + response = SolarWindsAPM::TraceOptionsResponse.new(nil, nil, []) + result = SolarWindsAPM::TraceOptions.stringify_trace_options_response(response) + assert_equal '', result + end + end + + describe 'numeric_integer?' do + it 'returns true for valid integer strings' do + assert SolarWindsAPM::TraceOptions.numeric_integer?('12345') + assert SolarWindsAPM::TraceOptions.numeric_integer?('-1') + assert SolarWindsAPM::TraceOptions.numeric_integer?('0') + end + + it 'returns false for non-integer strings' do + refute SolarWindsAPM::TraceOptions.numeric_integer?('abc') + refute SolarWindsAPM::TraceOptions.numeric_integer?('12.34') + refute SolarWindsAPM::TraceOptions.numeric_integer?('') + end + end + + describe 'parse_trace_options' do + it 'parses all option types in single header' do + header = "trigger-trace;sw-keys=check-id:123;custom-foo=bar;ts=#{Time.now.to_i}" + result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) + + assert result.trigger_trace + assert_equal 'check-id:123', result.sw_keys + assert_equal 'bar', result.custom['custom-foo'] + refute_nil result.timestamp + end + + it 'ignores duplicate sw-keys' do + header = 'sw-keys=first;sw-keys=second' + result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) + + assert_equal 'first', result.sw_keys + assert_equal 1, result.ignored.size + end + + it 'ignores duplicate custom keys' do + header = 'custom-key=first;custom-key=second' + result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) + + assert_equal 'first', result.custom['custom-key'] + assert_equal 1, result.ignored.size + end + + it 'ignores unknown keys' do + header = 'unknown-key=value' + result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) + + assert_equal 1, result.ignored.size + assert_equal 'unknown-key', result.ignored[0][0] + end + + it 'ignores sw-keys with nil value' do + header = 'sw-keys' + result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) + + assert_nil result.sw_keys + assert_equal 1, result.ignored.size + end + + it 'ignores custom key with nil value' do + header = 'custom-key' + result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) + + assert_empty result.custom + assert_equal 1, result.ignored.size + end + + it 'ignores non-integer timestamp' do + header = 'ts=notanumber' + result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) + + assert_nil result.timestamp + assert_equal 1, result.ignored.size + end + + it 'ignores duplicate timestamps' do + header = 'ts=100;ts=200' + result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) + + assert_equal 100, result.timestamp + assert_equal 1, result.ignored.size + end + + it 'ignores timestamp without value' do + header = 'ts' + result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) + + assert_nil result.timestamp + assert_equal 1, result.ignored.size + end + + it 'handles values with equals signs' do + header = 'custom-key=value=with=equals' + result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) + + assert_equal 'value=with=equals', result.custom['custom-key'] + end + end +end diff --git a/test/solarwinds_apm/config_test.rb b/test/solarwinds_apm/config_test.rb index 6d9e1806..4107d342 100644 --- a/test/solarwinds_apm/config_test.rb +++ b/test/solarwinds_apm/config_test.rb @@ -272,7 +272,7 @@ SolarWindsAPM::Config.initialize end - it 'check default setting' do + it 'initializes with correct default values for all configuration keys' do assert_nil(SolarWindsAPM::Config[:sampling_rate]) assert_nil(SolarWindsAPM::Config[:sample_rate]) _(SolarWindsAPM::Config[:transaction_settings].class).must_equal Array @@ -298,3 +298,366 @@ end end end + +describe 'Config env var precedence, type coercion, deprecated keys, and log level' do + describe 'enable_disable_config' do + it 'uses env var when valid enabled/disabled value' do + original = ENV['SW_APM_TRIGGER_TRACING_MODE'] + ENV['SW_APM_TRIGGER_TRACING_MODE'] = 'disabled' + + SolarWindsAPM::Config[:trigger_tracing_mode] = :enabled + assert_equal :disabled, SolarWindsAPM::Config[:trigger_tracing_mode] + ensure + if original + ENV['SW_APM_TRIGGER_TRACING_MODE'] = original + else + ENV.delete('SW_APM_TRIGGER_TRACING_MODE') + end + end + + it 'uses default for invalid env var' do + original = ENV['SW_APM_TRIGGER_TRACING_MODE'] + ENV['SW_APM_TRIGGER_TRACING_MODE'] = 'invalid_value' + + SolarWindsAPM::Config[:trigger_tracing_mode] = :enabled + assert_equal :enabled, SolarWindsAPM::Config[:trigger_tracing_mode] + ensure + if original + ENV['SW_APM_TRIGGER_TRACING_MODE'] = original + else + ENV.delete('SW_APM_TRIGGER_TRACING_MODE') + end + end + + it 'accepts boolean config with true/false env var' do + original = ENV['SW_APM_TAG_SQL'] + ENV['SW_APM_TAG_SQL'] = 'true' + + SolarWindsAPM::Config[:tag_sql] = false + assert_equal true, SolarWindsAPM::Config[:tag_sql] + ensure + if original + ENV['SW_APM_TAG_SQL'] = original + else + ENV.delete('SW_APM_TAG_SQL') + end + end + + it 'uses default for invalid boolean env var' do + original = ENV['SW_APM_TAG_SQL'] + ENV['SW_APM_TAG_SQL'] = 'invalid_bool' + + SolarWindsAPM::Config[:tag_sql] = true + assert_equal false, SolarWindsAPM::Config[:tag_sql] + ensure + if original + ENV['SW_APM_TAG_SQL'] = original + else + ENV.delete('SW_APM_TAG_SQL') + end + end + + it 'accepts value from code when env var not set' do + original = ENV['SW_APM_TRIGGER_TRACING_MODE'] + ENV.delete('SW_APM_TRIGGER_TRACING_MODE') + + SolarWindsAPM::Config[:trigger_tracing_mode] = :disabled + assert_equal :disabled, SolarWindsAPM::Config[:trigger_tracing_mode] + ensure + if original + ENV['SW_APM_TRIGGER_TRACING_MODE'] = original + else + ENV.delete('SW_APM_TRIGGER_TRACING_MODE') + end + end + + it 'uses default for invalid code value' do + original = ENV['SW_APM_TRIGGER_TRACING_MODE'] + ENV.delete('SW_APM_TRIGGER_TRACING_MODE') + + SolarWindsAPM::Config[:trigger_tracing_mode] = 'invalid_string' + assert_equal :enabled, SolarWindsAPM::Config[:trigger_tracing_mode] + ensure + if original + ENV['SW_APM_TRIGGER_TRACING_MODE'] = original + else + ENV.delete('SW_APM_TRIGGER_TRACING_MODE') + end + end + end + + describe 'true?' do + it 'returns true for string true' do + assert SolarWindsAPM::Config.true?('true') + end + + it 'returns true for string TRUE' do + assert SolarWindsAPM::Config.true?('TRUE') + end + + it 'returns false for string false' do + refute SolarWindsAPM::Config.true?('false') + end + end + + describe 'boolean?' do + it 'returns true for true' do + assert SolarWindsAPM::Config.boolean?(true) + end + + it 'returns true for false' do + assert SolarWindsAPM::Config.boolean?(false) + end + + it 'returns false for string' do + refute SolarWindsAPM::Config.boolean?('true') + end + end + + describe 'symbol?' do + it 'returns true for :enabled' do + assert SolarWindsAPM::Config.symbol?(:enabled) + end + + it 'returns true for :disabled' do + assert SolarWindsAPM::Config.symbol?(:disabled) + end + + it 'returns false for string' do + refute SolarWindsAPM::Config.symbol?('enabled') + end + end + + describe '[]= key handling' do + it 'warns on deprecated sampling_rate' do + original = SolarWindsAPM::Config[:sampling_rate] + SolarWindsAPM::Config[:sampling_rate] = 100 + ensure + SolarWindsAPM::Config.class_variable_get(:@@config)[:sampling_rate] = original + end + + it 'warns on deprecated sample_rate' do + original = SolarWindsAPM::Config[:sample_rate] + SolarWindsAPM::Config[:sample_rate] = 100 + ensure + SolarWindsAPM::Config.class_variable_get(:@@config)[:sample_rate] = original + end + + it 'warns on deprecated ec2_metadata_timeout' do + original = SolarWindsAPM::Config[:ec2_metadata_timeout] + SolarWindsAPM::Config[:ec2_metadata_timeout] = 1000 + ensure + SolarWindsAPM::Config.class_variable_get(:@@config)[:ec2_metadata_timeout] = original + end + + it 'warns on deprecated http_proxy' do + original = SolarWindsAPM::Config[:http_proxy] + SolarWindsAPM::Config[:http_proxy] = 'http://proxy' + ensure + SolarWindsAPM::Config.class_variable_get(:@@config)[:http_proxy] = original + end + + it 'warns on deprecated hostname_alias' do + original = SolarWindsAPM::Config[:hostname_alias] + SolarWindsAPM::Config[:hostname_alias] = 'alias' + ensure + SolarWindsAPM::Config.class_variable_get(:@@config)[:hostname_alias] = original + end + + it 'warns on deprecated log_args' do + original = SolarWindsAPM::Config[:log_args] + SolarWindsAPM::Config[:log_args] = true + ensure + SolarWindsAPM::Config.class_variable_get(:@@config)[:log_args] = original + end + + it 'handles tracing_mode assignment' do + original = ENV['SW_APM_TRIGGER_TRACING_MODE'] + ENV.delete('SW_APM_TRIGGER_TRACING_MODE') + SolarWindsAPM::Config[:tracing_mode] = :enabled + assert_equal :enabled, SolarWindsAPM::Config[:tracing_mode] + ensure + if original + ENV['SW_APM_TRIGGER_TRACING_MODE'] = original + else + ENV.delete('SW_APM_TRIGGER_TRACING_MODE') + end + end + + it 'handles transaction_settings with disabled regexp' do + settings = [{ regexp: '/health', tracing: :disabled }] + SolarWindsAPM::Config[:transaction_settings] = settings + refute_nil SolarWindsAPM::Config[:disabled_regexps] + end + + it 'handles transaction_settings with enabled regexp' do + settings = [{ regexp: '/api', tracing: :enabled }] + SolarWindsAPM::Config[:transaction_settings] = settings + refute_nil SolarWindsAPM::Config[:enabled_regexps] + end + + it 'handles empty transaction_settings' do + SolarWindsAPM::Config[:transaction_settings] = [] + assert_nil SolarWindsAPM::Config[:enabled_regexps] + assert_nil SolarWindsAPM::Config[:disabled_regexps] + end + + it 'handles non-array transaction_settings' do + SolarWindsAPM::Config[:transaction_settings] = 'invalid' + assert_nil SolarWindsAPM::Config[:enabled_regexps] + assert_nil SolarWindsAPM::Config[:disabled_regexps] + end + + it 'handles transaction_settings with Regexp object' do + settings = [{ regexp: /\/health/, tracing: :disabled }] + SolarWindsAPM::Config[:transaction_settings] = settings + refute_nil SolarWindsAPM::Config[:disabled_regexps] + end + + it 'handles transaction_settings with invalid regexp string' do + settings = [{ regexp: '(invalid[', tracing: :disabled }] + SolarWindsAPM::Config[:transaction_settings] = settings + # Invalid regexp is ignored + end + + it 'handles transaction_settings with empty regexp string' do + settings = [{ regexp: '', tracing: :disabled }] + SolarWindsAPM::Config[:transaction_settings] = settings + end + + it 'handles transaction_settings with empty Regexp' do + settings = [{ regexp: Regexp.new(''), tracing: :disabled }] + SolarWindsAPM::Config[:transaction_settings] = settings + end + + it 'handles transaction_settings without tracing key' do + settings = [{ regexp: '/test' }] + SolarWindsAPM::Config[:transaction_settings] = settings + # No tracing key defaults to disabled + end + + it 'handles generic key assignment' do + SolarWindsAPM::Config[:custom_key] = 'custom_value' + assert_equal 'custom_value', SolarWindsAPM::Config[:custom_key] + end + end + + describe 'set_log_level' do + it 'sets logger level based on SW_APM_DEBUG_LEVEL' do + original = ENV['SW_APM_DEBUG_LEVEL'] + ENV['SW_APM_DEBUG_LEVEL'] = '4' + SolarWindsAPM::Config.set_log_level + assert_equal ::Logger::DEBUG, SolarWindsAPM.logger.level + ensure + if original + ENV['SW_APM_DEBUG_LEVEL'] = original + else + ENV.delete('SW_APM_DEBUG_LEVEL') + end + SolarWindsAPM.logger.level = 1 + end + + it 'uses default INFO for unknown log level' do + original_env = ENV['SW_APM_DEBUG_LEVEL'] + original_config = SolarWindsAPM::Config[:debug_level] + ENV['SW_APM_DEBUG_LEVEL'] = '99' + SolarWindsAPM::Config.set_log_level + assert_equal ::Logger::INFO, SolarWindsAPM.logger.level + ensure + if original_env + ENV['SW_APM_DEBUG_LEVEL'] = original_env + else + ENV.delete('SW_APM_DEBUG_LEVEL') + end + SolarWindsAPM::Config[:debug_level] = original_config if original_config + SolarWindsAPM.logger.level = 1 + end + + it 'creates nil logger for level -1' do + original = ENV['SW_APM_DEBUG_LEVEL'] + old_logger = SolarWindsAPM.logger + ENV['SW_APM_DEBUG_LEVEL'] = '-1' + SolarWindsAPM::Config.set_log_level + ensure + if original + ENV['SW_APM_DEBUG_LEVEL'] = original + else + ENV.delete('SW_APM_DEBUG_LEVEL') + end + SolarWindsAPM.logger = old_logger + SolarWindsAPM.logger.level = 1 + end + end + + describe 'config_file_from_env' do + it 'returns nil for non-existent file' do + original = ENV['SW_APM_CONFIG_RUBY'] + ENV['SW_APM_CONFIG_RUBY'] = '/nonexistent/path/file.rb' + result = SolarWindsAPM::Config.config_file_from_env + assert_nil result + ensure + if original + ENV['SW_APM_CONFIG_RUBY'] = original + else + ENV.delete('SW_APM_CONFIG_RUBY') + end + end + + it 'returns file path when file exists' do + require 'tempfile' + tmp = Tempfile.new(['solarwinds_apm_config', '.rb']) + tmp.write("# test config\n") + tmp.close + + original = ENV['SW_APM_CONFIG_RUBY'] + ENV['SW_APM_CONFIG_RUBY'] = tmp.path + result = SolarWindsAPM::Config.config_file_from_env + assert_equal tmp.path, result + ensure + if original + ENV['SW_APM_CONFIG_RUBY'] = original + else + ENV.delete('SW_APM_CONFIG_RUBY') + end + tmp&.unlink + end + + it 'returns file from directory when dir contains config file' do + require 'tmpdir' + dir = Dir.mktmpdir + config_file = File.join(dir, 'solarwinds_apm_config.rb') + File.write(config_file, "# test config\n") + + original = ENV['SW_APM_CONFIG_RUBY'] + ENV['SW_APM_CONFIG_RUBY'] = dir + result = SolarWindsAPM::Config.config_file_from_env + assert_equal config_file, result + ensure + if original + ENV['SW_APM_CONFIG_RUBY'] = original + else + ENV.delete('SW_APM_CONFIG_RUBY') + end + FileUtils.rm_rf(dir) if dir + end + end + + describe 'update! and merge!' do + it 'updates config with hash data' do + SolarWindsAPM::Config.update!({ test_update_key: 'test_value' }) + assert_equal 'test_value', SolarWindsAPM::Config[:test_update_key] + end + + it 'merge! is an alias for update!' do + SolarWindsAPM::Config.merge!({ test_merge_key: 'test_value' }) + assert_equal 'test_value', SolarWindsAPM::Config[:test_merge_key] + end + end + + describe 'print_config' do + it 'prints config without error' do + result = SolarWindsAPM::Config.print_config + assert_nil result + end + end +end diff --git a/test/solarwinds_apm/init_test/init_1_test.rb b/test/solarwinds_apm/init_test/init_1_test.rb index 07497d9d..fd16ed52 100644 --- a/test/solarwinds_apm/init_test/init_1_test.rb +++ b/test/solarwinds_apm/init_test/init_1_test.rb @@ -6,7 +6,7 @@ require 'initest_helper' describe 'solarwinds_apm_init_1' do - it 'SW_APM_ENABLED_set_to_disabled' do + it 'logs disabled message and enters noop mode when SW_APM_ENABLED is false' do log_output = StringIO.new SolarWindsAPM.logger = Logger.new(log_output) diff --git a/test/solarwinds_apm/init_test/init_2_test.rb b/test/solarwinds_apm/init_test/init_2_test.rb index 2875fcf4..8633f478 100644 --- a/test/solarwinds_apm/init_test/init_2_test.rb +++ b/test/solarwinds_apm/init_test/init_2_test.rb @@ -6,7 +6,7 @@ require 'initest_helper' describe 'solarwinds_apm_init_2' do - it 'SW_APM_SERVICE_KEY_is_invalid' do + it 'logs token format error and enters noop mode when service key is invalid' do log_output = StringIO.new SolarWindsAPM.logger = Logger.new(log_output) diff --git a/test/solarwinds_apm/init_test/init_3_test.rb b/test/solarwinds_apm/init_test/init_3_test.rb index 9134f4e1..260e722f 100644 --- a/test/solarwinds_apm/init_test/init_3_test.rb +++ b/test/solarwinds_apm/init_test/init_3_test.rb @@ -6,7 +6,7 @@ require 'initest_helper' describe 'solarwinds_apm_init_3' do - it 'SW_APM_AUTO_CONFIGURE_set_to_false' do + it 'logs message and enters noop mode when SW_APM_AUTO_CONFIGURE is false' do log_output = StringIO.new SolarWindsAPM.logger = Logger.new(log_output) diff --git a/test/solarwinds_apm/init_test/init_4_test.rb b/test/solarwinds_apm/init_test/init_4_test.rb index 27ae3bd6..f15671a5 100644 --- a/test/solarwinds_apm/init_test/init_4_test.rb +++ b/test/solarwinds_apm/init_test/init_4_test.rb @@ -6,7 +6,7 @@ require 'initest_helper' describe 'solarwinds_apm_init_4' do - it 'everything_default' do + it 'logs current version string during default initialization' do log_output = StringIO.new SolarWindsAPM.logger = Logger.new(log_output) diff --git a/test/solarwinds_apm/noop_test.rb b/test/solarwinds_apm/noop_test.rb new file mode 100644 index 00000000..abf7dd49 --- /dev/null +++ b/test/solarwinds_apm/noop_test.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +# Copyright (c) 2025 SolarWinds, LLC. +# All rights reserved. + +require 'minitest_helper' +require_relative '../../lib/solarwinds_apm/config' +require './lib/solarwinds_apm/noop' + +describe 'NoopAPI modules return safe defaults and no-op behavior' do + # We need to test the noop modules individually since they're extended onto SolarWindsAPM::API + # but during tests the real modules may be loaded + + describe 'NoopAPI::Tracing' do + it 'solarwinds_ready? returns false' do + obj = Object.new + obj.extend(NoopAPI::Tracing) + assert_equal false, obj.solarwinds_ready? + end + + it 'solarwinds_ready? accepts integer_response parameter' do + obj = Object.new + obj.extend(NoopAPI::Tracing) + assert_equal false, obj.solarwinds_ready?(5000, integer_response: true) + end + end + + describe 'NoopAPI::CurrentTraceInfo' do + it 'current_trace_info returns TraceInfo' do + obj = Object.new + obj.extend(NoopAPI::CurrentTraceInfo) + trace = obj.current_trace_info + assert_equal '00000000000000000000000000000000', trace.trace_id + assert_equal '0000000000000000', trace.span_id + assert_equal '00', trace.trace_flags + assert_equal '', trace.for_log + assert_equal({}, trace.hash_for_log) + assert_equal :never, trace.do_log + end + end + + describe 'NoopAPI::CustomMetrics' do + it 'increment_metric returns false' do + obj = Object.new + obj.extend(NoopAPI::CustomMetrics) + assert_equal false, obj.increment_metric('test') + end + + it 'summary_metric returns false' do + obj = Object.new + obj.extend(NoopAPI::CustomMetrics) + assert_equal false, obj.summary_metric('test', 1.0) + end + end + + describe 'NoopAPI::OpenTelemetry' do + it 'in_span yields block' do + obj = Object.new + obj.extend(NoopAPI::OpenTelemetry) + result = obj.in_span('test') { 42 } + assert_equal 42, result + end + + it 'in_span returns nil without block' do + obj = Object.new + obj.extend(NoopAPI::OpenTelemetry) + result = obj.in_span('test') + assert_nil result + end + end + + describe 'NoopAPI::TransactionName' do + it 'set_transaction_name returns true' do + obj = Object.new + obj.extend(NoopAPI::TransactionName) + assert_equal true, obj.set_transaction_name('test') + end + end + + describe 'NoopAPI::Tracer' do + it 'add_tracer does nothing' do + obj = Object.new + obj.extend(NoopAPI::Tracer) + result = obj.add_tracer(:foo, 'bar') + assert_nil result + end + end +end diff --git a/test/solarwinds_apm/otel_config_propagator_test.rb b/test/solarwinds_apm/otel_config_propagator_test.rb index b035ec06..c9a43037 100644 --- a/test/solarwinds_apm/otel_config_propagator_test.rb +++ b/test/solarwinds_apm/otel_config_propagator_test.rb @@ -18,7 +18,7 @@ end # propagation in_code testing - it 'test_propagators_with_default' do + it 'configures TraceContext, Baggage, and SolarWinds propagators by default' do SolarWindsAPM::OTelConfig.initialize _(SolarWindsAPM::OTelConfig.class_variable_get(:@@agent_enabled)).must_equal true @@ -29,7 +29,7 @@ end # propagation in_code testing - it 'test_propagators_with_extra_propagators_from_otel' do + it 'appends SolarWinds propagator after OTEL_PROPAGATORS-specified propagators' do ENV['OTEL_PROPAGATORS'] = 'b3,tracecontext,baggage' SolarWindsAPM::OTelConfig.initialize @@ -41,7 +41,7 @@ _(OpenTelemetry.propagation.instance_variable_get(:@propagators)[3].class).must_equal SolarWindsAPM::OpenTelemetry::SolarWindsPropagator::TextMapPropagator end - it 'test_propagators_with_wrong_otel_propagation' do + it 'uses NoopTextMapPropagator for unrecognized OTEL_PROPAGATORS entries' do ENV['OTEL_PROPAGATORS'] = 'tracecontext,baggage,abcd' SolarWindsAPM::OTelConfig.initialize diff --git a/test/solarwinds_apm/otel_config_test.rb b/test/solarwinds_apm/otel_config_test.rb new file mode 100644 index 00000000..a16e6a71 --- /dev/null +++ b/test/solarwinds_apm/otel_config_test.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +# Copyright (c) 2025 SolarWinds, LLC. +# All rights reserved. + +require 'minitest_helper' +require_relative '../../lib/solarwinds_apm/config' +require './lib/solarwinds_apm/otel_config' + +describe 'OTelConfig response propagator resolution, initialization validation, and config accessor' do + describe 'resolve_response_propagator' do + it 'creates new rack setting when none exists' do + config_map = SolarWindsAPM::OTelConfig.class_variable_get(:@@config_map) + original = config_map['OpenTelemetry::Instrumentation::Rack'] + config_map.delete('OpenTelemetry::Instrumentation::Rack') + + SolarWindsAPM::OTelConfig.resolve_response_propagator + + rack_setting = config_map['OpenTelemetry::Instrumentation::Rack'] + refute_nil rack_setting + assert rack_setting[:response_propagators].is_a?(Array) + assert_equal 1, rack_setting[:response_propagators].length + ensure + if original + config_map['OpenTelemetry::Instrumentation::Rack'] = original + else + config_map.delete('OpenTelemetry::Instrumentation::Rack') + end + end + + it 'appends to existing array of response_propagators' do + config_map = SolarWindsAPM::OTelConfig.class_variable_get(:@@config_map) + original = config_map['OpenTelemetry::Instrumentation::Rack'] + + existing_propagator = Object.new + config_map['OpenTelemetry::Instrumentation::Rack'] = { response_propagators: [existing_propagator] } + + SolarWindsAPM::OTelConfig.resolve_response_propagator + + rack_setting = config_map['OpenTelemetry::Instrumentation::Rack'] + assert_equal 2, rack_setting[:response_propagators].length + assert_equal existing_propagator, rack_setting[:response_propagators][0] + ensure + if original + config_map['OpenTelemetry::Instrumentation::Rack'] = original + else + config_map.delete('OpenTelemetry::Instrumentation::Rack') + end + end + + it 'sets response_propagators when nil in existing rack setting' do + config_map = SolarWindsAPM::OTelConfig.class_variable_get(:@@config_map) + original = config_map['OpenTelemetry::Instrumentation::Rack'] + + config_map['OpenTelemetry::Instrumentation::Rack'] = { response_propagators: nil } + + SolarWindsAPM::OTelConfig.resolve_response_propagator + + rack_setting = config_map['OpenTelemetry::Instrumentation::Rack'] + assert rack_setting[:response_propagators].is_a?(Array) + assert_equal 1, rack_setting[:response_propagators].length + ensure + if original + config_map['OpenTelemetry::Instrumentation::Rack'] = original + else + config_map.delete('OpenTelemetry::Instrumentation::Rack') + end + end + + it 'warns when response_propagators is not an Array' do + config_map = SolarWindsAPM::OTelConfig.class_variable_get(:@@config_map) + original = config_map['OpenTelemetry::Instrumentation::Rack'] + + config_map['OpenTelemetry::Instrumentation::Rack'] = { response_propagators: 'not_an_array' } + + SolarWindsAPM::OTelConfig.resolve_response_propagator + + # Should keep original invalid type + rack_setting = config_map['OpenTelemetry::Instrumentation::Rack'] + assert_equal 'not_an_array', rack_setting[:response_propagators] + ensure + if original + config_map['OpenTelemetry::Instrumentation::Rack'] = original + else + config_map.delete('OpenTelemetry::Instrumentation::Rack') + end + end + end + + describe 'initialize_with_config' do + it 'warns and returns when no block given' do + result = SolarWindsAPM::OTelConfig.initialize_with_config + assert_nil result + end + + it 'warns and returns for empty config_map' do + config_map = SolarWindsAPM::OTelConfig.class_variable_get(:@@config_map) + original_map = config_map.dup + + config_map.clear + + result = SolarWindsAPM::OTelConfig.initialize_with_config do |_config| + # intentionally provide nothing + end + + assert_nil result + ensure + SolarWindsAPM::OTelConfig.class_variable_set(:@@config_map, original_map) + end + end + + describe '[] accessor' do + it 'returns value for given key' do + config = SolarWindsAPM::OTelConfig.class_variable_get(:@@config) + config[:test_key_otel] = 'test_value' + assert_equal 'test_value', SolarWindsAPM::OTelConfig[:test_key_otel] + ensure + config.delete(:test_key_otel) + end + + it 'returns nil for missing key' do + assert_nil SolarWindsAPM::OTelConfig[:nonexistent_key_xyz] + end + end + + describe 'agent_enabled' do + it 'returns boolean' do + result = SolarWindsAPM::OTelConfig.agent_enabled + assert [true, false].include?(result) + end + end +end diff --git a/test/support/log_formatters_test.rb b/test/support/log_formatters_test.rb new file mode 100644 index 00000000..91469819 --- /dev/null +++ b/test/support/log_formatters_test.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +# Copyright (c) 2025 SolarWinds, LLC. +# All rights reserved. + +require 'minitest_helper' +require_relative '../../lib/solarwinds_apm/config' +require './lib/solarwinds_apm/api' +require './lib/solarwinds_apm/support/lumberjack_formatter' +require './lib/solarwinds_apm/support/logging_log_event' + +describe 'Lumberjack::LogEntry trace ID injection based on log_traceId config' do + it 'inserts trace id into lumberjack log entry when log_traceId is :always' do + original = SolarWindsAPM::Config[:log_traceId] + SolarWindsAPM::Config[:log_traceId] = :always + + entry = Lumberjack::LogEntry.new(Time.now, Lumberjack::Severity::INFO, 'test message', 'TestProg', Process.pid, nil) + assert_includes entry.message, 'trace_id=' + ensure + SolarWindsAPM::Config[:log_traceId] = original + end + + it 'does not insert trace id when log_traceId is :never' do + original = SolarWindsAPM::Config[:log_traceId] + SolarWindsAPM::Config[:log_traceId] = :never + + entry = Lumberjack::LogEntry.new(Time.now, Lumberjack::Severity::INFO, 'test message', 'TestProg', Process.pid, nil) + refute_includes entry.message, 'trace_id=' + ensure + SolarWindsAPM::Config[:log_traceId] = original + end +end + +describe 'Logging::LogEvent trace ID injection based on log_traceId config' do + it 'inserts trace id into logging log event when log_traceId is :always' do + original = SolarWindsAPM::Config[:log_traceId] + SolarWindsAPM::Config[:log_traceId] = :always + + logger = Logging.logger['test_logger'] + event = Logging::LogEvent.new('test_logger', Logging::LEVELS['info'], 'test log message', false) + assert_includes event.data, 'trace_id=' + ensure + SolarWindsAPM::Config[:log_traceId] = original + end + + it 'does not insert trace id when log_traceId is :never' do + original = SolarWindsAPM::Config[:log_traceId] + SolarWindsAPM::Config[:log_traceId] = :never + + event = Logging::LogEvent.new('test_logger', Logging::LEVELS['info'], 'test log message', false) + refute_includes event.data.to_s, 'trace_id=' + ensure + SolarWindsAPM::Config[:log_traceId] = original + end +end diff --git a/test/support/logger_formatter_test.rb b/test/support/logger_formatter_test.rb new file mode 100644 index 00000000..72de1833 --- /dev/null +++ b/test/support/logger_formatter_test.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +# Copyright (c) 2025 SolarWinds, LLC. +# All rights reserved. + +require 'minitest_helper' +require_relative '../../lib/solarwinds_apm/config' +require './lib/solarwinds_apm/api' +require './lib/solarwinds_apm/support/logger_formatter' + +describe 'Logger::Formatter trace ID injection, deduplication, and message edge cases' do + before do + @formatter = ::Logger::Formatter.new + end + + it 'passes through when log_traceId is :never' do + original = SolarWindsAPM::Config[:log_traceId] + SolarWindsAPM::Config[:log_traceId] = :never + + output = @formatter.call('INFO', Time.now, 'TestProg', 'test message') + refute_nil output + refute_includes output, 'trace_id=' + ensure + SolarWindsAPM::Config[:log_traceId] = original + end + + it 'inserts trace_id into string message when log_traceId is :always' do + original = SolarWindsAPM::Config[:log_traceId] + SolarWindsAPM::Config[:log_traceId] = :always + + output = @formatter.call('INFO', Time.now, 'TestProg', 'hello world') + refute_nil output + assert_includes output, 'trace_id=' + ensure + SolarWindsAPM::Config[:log_traceId] = original + end + + it 'does not duplicate trace_id if already present' do + original = SolarWindsAPM::Config[:log_traceId] + SolarWindsAPM::Config[:log_traceId] = :always + + msg = 'message trace_id=abc123' + output = @formatter.call('INFO', Time.now, 'TestProg', msg) + refute_nil output + ensure + SolarWindsAPM::Config[:log_traceId] = original + end + + it 'skips empty messages' do + original = SolarWindsAPM::Config[:log_traceId] + SolarWindsAPM::Config[:log_traceId] = :always + + output = @formatter.call('INFO', Time.now, 'TestProg', ' ') + refute_nil output + ensure + SolarWindsAPM::Config[:log_traceId] = original + end + + it 'handles string message with error-like content' do + original = SolarWindsAPM::Config[:log_traceId] + SolarWindsAPM::Config[:log_traceId] = :always + + output = @formatter.call('ERROR', Time.now, 'TestProg', 'test error (StandardError)') + refute_nil output + assert_includes output, 'trace_id=' + ensure + SolarWindsAPM::Config[:log_traceId] = original + end + + it 'preserves trailing newlines in message' do + original = SolarWindsAPM::Config[:log_traceId] + SolarWindsAPM::Config[:log_traceId] = :always + + output = @formatter.call('INFO', Time.now, 'TestProg', "hello\n\n") + refute_nil output + assert_includes output, 'trace_id=' + ensure + SolarWindsAPM::Config[:log_traceId] = original + end +end diff --git a/test/support/otlp_endpoint_test.rb b/test/support/otlp_endpoint_test.rb index 81fec4a6..8f1a741b 100644 --- a/test/support/otlp_endpoint_test.rb +++ b/test/support/otlp_endpoint_test.rb @@ -251,3 +251,150 @@ def assert_singal_headers_nil(general_singal_header: true) assert_signal_endpoint_default end end + +describe 'OTLPEndPoint collector resolution, endpoint configuration, and token header injection' do + before do + # Save original env vars + @saved_env = {} + %w[SW_APM_COLLECTOR SW_APM_SERVICE_KEY OTEL_SERVICE_NAME + OTEL_RESOURCE_ATTRIBUTES OTEL_EXPORTER_OTLP_HEADERS + OTEL_EXPORTER_OTLP_ENDPOINT + OTEL_EXPORTER_OTLP_TRACES_HEADERS OTEL_EXPORTER_OTLP_TRACES_ENDPOINT + OTEL_EXPORTER_OTLP_METRICS_HEADERS OTEL_EXPORTER_OTLP_METRICS_ENDPOINT + OTEL_EXPORTER_OTLP_LOGS_HEADERS OTEL_EXPORTER_OTLP_LOGS_ENDPOINT].each do |key| + @saved_env[key] = ENV[key] + end + end + + after do + @saved_env.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v } + end + + describe 'initialize' do + it 'initializes with nil token and service_name' do + endpoint = SolarWindsAPM::OTLPEndPoint.new + assert_nil endpoint.token + assert_nil endpoint.service_name + end + end + + describe 'resolve_get_setting_endpoint' do + it 'keeps valid collector endpoint' do + ENV['SW_APM_COLLECTOR'] = 'apm.collector.na-01.cloud.solarwinds.com:443' + endpoint = SolarWindsAPM::OTLPEndPoint.new + matches = ENV['SW_APM_COLLECTOR'].match(SolarWindsAPM::OTLPEndPoint::SWO_APM_ENDPOINT_REGEX) + endpoint.send(:resolve_get_setting_endpoint, matches) + assert_equal 'apm.collector.na-01.cloud.solarwinds.com:443', ENV['SW_APM_COLLECTOR'] + end + + it 'falls back to default for invalid collector' do + ENV['SW_APM_COLLECTOR'] = 'invalid-endpoint' + endpoint = SolarWindsAPM::OTLPEndPoint.new + matches = ENV['SW_APM_COLLECTOR'].match(SolarWindsAPM::OTLPEndPoint::SWO_APM_ENDPOINT_REGEX) + endpoint.send(:resolve_get_setting_endpoint, matches) + assert_equal SolarWindsAPM::OTLPEndPoint::SWO_APM_ENDPOINT_DEFAULT, ENV['SW_APM_COLLECTOR'] + end + + it 'falls back to default for empty collector' do + ENV.delete('SW_APM_COLLECTOR') + endpoint = SolarWindsAPM::OTLPEndPoint.new + endpoint.send(:resolve_get_setting_endpoint, nil) + assert_equal SolarWindsAPM::OTLPEndPoint::SWO_APM_ENDPOINT_DEFAULT, ENV['SW_APM_COLLECTOR'] + end + end + + describe 'configure_otlp_endpoint' do + it 'sets endpoint from matched collector when no existing endpoint' do + ENV.delete('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT') + ENV.delete('OTEL_EXPORTER_OTLP_ENDPOINT') + + endpoint = SolarWindsAPM::OTLPEndPoint.new + matches = 'apm.collector.eu-01.cloud.solarwinds.com:443'.match(SolarWindsAPM::OTLPEndPoint::SWO_APM_ENDPOINT_REGEX) + + endpoint.configure_otlp_endpoint('TRACES', matches) + assert_equal 'https://otel.collector.eu-01.cloud.solarwinds.com:443/v1/traces', ENV['OTEL_EXPORTER_OTLP_TRACES_ENDPOINT'] + end + + it 'sets default endpoint when no matches and no endpoint' do + ENV.delete('OTEL_EXPORTER_OTLP_METRICS_ENDPOINT') + ENV.delete('OTEL_EXPORTER_OTLP_ENDPOINT') + + endpoint = SolarWindsAPM::OTLPEndPoint.new + endpoint.configure_otlp_endpoint('METRICS', nil) + assert_equal 'https://otel.collector.na-01.cloud.solarwinds.com:443/v1/metrics', ENV['OTEL_EXPORTER_OTLP_METRICS_ENDPOINT'] + end + + it 'does not override existing signal endpoint' do + ENV['OTEL_EXPORTER_OTLP_LOGS_ENDPOINT'] = 'https://custom.endpoint/v1/logs' + endpoint = SolarWindsAPM::OTLPEndPoint.new + endpoint.configure_otlp_endpoint('LOGS', nil) + assert_equal 'https://custom.endpoint/v1/logs', ENV['OTEL_EXPORTER_OTLP_LOGS_ENDPOINT'] + end + + it 'does not override when general endpoint is set' do + ENV.delete('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT') + ENV['OTEL_EXPORTER_OTLP_ENDPOINT'] = 'https://custom.general/endpoint' + endpoint = SolarWindsAPM::OTLPEndPoint.new + endpoint.configure_otlp_endpoint('TRACES', nil) + refute_equal 'https://otel.collector.na-01.cloud.solarwinds.com:443/v1/traces', ENV['OTEL_EXPORTER_OTLP_TRACES_ENDPOINT'] + end + end + + describe 'config_token' do + it 'sets token on general headers when matching SWO endpoint' do + ENV.delete('OTEL_EXPORTER_OTLP_HEADERS') + ENV.delete('OTEL_EXPORTER_OTLP_TRACES_HEADERS') + ENV['OTEL_EXPORTER_OTLP_ENDPOINT'] = 'https://otel.collector.na-01.cloud.solarwinds.com:443' + + endpoint = SolarWindsAPM::OTLPEndPoint.new + endpoint.instance_variable_set(:@token, 'my-token') + endpoint.config_token('TRACES') + + assert_includes ENV['OTEL_EXPORTER_OTLP_HEADERS'], 'Bearer my-token' + end + + it 'sets token on signal headers when signal endpoint matches' do + ENV.delete('OTEL_EXPORTER_OTLP_HEADERS') + ENV.delete('OTEL_EXPORTER_OTLP_TRACES_HEADERS') + ENV.delete('OTEL_EXPORTER_OTLP_ENDPOINT') + ENV['OTEL_EXPORTER_OTLP_TRACES_ENDPOINT'] = 'https://otel.collector.na-01.cloud.solarwinds.com:443/v1/traces' + + endpoint = SolarWindsAPM::OTLPEndPoint.new + endpoint.instance_variable_set(:@token, 'my-token') + endpoint.config_token('TRACES') + + assert_includes ENV['OTEL_EXPORTER_OTLP_TRACES_HEADERS'], 'Bearer my-token' + end + + it 'does not set token when no token available' do + ENV.delete('OTEL_EXPORTER_OTLP_HEADERS') + endpoint = SolarWindsAPM::OTLPEndPoint.new + endpoint.config_token('TRACES') + assert_nil ENV['OTEL_EXPORTER_OTLP_HEADERS'] + end + + it 'does not override existing headers' do + ENV['OTEL_EXPORTER_OTLP_TRACES_HEADERS'] = 'existing=header' + ENV['OTEL_EXPORTER_OTLP_TRACES_ENDPOINT'] = 'https://otel.collector.na-01.cloud.solarwinds.com:443/v1/traces' + + endpoint = SolarWindsAPM::OTLPEndPoint.new + endpoint.instance_variable_set(:@token, 'my-token') + endpoint.config_token('TRACES') + + assert_equal 'existing=header', ENV['OTEL_EXPORTER_OTLP_TRACES_HEADERS'] + end + + it 'sets fallback token when no endpoints configured' do + ENV.delete('OTEL_EXPORTER_OTLP_HEADERS') + ENV.delete('OTEL_EXPORTER_OTLP_TRACES_HEADERS') + ENV.delete('OTEL_EXPORTER_OTLP_ENDPOINT') + ENV.delete('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT') + + endpoint = SolarWindsAPM::OTLPEndPoint.new + endpoint.instance_variable_set(:@token, 'my-token') + endpoint.config_token('TRACES') + + assert_includes ENV['OTEL_EXPORTER_OTLP_HEADERS'], 'Bearer my-token' + end + end +end diff --git a/test/support/resource_detector_test.rb b/test/support/resource_detector_test.rb index 2e2a9c37..81ff216f 100644 --- a/test/support/resource_detector_test.rb +++ b/test/support/resource_detector_test.rb @@ -9,7 +9,7 @@ describe 'Resource Detector Test' do let(:mount_file) { SolarWindsAPM::ResourceDetector::K8S_MOUNTINFO_FILE } - it 'detect_k8s_attributes_with_valid_path' do + it 'detects namespace, pod UID, and pod name when K8s env and files are present' do ENV['KUBERNETES_SERVICE_HOST'] = '10.96.0.1' ENV['KUBERNETES_SERVICE_PORT'] = '443' # can't modify the /proc/self/mountinfo inside docker, use env for testing @@ -34,7 +34,7 @@ ENV.delete('SW_K8S_POD_UID') end - it 'detect_k8s_attributes_with_invalid_path' do + it 'detects pod name even when namespace file path is invalid' do ENV['KUBERNETES_SERVICE_HOST'] = '10.96.0.1' ENV['KUBERNETES_SERVICE_PORT'] = '443' @@ -47,18 +47,24 @@ ENV.delete('KUBERNETES_SERVICE_PORT') end - it 'return_empty_if_not_in_k8s' do + it 'returns empty attributes when Kubernetes env vars are not set' do + ENV.delete('KUBERNETES_SERVICE_HOST') + ENV.delete('KUBERNETES_SERVICE_PORT') + ENV.delete('SW_K8S_POD_NAME') + ENV.delete('SW_K8S_POD_NAMESPACE') + ENV.delete('SW_K8S_POD_UID') + attributes = SolarWindsAPM::ResourceDetector.detect_k8s_attributes assert_equal(attributes.instance_variable_get(:@attributes), {}) end - it 'detect_uams_client_id_failed' do + it 'returns nil for uams client id when no source is available' do attributes = SolarWindsAPM::ResourceDetector.detect_uams_client_id assert_nil(attributes.instance_variable_get(:@attributes)['sw.uams.client.id']) assert_nil(attributes.instance_variable_get(:@attributes)['host.id']) end - it 'detect_uams_client_id_from_file' do + it 'reads UAMS client ID and host ID from file when file exists' do FileUtils.mkdir_p('/opt/solarwinds/uamsclient/var') File.open(SolarWindsAPM::ResourceDetector::UAMS_CLIENT_PATH, 'w') do |file| file.puts('fake_uams_client_id') @@ -71,7 +77,7 @@ File.delete(SolarWindsAPM::ResourceDetector::UAMS_CLIENT_PATH) end - it 'detect_uams_client_id_from_local_url' do + it 'fetches UAMS client ID from local HTTP endpoint when file is unavailable' do WebMock.disable_net_connect! WebMock.enable! WebMock.stub_request(:get, SolarWindsAPM::ResourceDetector::UAMS_CLIENT_URL) @@ -88,3 +94,228 @@ WebMock.allow_net_connect! end end + +describe 'ResourceDetector UAMS client ID, K8s/EC2/Azure/container detection, and utility methods' do + describe 'detect' do + it 'returns resource with uuid attribute' do + WebMock.enable! + WebMock.stub_request(:get, SolarWindsAPM::ResourceDetector::UAMS_CLIENT_URL) + .to_return(status: 500, body: '') + # Stub all requests to AWS/Azure metadata service + WebMock.stub_request(:any, /169\.254\.169\.254/) + .to_return(status: 408, body: '') + + ENV.delete('KUBERNETES_SERVICE_HOST') + ENV.delete('KUBERNETES_SERVICE_PORT') + + result = SolarWindsAPM::ResourceDetector.detect + attrs = result.instance_variable_get(:@attributes) + + assert attrs.key?('service.instance.id') + refute_nil attrs['service.instance.id'] + ensure + WebMock.disable! + end + end + + describe 'detect_uams_client_id' do + it 'reads uams client id from file when available' do + tmpfile = Tempfile.new('uamsclientid') + tmpfile.write('test-uams-id') + tmpfile.close + + stub_const = SolarWindsAPM::ResourceDetector + original_path = stub_const.const_get(:UAMS_CLIENT_PATH) + stub_const.send(:remove_const, :UAMS_CLIENT_PATH) + stub_const.const_set(:UAMS_CLIENT_PATH, tmpfile.path) + + result = stub_const.detect_uams_client_id + attrs = result.instance_variable_get(:@attributes) + + assert_equal 'test-uams-id', attrs['sw.uams.client.id'] + ensure + stub_const.send(:remove_const, :UAMS_CLIENT_PATH) + stub_const.const_set(:UAMS_CLIENT_PATH, original_path) + tmpfile&.unlink + end + + it 'falls back to API when file not found' do + WebMock.enable! + WebMock.stub_request(:get, SolarWindsAPM::ResourceDetector::UAMS_CLIENT_URL) + .to_return(status: 200, body: '{"uamsclient_id": "api-client-id"}', + headers: { 'Content-Type' => 'application/json' }) + + stub_const = SolarWindsAPM::ResourceDetector + original_path = stub_const.const_get(:UAMS_CLIENT_PATH) + stub_const.send(:remove_const, :UAMS_CLIENT_PATH) + stub_const.const_set(:UAMS_CLIENT_PATH, '/nonexistent/path/uamsclientid') + + result = stub_const.detect_uams_client_id + attrs = result.instance_variable_get(:@attributes) + + assert_equal 'api-client-id', attrs['sw.uams.client.id'] + ensure + stub_const.send(:remove_const, :UAMS_CLIENT_PATH) + stub_const.const_set(:UAMS_CLIENT_PATH, original_path) + WebMock.disable! + end + + it 'handles API failure gracefully' do + WebMock.enable! + WebMock.stub_request(:get, SolarWindsAPM::ResourceDetector::UAMS_CLIENT_URL) + .to_return(status: 500, body: 'error') + + stub_const = SolarWindsAPM::ResourceDetector + original_path = stub_const.const_get(:UAMS_CLIENT_PATH) + stub_const.send(:remove_const, :UAMS_CLIENT_PATH) + stub_const.const_set(:UAMS_CLIENT_PATH, '/nonexistent/path/uamsclientid') + + result = stub_const.detect_uams_client_id + attrs = result.instance_variable_get(:@attributes) + + assert_nil attrs['sw.uams.client.id'] + ensure + stub_const.send(:remove_const, :UAMS_CLIENT_PATH) + stub_const.const_set(:UAMS_CLIENT_PATH, original_path) + WebMock.disable! + end + end + + describe 'detect_k8s_attributes' do + it 'returns empty resource when not in k8s' do + ENV.delete('KUBERNETES_SERVICE_HOST') + ENV.delete('KUBERNETES_SERVICE_PORT') + + result = SolarWindsAPM::ResourceDetector.detect_k8s_attributes + attrs = result.instance_variable_get(:@attributes) + assert_empty attrs + end + + it 'reads pod name from env variable' do + ENV['KUBERNETES_SERVICE_HOST'] = '10.96.0.1' + ENV['KUBERNETES_SERVICE_PORT'] = '443' + ENV['SW_K8S_POD_NAME'] = 'my-pod-name' + + result = SolarWindsAPM::ResourceDetector.detect_k8s_attributes + attrs = result.instance_variable_get(:@attributes) + + assert_equal 'my-pod-name', attrs['k8s.pod.name'] + ensure + ENV.delete('KUBERNETES_SERVICE_HOST') + ENV.delete('KUBERNETES_SERVICE_PORT') + ENV.delete('SW_K8S_POD_NAME') + end + + it 'reads pod namespace from env variable' do + ENV['KUBERNETES_SERVICE_HOST'] = '10.96.0.1' + ENV['KUBERNETES_SERVICE_PORT'] = '443' + ENV['SW_K8S_POD_NAMESPACE'] = 'test-namespace' + + result = SolarWindsAPM::ResourceDetector.detect_k8s_attributes + attrs = result.instance_variable_get(:@attributes) + + assert_equal 'test-namespace', attrs['k8s.namespace.name'] + ensure + ENV.delete('KUBERNETES_SERVICE_HOST') + ENV.delete('KUBERNETES_SERVICE_PORT') + ENV.delete('SW_K8S_POD_NAMESPACE') + end + + it 'reads pod uid from env variable' do + ENV['KUBERNETES_SERVICE_HOST'] = '10.96.0.1' + ENV['KUBERNETES_SERVICE_PORT'] = '443' + ENV['SW_K8S_POD_UID'] = 'b4683374-c415-4136-99bf-7fd72a0aa885' + + result = SolarWindsAPM::ResourceDetector.detect_k8s_attributes + attrs = result.instance_variable_get(:@attributes) + + assert_equal 'b4683374-c415-4136-99bf-7fd72a0aa885', attrs['k8s.pod.uid'] + ensure + ENV.delete('KUBERNETES_SERVICE_HOST') + ENV.delete('KUBERNETES_SERVICE_PORT') + ENV.delete('SW_K8S_POD_UID') + end + end + + describe 'detect_ec2' do + it 'returns resource without raising' do + result = SolarWindsAPM::ResourceDetector.detect_ec2 + refute_nil result + end + end + + describe 'detect_azure' do + it 'returns resource without raising' do + result = SolarWindsAPM::ResourceDetector.detect_azure + refute_nil result + end + end + + describe 'detect_container' do + it 'returns resource without raising' do + result = SolarWindsAPM::ResourceDetector.detect_container + refute_nil result + end + end + + describe 'number?' do + it 'returns true for valid numbers' do + assert SolarWindsAPM::ResourceDetector.number?('42') + assert SolarWindsAPM::ResourceDetector.number?('3.14') + assert SolarWindsAPM::ResourceDetector.number?('-1') + end + + it 'returns false for non-numbers' do + refute SolarWindsAPM::ResourceDetector.number?('abc') + refute SolarWindsAPM::ResourceDetector.number?('') + end + end + + describe 'safe_integer?' do + it 'returns true for safe integers' do + assert SolarWindsAPM::ResourceDetector.safe_integer?(42) + assert SolarWindsAPM::ResourceDetector.safe_integer?(0) + assert SolarWindsAPM::ResourceDetector.safe_integer?(-100) + assert SolarWindsAPM::ResourceDetector.safe_integer?('123') + end + + it 'returns false for unsafe integers' do + refute SolarWindsAPM::ResourceDetector.safe_integer?((2**53)) + refute SolarWindsAPM::ResourceDetector.safe_integer?(-(2**53)) + end + end + + describe 'windows?' do + it 'returns false on non-windows platforms' do + refute SolarWindsAPM::ResourceDetector.windows? unless RUBY_PLATFORM.match?(/mswin|mingw|cygwin/) + end + end + + describe 'random_uuid' do + it 'returns a valid UUID string' do + uuid = SolarWindsAPM::ResourceDetector.random_uuid + assert_match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, uuid) + end + + it 'returns unique values' do + uuid1 = SolarWindsAPM::ResourceDetector.random_uuid + uuid2 = SolarWindsAPM::ResourceDetector.random_uuid + refute_equal uuid1, uuid2 + end + end + + describe 'run_opentelemetry_detector' do + it 'handles detector failure gracefully' do + # Create a mock detector that raises + mock_detector = Class.new do + def self.detect + raise StandardError, 'detector failed' + end + end + + result = SolarWindsAPM::ResourceDetector.run_opentelemetry_detector(mock_detector) + attrs = result.instance_variable_get(:@attributes) + assert_empty attrs + end + end +end diff --git a/test/support/service_key_checker_test.rb b/test/support/service_key_checker_test.rb index 604971a4..0b8cdc7a 100644 --- a/test/support/service_key_checker_test.rb +++ b/test/support/service_key_checker_test.rb @@ -6,6 +6,7 @@ require 'minitest_helper' require './lib/solarwinds_apm/config' require './lib/solarwinds_apm/support/service_key_checker' +require './lib/solarwinds_apm/support/utils' describe 'ServiceKeyCheckerTest' do before do @@ -31,7 +32,7 @@ def service_key_ok?(service_key) !service_key.to_s.empty? end - it 'validates the service key' do + it 'accepts valid token:service format and rejects malformed service keys' do ENV['SW_APM_SERVICE_KEY'] = nil SolarWindsAPM::Config[:service_key] = nil @@ -109,7 +110,7 @@ def service_key_ok?(service_key) _(service_key_checker.service_name).must_equal "serv_#{'1234567890' * 25}" end - it 'test_when_otel_service_name_exist' do + it 'uses OTEL_SERVICE_NAME as service name when set and non-empty' do ENV['OTEL_SERVICE_NAME'] = 'abcdef' ENV['SW_APM_SERVICE_KEY'] = 'CWoadXY66FXNd_e5u3nabLZ1KByYZRTi1yWJg2AcD6MHo1AA42UstbipfHfx6Hnl-821ARq:my-cool-service' @@ -135,7 +136,7 @@ def service_key_ok?(service_key) _(service_key_checker.service_name).must_equal 'my-cool-service' end - it 'test_when_otel_service_name_does_not_exist' do + it 'sets OTEL_SERVICE_NAME from service key when OTEL_SERVICE_NAME is not set' do ENV['SW_APM_SERVICE_KEY'] = 'CWoadXY66FXNd_e5u3nabLZ1KByYZRTi1yWJg2AcD6MHo1AA42UstbipfHfx6Hnl-821ARq:my-cool-service' ENV['OTEL_SERVICE_NAME'] = nil @@ -156,7 +157,7 @@ def service_key_ok?(service_key) assert_nil(ENV.fetch('OTEL_SERVICE_NAME', nil)) end - it 'test_with_OTEL_RESOURCE_ATTRIBUTES_and_OTEL_SERVICE_NAME' do + it 'resolves service name with OTEL_SERVICE_NAME over OTEL_RESOURCE_ATTRIBUTES and service key' do ENV['SW_APM_SERVICE_KEY'] = 'CWoadXY66FXNd_e5u3nabLZ1KByYZRTi1yWJg2AcD6MHo1AA42UstbipfHfx6Hnl-821ARq:my-cool-service' ENV['OTEL_SERVICE_NAME'] = nil @@ -225,3 +226,107 @@ def service_key_ok?(service_key) assert_nil(ENV.fetch('OTEL_SERVICE_NAME', nil)) end end + +describe 'ServiceKeyChecker token/service_name parsing, env var overrides, and name sanitization' do + describe 'initialization' do + it 'returns nil token for non-ssl reporter' do + checker = SolarWindsAPM::ServiceKeyChecker.new('udp', false) + assert_nil checker.token + assert_nil checker.service_name + end + + it 'returns nil token for lambda environment' do + checker = SolarWindsAPM::ServiceKeyChecker.new('ssl', true) + assert_nil checker.token + assert_nil checker.service_name + end + + it 'parses valid service key from environment' do + original_key = ENV['SW_APM_SERVICE_KEY'] + ENV['SW_APM_SERVICE_KEY'] = 'test-token-key-123456789012345678901234567890123:my-service' + ENV.delete('OTEL_SERVICE_NAME') + ENV.delete('OTEL_RESOURCE_ATTRIBUTES') + + checker = SolarWindsAPM::ServiceKeyChecker.new('ssl', false) + assert_equal 'test-token-key-123456789012345678901234567890123', checker.token + assert_equal 'my-service', checker.service_name + ensure + ENV['SW_APM_SERVICE_KEY'] = original_key + end + + it 'returns nil token for empty service key' do + original_key = ENV['SW_APM_SERVICE_KEY'] + ENV['SW_APM_SERVICE_KEY'] = '' + + checker = SolarWindsAPM::ServiceKeyChecker.new('ssl', false) + assert_nil checker.token + ensure + ENV['SW_APM_SERVICE_KEY'] = original_key + end + + it 'returns nil token for missing service name' do + original_key = ENV['SW_APM_SERVICE_KEY'] + ENV['SW_APM_SERVICE_KEY'] = 'only-token-no-colon' + ENV.delete('OTEL_SERVICE_NAME') + + checker = SolarWindsAPM::ServiceKeyChecker.new('ssl', false) + assert_nil checker.token + ensure + ENV['SW_APM_SERVICE_KEY'] = original_key + end + + it 'uses OTEL_SERVICE_NAME override' do + original_key = ENV['SW_APM_SERVICE_KEY'] + original_otel = ENV['OTEL_SERVICE_NAME'] + ENV['SW_APM_SERVICE_KEY'] = 'test-token-key-123456789012345678901234567890123:original-service' + ENV['OTEL_SERVICE_NAME'] = 'otel-override' + + checker = SolarWindsAPM::ServiceKeyChecker.new('ssl', false) + assert_equal 'otel-override', checker.service_name + ensure + ENV['SW_APM_SERVICE_KEY'] = original_key + ENV['OTEL_SERVICE_NAME'] = original_otel + end + + it 'uses OTEL_RESOURCE_ATTRIBUTES service.name override' do + original_key = ENV['SW_APM_SERVICE_KEY'] + original_otel = ENV['OTEL_SERVICE_NAME'] + original_resource = ENV['OTEL_RESOURCE_ATTRIBUTES'] + ENV['SW_APM_SERVICE_KEY'] = 'test-token-key-123456789012345678901234567890123:original-service' + ENV.delete('OTEL_SERVICE_NAME') + ENV['OTEL_RESOURCE_ATTRIBUTES'] = 'service.name=resource-service,other=val' + + checker = SolarWindsAPM::ServiceKeyChecker.new('ssl', false) + assert_equal 'resource-service', checker.service_name + ensure + ENV['SW_APM_SERVICE_KEY'] = original_key + ENV['OTEL_SERVICE_NAME'] = original_otel + ENV['OTEL_RESOURCE_ATTRIBUTES'] = original_resource + end + + it 'transforms service name by lowercasing and removing invalid chars' do + original_key = ENV['SW_APM_SERVICE_KEY'] + ENV['SW_APM_SERVICE_KEY'] = 'test-token-key-123456789012345678901234567890123:My Service!@#$' + ENV.delete('OTEL_SERVICE_NAME') + ENV.delete('OTEL_RESOURCE_ATTRIBUTES') + + checker = SolarWindsAPM::ServiceKeyChecker.new('ssl', false) + assert_equal 'myservice', checker.service_name + ensure + ENV['SW_APM_SERVICE_KEY'] = original_key + end + + it 'truncates long service names to 255 chars' do + original_key = ENV['SW_APM_SERVICE_KEY'] + long_name = 'a' * 300 + ENV['SW_APM_SERVICE_KEY'] = "test-token-key-123456789012345678901234567890123:#{long_name}" + ENV.delete('OTEL_SERVICE_NAME') + ENV.delete('OTEL_RESOURCE_ATTRIBUTES') + + checker = SolarWindsAPM::ServiceKeyChecker.new('ssl', false) + assert checker.service_name.length <= 255 + ensure + ENV['SW_APM_SERVICE_KEY'] = original_key + end + end +end diff --git a/test/support/trace_context_log_test.rb b/test/support/trace_context_log_test.rb index c18182a6..70567a12 100644 --- a/test/support/trace_context_log_test.rb +++ b/test/support/trace_context_log_test.rb @@ -23,7 +23,7 @@ SolarWindsAPM.logger.level = Logger::INFO end - it 'test_log_traceId_with_debug_always' do + it 'Logger includes trace context in debug output when log_traceId is :always' do SolarWindsAPM.logger.level = Logger::DEBUG SolarWindsAPM::Config[:log_traceId] = :always SolarWindsAPM.logger.debug 'Sample debug message' @@ -32,7 +32,7 @@ 'trace_id=00000000000000000000000000000000 span_id=0000000000000000 trace_flags=00 resource.service.name=' end - it 'test_log_traceId_with_info_always' do + it 'Logger omits debug messages when log level is INFO' do SolarWindsAPM.logger.level = Logger::INFO SolarWindsAPM::Config[:log_traceId] = :always SolarWindsAPM.logger.debug 'Sample debug message' @@ -40,7 +40,7 @@ assert_empty(@log_output.read) end - it 'test_logging_traceId_with_default' do + it 'Logging gem omits trace context when log_traceId is :sampled and no active trace' do SolarWindsAPM.logger.level = Logger::DEBUG SolarWindsAPM::Config[:log_traceId] = :sampled SolarWindsAPM.logger.debug 'Sample debug message' @@ -49,7 +49,7 @@ 'trace_id=00000000000000000000000000000000 span_id=0000000000000000 trace_flags=00') end - it 'test_logging_traceId_with_debug_always' do + it 'Logging gem includes trace context when log_traceId is :always' do SolarWindsAPM::Config[:log_traceId] = :always logger = Logging.logger(@log_output) logger.level = :debug @@ -59,7 +59,7 @@ 'trace_id=00000000000000000000000000000000 span_id=0000000000000000 trace_flags=00 resource.service.name=' end - it 'test_logging_traceId_with_debug_sampled' do + it 'Logging gem omits trace context when log_traceId is :sampled' do SolarWindsAPM::Config[:log_traceId] = :sampled logger = Logging.logger(@log_output) logger.level = :debug @@ -71,7 +71,7 @@ # lumberjack can't work in prepend Formatter anymore. # use logger.tag(context: lambda {SolarWindsAPM::API.current_trace_info.for_log}) - it 'test_lumberjack_with_tag_debug_sampled' do + it 'Lumberjack includes trace context via tag lambda when log_traceId is :always' do SolarWindsAPM::Config[:log_traceId] = :always logger = Lumberjack::Logger.new(@log_output, level: :debug) logger.tag(tracecontext: -> { SolarWindsAPM::API.current_trace_info.for_log }) @@ -81,7 +81,7 @@ 'trace_id=00000000000000000000000000000000 span_id=0000000000000000 trace_flags=00 resource.service.name=' end - it 'test_lumberjack_with_debug_sampled' do + it 'Lumberjack auto-injects trace context when log_traceId is :always' do SolarWindsAPM::Config[:log_traceId] = :always logger = Lumberjack::Logger.new(@log_output, level: :debug) logger.debug('Sample debug message') @@ -90,7 +90,7 @@ 'trace_id=00000000000000000000000000000000 span_id=0000000000000000 trace_flags=00 resource.service.name=' end - it 'test_lumberjack_with_debug_sampled_valid_span' do + it 'Lumberjack includes real trace/span IDs within an active sampled span' do SolarWindsAPM::Config[:log_traceId] = :always logger = Lumberjack::Logger.new(@log_output, level: :debug) @@ -123,7 +123,7 @@ refute_includes(log_output, 'trace_id=00000000000000000000000000000000 span_id=0000000000000000 trace_flags=00') end - it 'test_log_traceId_with_debug_always_valid_span' do + it 'Logger includes real trace/span IDs when log_traceId is :sampled and span is active' do SolarWindsAPM.logger.level = Logger::DEBUG SolarWindsAPM::Config[:log_traceId] = :sampled @@ -154,7 +154,7 @@ assert_equal(trace_flags[1]&.size, 2) end - it 'test_log_traceId_with_debug_never_valid_span' do + it 'Logger omits trace context when log_traceId is :never even with active span' do SolarWindsAPM.logger.level = Logger::DEBUG SolarWindsAPM::Config[:log_traceId] = :never @@ -178,7 +178,7 @@ assert_nil(trace_flags) end - it 'test_log_traceId_with_debug_never_valid_span_untraced' do + it 'Logger omits trace context when log_traceId is :sampled and span trace flags are unset' do SolarWindsAPM.logger.level = Logger::DEBUG SolarWindsAPM::Config[:log_traceId] = :sampled @@ -202,56 +202,4 @@ assert_nil(span_id) assert_nil(trace_flags) end - - # lumberjack can't work in prepend Formatter anymore. - # use logger.tag(context: lambda {SolarWindsAPM::API.current_trace_info.for_log}) - it 'test_lumberjack_with_tag_debug_sampled' do - SolarWindsAPM::Config[:log_traceId] = :always - logger = Lumberjack::Logger.new(@log_output, level: :debug) - logger.tag(tracecontext: -> { SolarWindsAPM::API.current_trace_info.for_log }) - logger.debug('Sample debug message') - @log_output.rewind - assert_includes @log_output.read, 'trace_id=00000000000000000000000000000000 span_id=0000000000000000 trace_flags=00 resource.service.name=' - end - - it 'test_lumberjack_with_debug_sampled' do - SolarWindsAPM::Config[:log_traceId] = :always - logger = Lumberjack::Logger.new(@log_output, level: :debug) - logger.debug('Sample debug message') - @log_output.rewind - assert_includes @log_output.read, 'trace_id=00000000000000000000000000000000 span_id=0000000000000000 trace_flags=00 resource.service.name=' - end - - it 'test_lumberjack_with_debug_sampled_valid_span' do - SolarWindsAPM::Config[:log_traceId] = :always - logger = Lumberjack::Logger.new(@log_output, level: :debug) - - OpenTelemetry::SDK.configure do |c| - c.service_name = 'my_service' - c.add_span_processor(OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(OpenTelemetry::SDK::Trace::Export::InMemorySpanExporter.new)) - end - - OpenTelemetry.tracer_provider.tracer('my_service').in_span('sample_span') do |span| - span.context.trace_flags.instance_variable_set(:@flags, 1) - logger.debug 'Sample debug message' - end - - @log_output.rewind - log_output = @log_output.read - - trace_id = log_output.match(/trace_id=([\da-fA-F]+)/) - - assert_equal(trace_id&.size, 2) - assert_equal(trace_id[1]&.size, 32) - - span_id = log_output.match(/span_id=([\da-fA-F]+)/) - assert_equal(span_id&.size, 2) - assert_equal(span_id[1]&.size, 16) - - trace_flags = log_output.match(/trace_flags=([\da-fA-F]+)/) - assert_equal(trace_flags&.size, 2) - assert_equal(trace_flags[1]&.size, 2) - - refute_includes(log_output, 'trace_id=00000000000000000000000000000000 span_id=0000000000000000 trace_flags=00') - end end diff --git a/test/support/transaction_settings_test.rb b/test/support/transaction_settings_test.rb index b441d011..34e8f9ee 100644 --- a/test/support/transaction_settings_test.rb +++ b/test/support/transaction_settings_test.rb @@ -104,3 +104,79 @@ _(trans_settings.calculate_trace_mode).must_equal 1 end end + +describe 'TransactionSettings#calculate_trace_mode with tracing modes and regexp filtering' do + before do + @original_tracing_mode = SolarWindsAPM::Config[:tracing_mode] + @original_enabled_regexps = SolarWindsAPM::Config[:enabled_regexps] + @original_disabled_regexps = SolarWindsAPM::Config[:disabled_regexps] + end + + after do + SolarWindsAPM::Config[:tracing_mode] = @original_tracing_mode + SolarWindsAPM::Config[:enabled_regexps] = @original_enabled_regexps + SolarWindsAPM::Config[:disabled_regexps] = @original_disabled_regexps + end + + describe 'calculate_trace_mode' do + it 'returns enabled when tracing_mode is enabled and no filters match' do + SolarWindsAPM::Config[:tracing_mode] = :enabled + SolarWindsAPM::Config[:enabled_regexps] = nil + SolarWindsAPM::Config[:disabled_regexps] = nil + + ts = SolarWindsAPM::TransactionSettings.new(url_path: '/api/test', name: 'test', kind: :server) + assert_equal 1, ts.calculate_trace_mode + end + + it 'returns disabled when tracing_mode is not enabled' do + SolarWindsAPM::Config[:tracing_mode] = :disabled + ts = SolarWindsAPM::TransactionSettings.new(url_path: '/test', name: 'test', kind: :server) + assert_equal 0, ts.calculate_trace_mode + end + + it 'returns disabled when url matches disabled regexp' do + SolarWindsAPM::Config[:tracing_mode] = :enabled + SolarWindsAPM::Config[:disabled_regexps] = [/\/health/] + SolarWindsAPM::Config[:enabled_regexps] = nil + + ts = SolarWindsAPM::TransactionSettings.new(url_path: '/health', name: 'test', kind: :server) + assert_equal 0, ts.calculate_trace_mode + end + + it 'returns enabled when url matches enabled regexp' do + SolarWindsAPM::Config[:tracing_mode] = :enabled + SolarWindsAPM::Config[:enabled_regexps] = [/\/api/] + SolarWindsAPM::Config[:disabled_regexps] = nil + + ts = SolarWindsAPM::TransactionSettings.new(url_path: '/api/test', name: 'test', kind: :server) + assert_equal 1, ts.calculate_trace_mode + end + + it 'returns disabled when span layer matches disabled regexp' do + SolarWindsAPM::Config[:tracing_mode] = :enabled + SolarWindsAPM::Config[:disabled_regexps] = [/server:background_job/] + SolarWindsAPM::Config[:enabled_regexps] = nil + + ts = SolarWindsAPM::TransactionSettings.new(url_path: '', name: 'background_job', kind: 'server') + assert_equal 0, ts.calculate_trace_mode + end + + it 'returns enabled when span layer matches enabled regexp' do + SolarWindsAPM::Config[:tracing_mode] = :enabled + SolarWindsAPM::Config[:enabled_regexps] = [/server:api_call/] + SolarWindsAPM::Config[:disabled_regexps] = nil + + ts = SolarWindsAPM::TransactionSettings.new(url_path: '', name: 'api_call', kind: 'server') + assert_equal 1, ts.calculate_trace_mode + end + + it 'disabled takes priority over enabled for url' do + SolarWindsAPM::Config[:tracing_mode] = :enabled + SolarWindsAPM::Config[:disabled_regexps] = [/\/api/] + SolarWindsAPM::Config[:enabled_regexps] = [/\/api/] + + ts = SolarWindsAPM::TransactionSettings.new(url_path: '/api/test', name: 'test', kind: :server) + assert_equal 0, ts.calculate_trace_mode + end + end +end diff --git a/test/support/txn_name_manager_test.rb b/test/support/txn_name_manager_test.rb index badde4c9..0b602b9b 100644 --- a/test/support/txn_name_manager_test.rb +++ b/test/support/txn_name_manager_test.rb @@ -6,37 +6,81 @@ require 'minitest_helper' require './lib/solarwinds_apm/support/txn_name_manager' -describe 'SolarWindsTXNNameManangerTest.rb' do +describe 'TxnNameManager CRUD operations, name length/cardinality limits, and root context tracking' do before do @txn_manager = SolarWindsAPM::TxnNameManager.new - @txn_manager.set('c', 'd') end - it 'test_set' do - @txn_manager.set('a', 'b') - _(@txn_manager.get('a')).must_equal 'b' + it 'creates a cleanup thread for transaction name cache' do + @txn_manager.set('c', 'd') + _(@txn_manager.instance_variable_get(:@transaction_name)['d'].class).must_equal Thread end - it 'test_[]' do - @txn_manager['e'] = 'f' - _(@txn_manager.get('e')).must_equal 'f' - end + describe 'set and get' do + it 'stores and retrieves values' do + @txn_manager.set('key1', 'value1') + assert_equal 'value1', @txn_manager.get('key1') + end + + it 'limits to MAX_TXN_NAME_LENGTH' do + long_name = 'a' * 500 + @txn_manager.set('key1', long_name) + result = @txn_manager.get('key1') + assert result.length <= SolarWindsAPM::Constants::MAX_TXN_NAME_LENGTH + end + + it 'replaces existing transaction name' do + @txn_manager.set('key1', 'first') + @txn_manager.set('key1', 'second') + assert_equal 'second', @txn_manager.get('key1') + end - it 'test_del' do - @txn_manager.del('c') - assert_nil(@txn_manager.get('c')) + it 'returns default name when cardinality limit reached' do + # Fill up with unique names + SolarWindsAPM::TxnNameManager::MAX_CARDINALITY.times do |i| + @txn_manager.set("key_#{i}", "unique_name_#{i}") + end + + # Next new unique name should get default + @txn_manager.set('overflow_key', 'new_unique_name') + assert_equal 'other', @txn_manager.get('overflow_key') + end end - it 'test_get' do - _(@txn_manager.get('c')).must_equal 'd' + describe 'del' do + it 'removes stored values' do + @txn_manager.set('key1', 'value1') + @txn_manager.del('key1') + assert_nil @txn_manager.get('key1') + end + + it 'handles deletion of non-existent key' do + @txn_manager.del('nonexistent') + assert_nil @txn_manager.get('nonexistent') + end end - it 'test_set_get_root_context' do - @txn_manager.set_root_context_h('key1', 'abcd') - _(@txn_manager.get_root_context_h('key1')).must_equal 'abcd' + describe 'root_context_h' do + it 'sets and gets root context' do + @txn_manager.set_root_context_h('trace1', 'span1-01') + assert_equal 'span1-01', @txn_manager.get_root_context_h('trace1') + end + + it 'deletes root context' do + @txn_manager.set_root_context_h('trace1', 'span1-01') + @txn_manager.delete_root_context_h('trace1') + assert_nil @txn_manager.get_root_context_h('trace1') + end + + it 'returns nil for non-existent root context' do + assert_nil @txn_manager.get_root_context_h('nonexistent') + end end - it 'transaction_name have thread' do - _(@txn_manager.instance_variable_get(:@transaction_name)['d'].class).must_equal Thread + describe '[]= alias' do + it 'works as alias for set' do + @txn_manager['alias_key'] = 'alias_value' + assert_equal 'alias_value', @txn_manager.get('alias_key') + end end end diff --git a/test/support/utils_test.rb b/test/support/utils_test.rb index bd15f059..a47db70a 100644 --- a/test/support/utils_test.rb +++ b/test/support/utils_test.rb @@ -6,22 +6,93 @@ require 'minitest_helper' require './lib/solarwinds_apm/support/utils' -describe 'Utility Test' do - before do - @span_context = OpenTelemetry::Trace::SpanContext.new(trace_id: "\xDD\x95\xC5l\xE3\x83\xCA\xF0\x95;S\x98i\xF9:{", +describe 'Utils tracestate formatting, traceparent construction, and Lambda detection' do + it 'builds W3C traceparent string from span context' do + span_context = OpenTelemetry::Trace::SpanContext.new(trace_id: "\xDD\x95\xC5l\xE3\x83\xCA\xF0\x95;S\x98i\xF9:{", span_id: "\x8D\xB5\xDC?$l\x84W") - - @tracestate = OpenTelemetry::Trace::Tracestate.from_hash({ 'sw' => '0000000000000000-01' }) - @utils = SolarWindsAPM::Utils + result = SolarWindsAPM::Utils.traceparent_from_context(span_context) + _(result).must_equal '00-dd95c56ce383caf0953b539869f93a7b-8db5dc3f246c8457-00' end - it 'test trace_state_header' do - result = @utils.trace_state_header(@tracestate) + it 'formats tracestate hash into key=value header string' do + tracestate = OpenTelemetry::Trace::Tracestate.from_hash({ 'sw' => '0000000000000000-01' }) + result = SolarWindsAPM::Utils.trace_state_header(tracestate) _(result).must_equal 'sw=0000000000000000-01' end - it 'test traceparent_from_context' do - result = @utils.traceparent_from_context(@span_context) - _(result).must_equal '00-dd95c56ce383caf0953b539869f93a7b-8db5dc3f246c8457-00' + describe 'trace_state_header' do + it 'returns nil for nil tracestate' do + assert_nil SolarWindsAPM::Utils.trace_state_header(nil) + end + + it 'returns nil for empty tracestate' do + tracestate = OpenTelemetry::Trace::Tracestate::DEFAULT + assert_nil SolarWindsAPM::Utils.trace_state_header(tracestate) + end + + it 'formats multiple tracestate entries' do + tracestate = OpenTelemetry::Trace::Tracestate.from_hash({ 'sw' => '1234-01', 'other' => 'value' }) + result = SolarWindsAPM::Utils.trace_state_header(tracestate) + assert_includes result, 'sw=1234-01' + assert_includes result, 'other=value' + end + end + + describe 'traceparent_from_context' do + it 'formats sampled span context' do + span_context = OpenTelemetry::Trace::SpanContext.new( + span_id: "\x8D\xB5\xDC?$l\x84W", + trace_id: "\xDD\x95\xC5l\xE3\x83\xCA\xF0\x95;S\x98i\xF9:{", + trace_flags: OpenTelemetry::Trace::TraceFlags::SAMPLED + ) + result = SolarWindsAPM::Utils.traceparent_from_context(span_context) + assert result.start_with?('00-') + assert result.end_with?('-01') + end + + it 'formats non-sampled span context' do + span_context = OpenTelemetry::Trace::SpanContext.new( + span_id: "\x8D\xB5\xDC?$l\x84W", + trace_id: "\xDD\x95\xC5l\xE3\x83\xCA\xF0\x95;S\x98i\xF9:{", + trace_flags: OpenTelemetry::Trace::TraceFlags::DEFAULT + ) + result = SolarWindsAPM::Utils.traceparent_from_context(span_context) + assert result.end_with?('-00') + end + end + + describe 'determine_lambda' do + it 'returns false when not in lambda' do + original_task_root = ENV['LAMBDA_TASK_ROOT'] + original_func_name = ENV['AWS_LAMBDA_FUNCTION_NAME'] + ENV.delete('LAMBDA_TASK_ROOT') + ENV.delete('AWS_LAMBDA_FUNCTION_NAME') + + refute SolarWindsAPM::Utils.determine_lambda + ensure + original_task_root ? ENV['LAMBDA_TASK_ROOT'] = original_task_root : ENV.delete('LAMBDA_TASK_ROOT') + original_func_name ? ENV['AWS_LAMBDA_FUNCTION_NAME'] = original_func_name : ENV.delete('AWS_LAMBDA_FUNCTION_NAME') + end + + it 'returns true when LAMBDA_TASK_ROOT is set' do + original = ENV['LAMBDA_TASK_ROOT'] + ENV['LAMBDA_TASK_ROOT'] = '/var/task' + + assert SolarWindsAPM::Utils.determine_lambda + ensure + original ? ENV['LAMBDA_TASK_ROOT'] = original : ENV.delete('LAMBDA_TASK_ROOT') + end + + it 'returns true when AWS_LAMBDA_FUNCTION_NAME is set' do + original_task_root = ENV['LAMBDA_TASK_ROOT'] + original_func_name = ENV['AWS_LAMBDA_FUNCTION_NAME'] + ENV.delete('LAMBDA_TASK_ROOT') + ENV['AWS_LAMBDA_FUNCTION_NAME'] = 'my-function' + + assert SolarWindsAPM::Utils.determine_lambda + ensure + original_task_root ? ENV['LAMBDA_TASK_ROOT'] = original_task_root : ENV.delete('LAMBDA_TASK_ROOT') + original_func_name ? ENV['AWS_LAMBDA_FUNCTION_NAME'] = original_func_name : ENV.delete('AWS_LAMBDA_FUNCTION_NAME') + end end end From c505f1f3d82aae2c4e3589f17a29c2344e86ad3c Mon Sep 17 00:00:00 2001 From: xuan-cao-swi Date: Wed, 18 Mar 2026 12:39:41 -0400 Subject: [PATCH 03/10] remove duplicates --- test/opentelemetry/otlp_processor_test.rb | 220 ++------- .../solarwinds_propagator_test.rb | 18 +- test/sampling/dice_test.rb | 50 --- test/sampling/json_sampler_test.rb | 32 +- test/sampling/oboe_sampler_test.rb | 416 ++---------------- test/sampling/sampler_test.rb | 83 +--- test/sampling/settings_test.rb | 6 +- test/sampling/token_bucket_test.rb | 46 ++ test/sampling/trace_options_test.rb | 200 ++------- test/solarwinds_apm/config_test.rb | 93 +--- test/support/otlp_endpoint_test.rb | 19 - test/support/resource_detector_test.rb | 67 --- test/support/service_key_checker_test.rb | 112 +---- 13 files changed, 167 insertions(+), 1195 deletions(-) diff --git a/test/opentelemetry/otlp_processor_test.rb b/test/opentelemetry/otlp_processor_test.rb index 8f654367..5908d116 100644 --- a/test/opentelemetry/otlp_processor_test.rb +++ b/test/opentelemetry/otlp_processor_test.rb @@ -380,14 +380,6 @@ _(result['sw.is_error']).must_equal false end end -end - -describe 'OTLPProcessor lifecycle, entry span detection, transaction naming, and metric attributes' do - before do - SolarWindsAPM::OpenTelemetry::OTLPProcessor.prepend(DisableAddView) - @txn_manager = SolarWindsAPM::TxnNameManager.new - @processor = SolarWindsAPM::OpenTelemetry::OTLPProcessor.new(@txn_manager) - end describe 'force_flush' do it 'returns SUCCESS' do @@ -474,137 +466,50 @@ end end - describe 'private methods via send' do - describe 'calculate_span_time' do - it 'returns 0 when start_time is nil' do - result = @processor.send(:calculate_span_time, start_time: nil, end_time: 1000) - assert_equal 0, result - end - - it 'returns 0 when end_time is nil' do - result = @processor.send(:calculate_span_time, start_time: 1000, end_time: nil) - assert_equal 0, result - end - - it 'calculates time difference in microseconds' do - result = @processor.send(:calculate_span_time, start_time: 1_000_000_000, end_time: 2_000_000_000) - assert result > 0 - end + describe 'calculate_span_time' do + it 'calculates time difference in microseconds' do + result = @processor.send(:calculate_span_time, start_time: 1_000_000_000, end_time: 2_000_000_000) + assert result > 0 end + end - describe 'error?' do - it 'returns 1 for error status' do - span_data = create_span_data - # Override status to error - error_status = OpenTelemetry::Trace::Status.error('error') - span_data_with_error = OpenTelemetry::SDK::Trace::SpanData.new( - 'test', :internal, error_status, - ("\0" * 8).b, 2, 2, 0, - 1_669_317_386_253_789_212, 1_669_317_386_298_642_087, - {}, nil, nil, - OpenTelemetry::SDK::Resources::Resource.create({}), - OpenTelemetry::SDK::InstrumentationScope.new('test', '1.0'), - Random.bytes(8), Random.bytes(16), - OpenTelemetry::Trace::TraceFlags.from_byte(0x01), - OpenTelemetry::Trace::Tracestate::DEFAULT - ) - - result = @processor.send(:error?, span_data_with_error) - assert_equal 1, result - end - - it 'returns 0 for ok status' do - span_data = create_span_data - result = @processor.send(:error?, span_data) - assert_equal 0, result - end - end - - describe 'span_http?' do - it 'returns true for server span with http.method' do - span_data = OpenTelemetry::SDK::Trace::SpanData.new( - 'test', :internal, OpenTelemetry::Trace::Status.ok, - ("\0" * 8).b, 2, 2, 0, - 1_669_317_386_253_789_212, 1_669_317_386_298_642_087, - { 'http.method' => 'GET' }, nil, nil, - OpenTelemetry::SDK::Resources::Resource.create({}), - OpenTelemetry::SDK::InstrumentationScope.new('test', '1.0'), - Random.bytes(8), Random.bytes(16), - OpenTelemetry::Trace::TraceFlags.from_byte(0x01), - OpenTelemetry::Trace::Tracestate::DEFAULT - ) - # Override kind to SERVER - span_data.define_singleton_method(:kind) { ::OpenTelemetry::Trace::SpanKind::SERVER } - - result = @processor.send(:span_http?, span_data) - assert result - end - - it 'returns true for server span with http.request.method' do - span_data = OpenTelemetry::SDK::Trace::SpanData.new( - 'test', :internal, OpenTelemetry::Trace::Status.ok, - ("\0" * 8).b, 2, 2, 0, - 1_669_317_386_253_789_212, 1_669_317_386_298_642_087, - { 'http.request.method' => 'POST' }, nil, nil, - OpenTelemetry::SDK::Resources::Resource.create({}), - OpenTelemetry::SDK::InstrumentationScope.new('test', '1.0'), - Random.bytes(8), Random.bytes(16), - OpenTelemetry::Trace::TraceFlags.from_byte(0x01), - OpenTelemetry::Trace::Tracestate::DEFAULT - ) - span_data.define_singleton_method(:kind) { ::OpenTelemetry::Trace::SpanKind::SERVER } - - result = @processor.send(:span_http?, span_data) - assert result - end + describe 'error?' do + it 'returns 1 for error status' do + span_data = create_span_data + # Override status to error + error_status = OpenTelemetry::Trace::Status.error('error') + span_data_with_error = OpenTelemetry::SDK::Trace::SpanData.new( + 'test', :internal, error_status, + ("\0" * 8).b, 2, 2, 0, + 1_669_317_386_253_789_212, 1_669_317_386_298_642_087, + {}, nil, nil, + OpenTelemetry::SDK::Resources::Resource.create({}), + OpenTelemetry::SDK::InstrumentationScope.new('test', '1.0'), + Random.bytes(8), Random.bytes(16), + OpenTelemetry::Trace::TraceFlags.from_byte(0x01), + OpenTelemetry::Trace::Tracestate::DEFAULT + ) - it 'returns false for non-server span' do - span_data = create_span_data - result = @processor.send(:span_http?, span_data) - refute result - end + result = @processor.send(:error?, span_data_with_error) + assert_equal 1, result end + end - describe 'get_http_status_code' do - it 'returns http.response.status_code when present' do - span_data = OpenTelemetry::SDK::Trace::SpanData.new( - 'test', :internal, OpenTelemetry::Trace::Status.ok, - ("\0" * 8).b, 2, 2, 0, - 1_669_317_386_253_789_212, 1_669_317_386_298_642_087, - { 'http.response.status_code' => 201 }, nil, nil, - OpenTelemetry::SDK::Resources::Resource.create({}), - OpenTelemetry::SDK::InstrumentationScope.new('test', '1.0'), - Random.bytes(8), Random.bytes(16), - OpenTelemetry::Trace::TraceFlags.from_byte(0x01), - OpenTelemetry::Trace::Tracestate::DEFAULT - ) - - result = @processor.send(:get_http_status_code, span_data) - assert_equal 201, result - end - - it 'returns http.status_code fallback' do - span_data = OpenTelemetry::SDK::Trace::SpanData.new( - 'test', :internal, OpenTelemetry::Trace::Status.ok, - ("\0" * 8).b, 2, 2, 0, - 1_669_317_386_253_789_212, 1_669_317_386_298_642_087, - { 'http.status_code' => 404 }, nil, nil, - OpenTelemetry::SDK::Resources::Resource.create({}), - OpenTelemetry::SDK::InstrumentationScope.new('test', '1.0'), - Random.bytes(8), Random.bytes(16), - OpenTelemetry::Trace::TraceFlags.from_byte(0x01), - OpenTelemetry::Trace::Tracestate::DEFAULT - ) - - result = @processor.send(:get_http_status_code, span_data) - assert_equal 404, result - end - - it 'returns INVALID_HTTP_STATUS_CODE when no status code' do - span_data = create_span_data - result = @processor.send(:get_http_status_code, span_data) - assert_equal 0, result - end + describe 'get_http_status_code additional' do + it 'returns http.status_code fallback' do + span_data = OpenTelemetry::SDK::Trace::SpanData.new( + 'test', :internal, OpenTelemetry::Trace::Status.ok, + ("\0" * 8).b, 2, 2, 0, + 1_669_317_386_253_789_212, 1_669_317_386_298_642_087, + { 'http.status_code' => 404 }, nil, nil, + OpenTelemetry::SDK::Resources::Resource.create({}), + OpenTelemetry::SDK::InstrumentationScope.new('test', '1.0'), + Random.bytes(8), Random.bytes(16), + OpenTelemetry::Trace::TraceFlags.from_byte(0x01), + OpenTelemetry::Trace::Tracestate::DEFAULT + ) + result = @processor.send(:get_http_status_code, span_data) + assert_equal 404, result end describe 'non_entry_span' do @@ -673,31 +578,6 @@ assert_equal '/api/v1/users', result end - it 'falls back to span name when no http.route' do - span_context = OpenTelemetry::Trace::SpanContext.new( - span_id: Random.bytes(8), - trace_id: Random.bytes(16) - ) - span = OpenTelemetry::SDK::Trace::Span.new( - span_context, - OpenTelemetry::Context.empty, - OpenTelemetry::Trace::Span::INVALID, - 'my_span_name', - OpenTelemetry::Trace::SpanKind::SERVER, - nil, - OpenTelemetry::SDK::Trace::SpanLimits.new, - [], - {}, - nil, - Time.now, - nil, - nil - ) - - result = @processor.send(:calculate_transaction_names, span) - assert_equal 'my_span_name', result - end - it 'uses lambda transaction name in lambda mode' do @processor.instance_variable_set(:@is_lambda, true) ENV['AWS_LAMBDA_FUNCTION_NAME'] = 'my-lambda' @@ -740,28 +620,6 @@ end describe 'meter_attributes' do - it 'includes http status code and method for http spans' do - @processor.instance_variable_set(:@transaction_name, 'test_txn') - - span_data = OpenTelemetry::SDK::Trace::SpanData.new( - 'test', :internal, OpenTelemetry::Trace::Status.ok, - ("\0" * 8).b, 2, 2, 0, - 1_669_317_386_253_789_212, 1_669_317_386_298_642_087, - { 'http.method' => 'GET', 'http.status_code' => 200, 'sw.is_entry_span' => true }, nil, nil, - OpenTelemetry::SDK::Resources::Resource.create({}), - OpenTelemetry::SDK::InstrumentationScope.new('test', '1.0'), - Random.bytes(8), Random.bytes(16), - OpenTelemetry::Trace::TraceFlags.from_byte(0x01), - OpenTelemetry::Trace::Tracestate::DEFAULT - ) - span_data.define_singleton_method(:kind) { ::OpenTelemetry::Trace::SpanKind::SERVER } - - result = @processor.send(:meter_attributes, span_data) - assert_equal 200, result['http.status_code'] - assert_equal 'GET', result['http.method'] - assert_equal 'test_txn', result['sw.transaction'] - end - it 'uses http.request.method over http.method' do @processor.instance_variable_set(:@transaction_name, 'test_txn') diff --git a/test/opentelemetry/solarwinds_propagator_test.rb b/test/opentelemetry/solarwinds_propagator_test.rb index 3ff58276..6981db8b 100644 --- a/test/opentelemetry/solarwinds_propagator_test.rb +++ b/test/opentelemetry/solarwinds_propagator_test.rb @@ -15,6 +15,7 @@ describe 'SolarWindsPropagatorTest' do before do @text_map_propagator = SolarWindsAPM::OpenTelemetry::SolarWindsPropagator::TextMapPropagator.new + @propagator = @text_map_propagator @mock = Minitest::Mock.new end @@ -119,12 +120,6 @@ _(@mock.verify).must_equal true end -end - -describe 'SolarWindsPropagator extract x-trace-options and inject sw tracestate' do - before do - @propagator = SolarWindsAPM::OpenTelemetry::SolarWindsPropagator::TextMapPropagator.new - end describe 'extract' do it 'extracts x-trace-options header into context' do @@ -134,17 +129,6 @@ assert_equal 'trigger-trace;ts=12345', context.value('sw_xtraceoptions') end - it 'extracts x-trace-options-signature header into context' do - carrier = { - 'x-trace-options' => 'trigger-trace', - 'x-trace-options-signature' => 'abc123' - } - context = @propagator.extract(carrier, context: OpenTelemetry::Context.empty) - - assert_equal 'trigger-trace', context.value('sw_xtraceoptions') - assert_equal 'abc123', context.value('sw_signature') - end - it 'returns context unchanged when no headers present' do carrier = {} original_context = OpenTelemetry::Context.empty diff --git a/test/sampling/dice_test.rb b/test/sampling/dice_test.rb index a1a395ef..6ef399af 100644 --- a/test/sampling/dice_test.rb +++ b/test/sampling/dice_test.rb @@ -50,9 +50,7 @@ dice.update(rate: 0) 500.times { refute dice.roll } end -end -describe 'Dice rate clamping, update behavior, and default scale' do it 'rate setter clamps to scale' do dice = SolarWindsAPM::Dice.new(scale: 100, rate: 50) dice.rate = 200 @@ -75,51 +73,3 @@ assert_equal 0, dice.rate end end - -describe 'TokenBucket accessors and update with various input types and edge cases' do - it 'tokens accessor returns current tokens' do - bucket = SolarWindsAPM::TokenBucket.new(SolarWindsAPM::TokenBucketSettings.new(5, 1, 'test')) - tokens = bucket.tokens - assert tokens <= 5 - assert tokens >= 0 - end - - it 'type accessor returns type' do - bucket = SolarWindsAPM::TokenBucket.new(SolarWindsAPM::TokenBucketSettings.new(5, 1, 'MY_TYPE')) - assert_equal 'MY_TYPE', bucket.type - end - - it 'update with TokenBucketSettings object' do - bucket = SolarWindsAPM::TokenBucket.new(SolarWindsAPM::TokenBucketSettings.new(5, 1, 'test')) - new_settings = SolarWindsAPM::TokenBucketSettings.new(10, 2, 'test') - bucket.update(new_settings) - assert_equal 10, bucket.capacity - assert_equal 2, bucket.rate - end - - it 'update with hash' do - bucket = SolarWindsAPM::TokenBucket.new(SolarWindsAPM::TokenBucketSettings.new(5, 1, 'test')) - bucket.update({ capacity: 20, rate: 5 }) - assert_equal 20, bucket.capacity - assert_equal 5, bucket.rate - end - - it 'update handles only rate change' do - bucket = SolarWindsAPM::TokenBucket.new(SolarWindsAPM::TokenBucketSettings.new(5, 1, 'test')) - bucket.update({ rate: 10 }) - assert_equal 5, bucket.capacity - assert_equal 10, bucket.rate - end - - it 'update handles negative rate gracefully' do - bucket = SolarWindsAPM::TokenBucket.new(SolarWindsAPM::TokenBucketSettings.new(5, 1, 'test')) - bucket.update({ rate: -5 }) - assert_equal 0, bucket.rate - end - - it 'update handles negative capacity gracefully' do - bucket = SolarWindsAPM::TokenBucket.new(SolarWindsAPM::TokenBucketSettings.new(5, 0, 'test')) - bucket.update({ capacity: -5 }) - assert_equal 0, bucket.capacity - end -end diff --git a/test/sampling/json_sampler_test.rb b/test/sampling/json_sampler_test.rb index 870d2c39..2f3469d6 100644 --- a/test/sampling/json_sampler_test.rb +++ b/test/sampling/json_sampler_test.rb @@ -24,6 +24,7 @@ after do OpenTelemetry::TestHelpers.reset_opentelemetry @memory_exporter.reset + FileUtils.rm_f(@temp_path) end describe 'valid file' do @@ -144,31 +145,6 @@ assert_equal span.attributes.keys, %w[SampleRate SampleSource BucketCapacity BucketRate] end end -end - -describe 'JsonSampler settings file reading with malformed/missing/expired input handling' do - before do - @temp_path = '/tmp/solarwinds-apm-test-settings.json' - ENV['OTEL_TRACES_EXPORTER'] = 'none' - OpenTelemetry::SDK.configure - - @memory_exporter = OpenTelemetry::SDK::Trace::Export::InMemorySpanExporter.new - OpenTelemetry.tracer_provider.add_span_processor( - OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(@memory_exporter) - ) - end - - after do - OpenTelemetry::TestHelpers.reset_opentelemetry - @memory_exporter.reset - File.delete(@temp_path) if File.exist?(@temp_path) - end - - it 'handles missing settings file gracefully' do - File.delete(@temp_path) if File.exist?(@temp_path) - sampler = SolarWindsAPM::JsonSampler.new({}, @temp_path) - refute_nil sampler - end it 'handles invalid JSON file content' do File.write(@temp_path, 'not valid json{{{') @@ -188,12 +164,6 @@ refute_nil sampler end - it 'handles non-array in settings file' do - File.write(@temp_path, JSON.dump({ 'key' => 'value' })) - sampler = SolarWindsAPM::JsonSampler.new({}, @temp_path) - refute_nil sampler - end - it 'skips loop_check when settings not expired' do File.write(@temp_path, JSON.dump([ { diff --git a/test/sampling/oboe_sampler_test.rb b/test/sampling/oboe_sampler_test.rb index a0e2d594..e4a81c03 100644 --- a/test/sampling/oboe_sampler_test.rb +++ b/test/sampling/oboe_sampler_test.rb @@ -795,138 +795,8 @@ check_counters(@metric_exporter, ['trace.service.request_count']) end end -end - -# BUNDLE_GEMFILE=gemfiles/unit.gemfile bundle exec ruby -I test test/sampling/oboe_sampler_test.rb -n /spanType/ -describe 'SolarWindsAPM OboeSampler Test' do - describe 'spanType' do - it 'identifies no parent as ROOT' do - type = SolarWindsAPM::SpanType.span_type(nil) - assert_equal SolarWindsAPM::SpanType::ROOT, type - end - - # isSpanContextValid may have more restrict then ruby valid? - # js isSpanContextValid test if trace_id and span_id is valid format and not invalid like 00000... - # need to have our own isSpanContextValid function - it 'identifies invalid parent as ROOT' do - parent = make_span({ id: 'woops' }) - - type = SolarWindsAPM::SpanType.span_type(parent) - assert_equal SolarWindsAPM::SpanType::ROOT, type - end - - it 'identifies remote parent as ENTRY' do - parent = make_span({ remote: true }) - - type = SolarWindsAPM::SpanType.span_type(parent) - assert_equal SolarWindsAPM::SpanType::ENTRY, type - end - - it 'identifies local parent as LOCAL' do - parent = make_span({ remote: false }) - - type = SolarWindsAPM::SpanType.span_type(parent) - assert_equal SolarWindsAPM::SpanType::LOCAL, type - end - end -end - -describe 'OboeSampler sampling algorithms (parent-based, trigger-trace, dice-roll, disabled) and settings lifecycle' do - before do - OpenTelemetry::SDK.configure - @metric_exporter = OpenTelemetry::SDK::Metrics::Export::InMemoryMetricPullExporter.new - OpenTelemetry.meter_provider.add_metric_reader(@metric_exporter) - end describe 'parent_based_algo' do - it 'records and samples when SAMPLE_THROUGH_ALWAYS set and parent sampled' do - sampler = OboeTestSampler.new( - settings: { - sample_rate: 1_000_000, - sample_source: SolarWindsAPM::SampleSource::REMOTE, - flags: SolarWindsAPM::Flags::SAMPLE_START | SolarWindsAPM::Flags::SAMPLE_THROUGH_ALWAYS, - buckets: {}, - timestamp: Time.now.to_i, - ttl: 120, - signature_key: nil - }, - local_settings: { tracing_mode: SolarWindsAPM::TracingMode::ALWAYS }, - request_headers: {} - ) - - parent = make_span({ remote: true, sampled: true, sw: true }) - params = make_sample_params(parent: parent) - - result = sampler.should_sample?(params) - assert_equal TEST_OTEL_SAMPLING_DECISION::RECORD_AND_SAMPLE, result.instance_variable_get(:@decision) - end - - it 'records only when SAMPLE_THROUGH_ALWAYS set and parent not sampled' do - sampler = OboeTestSampler.new( - settings: { - sample_rate: 1_000_000, - sample_source: SolarWindsAPM::SampleSource::REMOTE, - flags: SolarWindsAPM::Flags::SAMPLE_START | SolarWindsAPM::Flags::SAMPLE_THROUGH_ALWAYS, - buckets: {}, - timestamp: Time.now.to_i, - ttl: 120, - signature_key: nil - }, - local_settings: { tracing_mode: SolarWindsAPM::TracingMode::ALWAYS }, - request_headers: {} - ) - - parent = make_span({ remote: true, sampled: false, sw: true }) - params = make_sample_params(parent: parent) - - result = sampler.should_sample?(params) - assert_equal TEST_OTEL_SAMPLING_DECISION::RECORD_ONLY, result.instance_variable_get(:@decision) - end - - it 'drops when SAMPLE_THROUGH_ALWAYS unset and SAMPLE_START unset' do - sampler = OboeTestSampler.new( - settings: { - sample_rate: 1_000_000, - sample_source: SolarWindsAPM::SampleSource::REMOTE, - flags: SolarWindsAPM::Flags::OK, - buckets: {}, - timestamp: Time.now.to_i, - ttl: 120, - signature_key: nil - }, - local_settings: { tracing_mode: SolarWindsAPM::TracingMode::NEVER }, - request_headers: {} - ) - - parent = make_span({ remote: true, sampled: true, sw: true }) - params = make_sample_params(parent: parent) - - result = sampler.should_sample?(params) - assert_equal TEST_OTEL_SAMPLING_DECISION::DROP, result.instance_variable_get(:@decision) - end - - it 'records only when SAMPLE_THROUGH_ALWAYS unset and SAMPLE_START set' do - sampler = OboeTestSampler.new( - settings: { - sample_rate: 1_000_000, - sample_source: SolarWindsAPM::SampleSource::REMOTE, - flags: SolarWindsAPM::Flags::SAMPLE_START, - buckets: {}, - timestamp: Time.now.to_i, - ttl: 120, - signature_key: nil - }, - local_settings: { tracing_mode: nil }, - request_headers: {} - ) - - parent = make_span({ remote: true, sampled: true, sw: true }) - params = make_sample_params(parent: parent) - - result = sampler.should_sample?(params) - assert_equal TEST_OTEL_SAMPLING_DECISION::RECORD_ONLY, result.instance_variable_get(:@decision) - end - it 'ignores trigger trace in parent-based algo' do sig_key = SecureRandom.random_bytes(8) headers = make_request_headers(trigger_trace: true, signature: true, signature_key: sig_key) @@ -1017,267 +887,9 @@ assert_equal TEST_OTEL_SAMPLING_DECISION::RECORD_AND_SAMPLE, result.instance_variable_get(:@decision) assert_equal true, result.attributes['TriggeredTrace'] end - - it 'records only when TRIGGERED_TRACE flag unset' do - headers = make_request_headers(trigger_trace: true) - - sampler = OboeTestSampler.new( - settings: { - sample_rate: 0, - sample_source: SolarWindsAPM::SampleSource::REMOTE, - flags: SolarWindsAPM::Flags::SAMPLE_START, - buckets: {}, - timestamp: Time.now.to_i, - ttl: 120, - signature_key: nil - }, - local_settings: { tracing_mode: SolarWindsAPM::TracingMode::ALWAYS }, - request_headers: headers - ) - - parent = make_span({ remote: true, sampled: false }) - params = make_sample_params(parent: parent) - - result = sampler.should_sample?(params) - assert_equal TEST_OTEL_SAMPLING_DECISION::RECORD_ONLY, result.instance_variable_get(:@decision) - assert_includes result.tracestate['xtrace_options_response'], 'trigger-trace:trigger-tracing-disabled' - end - - it 'records only when trigger trace bucket rate exceeded' do - headers = make_request_headers(trigger_trace: true) - - sampler = OboeTestSampler.new( - settings: { - sample_rate: 0, - sample_source: SolarWindsAPM::SampleSource::REMOTE, - flags: SolarWindsAPM::Flags::SAMPLE_START | SolarWindsAPM::Flags::TRIGGERED_TRACE, - buckets: { - SolarWindsAPM::BucketType::DEFAULT => { capacity: 0, rate: 0 }, - SolarWindsAPM::BucketType::TRIGGER_RELAXED => { capacity: 0, rate: 0 }, - SolarWindsAPM::BucketType::TRIGGER_STRICT => { capacity: 0, rate: 0 } - }, - timestamp: Time.now.to_i, - ttl: 120, - signature_key: nil - }, - local_settings: { tracing_mode: SolarWindsAPM::TracingMode::ALWAYS, trigger_mode: :enabled }, - request_headers: headers - ) - - parent = make_span({ remote: true, sampled: false }) - params = make_sample_params(parent: parent) - - result = sampler.should_sample?(params) - assert_equal TEST_OTEL_SAMPLING_DECISION::RECORD_ONLY, result.instance_variable_get(:@decision) - assert_includes result.tracestate['xtrace_options_response'], 'trigger-trace:rate-exceeded' - end - end - - describe 'dice_roll_algo' do - it 'records and samples when dice roll succeeds and bucket has capacity' do - sampler = OboeTestSampler.new( - settings: { - sample_rate: 1_000_000, - sample_source: SolarWindsAPM::SampleSource::REMOTE, - flags: SolarWindsAPM::Flags::SAMPLE_START | SolarWindsAPM::Flags::SAMPLE_THROUGH_ALWAYS, - buckets: { - SolarWindsAPM::BucketType::DEFAULT => { capacity: 100, rate: 10 }, - SolarWindsAPM::BucketType::TRIGGER_RELAXED => { capacity: 100, rate: 10 }, - SolarWindsAPM::BucketType::TRIGGER_STRICT => { capacity: 100, rate: 10 } - }, - timestamp: Time.now.to_i, - ttl: 120, - signature_key: nil - }, - local_settings: { tracing_mode: SolarWindsAPM::TracingMode::ALWAYS }, - request_headers: {} - ) - - parent = make_span({ remote: true, sampled: false }) - params = make_sample_params(parent: parent) - - result = sampler.should_sample?(params) - assert_equal TEST_OTEL_SAMPLING_DECISION::RECORD_AND_SAMPLE, result.instance_variable_get(:@decision) - assert_equal 1_000_000, result.attributes['SampleRate'] - assert_equal SolarWindsAPM::SampleSource::REMOTE, result.attributes['SampleSource'] - end - - it 'records only when dice roll succeeds but bucket exhausted' do - sampler = OboeTestSampler.new( - settings: { - sample_rate: 1_000_000, - sample_source: SolarWindsAPM::SampleSource::REMOTE, - flags: SolarWindsAPM::Flags::SAMPLE_START | SolarWindsAPM::Flags::SAMPLE_THROUGH_ALWAYS, - buckets: { - SolarWindsAPM::BucketType::DEFAULT => { capacity: 0, rate: 0 }, - SolarWindsAPM::BucketType::TRIGGER_RELAXED => { capacity: 100, rate: 10 }, - SolarWindsAPM::BucketType::TRIGGER_STRICT => { capacity: 100, rate: 10 } - }, - timestamp: Time.now.to_i, - ttl: 120, - signature_key: nil - }, - local_settings: { tracing_mode: SolarWindsAPM::TracingMode::ALWAYS }, - request_headers: {} - ) - - parent = make_span({ remote: true, sampled: false }) - params = make_sample_params(parent: parent) - - result = sampler.should_sample?(params) - assert_equal TEST_OTEL_SAMPLING_DECISION::RECORD_ONLY, result.instance_variable_get(:@decision) - end - - it 'records only when dice roll fails' do - sampler = OboeTestSampler.new( - settings: { - sample_rate: 0, - sample_source: SolarWindsAPM::SampleSource::REMOTE, - flags: SolarWindsAPM::Flags::SAMPLE_START | SolarWindsAPM::Flags::SAMPLE_THROUGH_ALWAYS, - buckets: { - SolarWindsAPM::BucketType::DEFAULT => { capacity: 100, rate: 10 }, - SolarWindsAPM::BucketType::TRIGGER_RELAXED => { capacity: 100, rate: 10 }, - SolarWindsAPM::BucketType::TRIGGER_STRICT => { capacity: 100, rate: 10 } - }, - timestamp: Time.now.to_i, - ttl: 120, - signature_key: nil - }, - local_settings: { tracing_mode: SolarWindsAPM::TracingMode::ALWAYS }, - request_headers: {} - ) - - parent = make_span({ remote: true, sampled: false }) - params = make_sample_params(parent: parent) - - result = sampler.should_sample?(params) - assert_equal TEST_OTEL_SAMPLING_DECISION::RECORD_ONLY, result.instance_variable_get(:@decision) - end - end - - describe 'disabled_algo' do - it 'drops when SAMPLE_THROUGH_ALWAYS unset' do - sampler = OboeTestSampler.new( - settings: { - sample_rate: 0, - sample_source: SolarWindsAPM::SampleSource::REMOTE, - flags: SolarWindsAPM::Flags::OK, - buckets: {}, - timestamp: Time.now.to_i, - ttl: 120, - signature_key: nil - }, - local_settings: { tracing_mode: SolarWindsAPM::TracingMode::NEVER }, - request_headers: {} - ) - - parent = make_span({ remote: true, sampled: false }) - params = make_sample_params(parent: parent) - - result = sampler.should_sample?(params) - assert_equal TEST_OTEL_SAMPLING_DECISION::DROP, result.instance_variable_get(:@decision) - end - - it 'records only when SAMPLE_THROUGH_ALWAYS set' do - sampler = OboeTestSampler.new( - settings: { - sample_rate: 0, - sample_source: SolarWindsAPM::SampleSource::REMOTE, - flags: SolarWindsAPM::Flags::SAMPLE_THROUGH_ALWAYS, - buckets: {}, - timestamp: Time.now.to_i, - ttl: 120, - signature_key: nil - }, - local_settings: { tracing_mode: nil }, - request_headers: {} - ) - - parent = make_span({ remote: true, sampled: false }) - params = make_sample_params(parent: parent) - - result = sampler.should_sample?(params) - assert_equal TEST_OTEL_SAMPLING_DECISION::RECORD_ONLY, result.instance_variable_get(:@decision) - end - - it 'reports tracing disabled for trigger trace in disabled algo' do - headers = make_request_headers(trigger_trace: true) - - sampler = OboeTestSampler.new( - settings: { - sample_rate: 0, - sample_source: SolarWindsAPM::SampleSource::REMOTE, - flags: SolarWindsAPM::Flags::OK, - buckets: {}, - timestamp: Time.now.to_i, - ttl: 120, - signature_key: nil - }, - local_settings: { tracing_mode: SolarWindsAPM::TracingMode::NEVER }, - request_headers: headers - ) - - parent = make_span({ remote: true, sampled: false }) - params = make_sample_params(parent: parent) - - result = sampler.should_sample?(params) - assert_equal TEST_OTEL_SAMPLING_DECISION::DROP, result.instance_variable_get(:@decision) - assert_includes result.tracestate['xtrace_options_response'], 'trigger-trace:tracing-disabled' - end end describe 'settings management' do - it 'drops when settings are unavailable' do - sampler = OboeTestSampler.new( - local_settings: { tracing_mode: SolarWindsAPM::TracingMode::ALWAYS }, - request_headers: {} - ) - - parent = make_span({ remote: true, sampled: false }) - params = make_sample_params(parent: parent) - - result = sampler.should_sample?(params) - assert_equal TEST_OTEL_SAMPLING_DECISION::DROP, result.instance_variable_get(:@decision) - end - - it 'reports settings not available for trigger trace when settings missing' do - headers = make_request_headers(trigger_trace: true) - - sampler = OboeTestSampler.new( - local_settings: { tracing_mode: SolarWindsAPM::TracingMode::ALWAYS }, - request_headers: headers - ) - - parent = make_span({ remote: true, sampled: false }) - params = make_sample_params(parent: parent) - - result = sampler.should_sample?(params) - assert_equal TEST_OTEL_SAMPLING_DECISION::DROP, result.instance_variable_get(:@decision) - assert_includes result.tracestate['xtrace_options_response'], 'trigger-trace:settings-not-available' - end - - it 'expires old settings' do - sampler = OboeTestSampler.new( - settings: { - sample_rate: 1_000_000, - sample_source: SolarWindsAPM::SampleSource::REMOTE, - flags: SolarWindsAPM::Flags::SAMPLE_START | SolarWindsAPM::Flags::SAMPLE_THROUGH_ALWAYS, - buckets: {}, - timestamp: Time.now.to_i - 200, - ttl: 10, - signature_key: nil - }, - local_settings: { tracing_mode: SolarWindsAPM::TracingMode::ALWAYS }, - request_headers: {} - ) - - parent = make_span({ remote: true, sampled: false }) - params = make_sample_params(parent: parent) - - result = sampler.should_sample?(params) - assert_equal TEST_OTEL_SAMPLING_DECISION::DROP, result.instance_variable_get(:@decision) - end - it 'rejects older settings' do sampler = OboeTestSampler.new( settings: { @@ -1427,6 +1039,34 @@ end end + describe 'spanType' do + it 'identifies no parent as ROOT' do + type = SolarWindsAPM::SpanType.span_type(nil) + assert_equal SolarWindsAPM::SpanType::ROOT, type + end + + it 'identifies invalid parent as ROOT' do + parent = make_span({ id: 'woops' }) + + type = SolarWindsAPM::SpanType.span_type(parent) + assert_equal SolarWindsAPM::SpanType::ROOT, type + end + + it 'identifies remote parent as ENTRY' do + parent = make_span({ remote: true }) + + type = SolarWindsAPM::SpanType.span_type(parent) + assert_equal SolarWindsAPM::SpanType::ENTRY, type + end + + it 'identifies local parent as LOCAL' do + parent = make_span({ remote: false }) + + type = SolarWindsAPM::SpanType.span_type(parent) + assert_equal SolarWindsAPM::SpanType::LOCAL, type + end + end + describe 'sw_from_span_and_decision' do it 'formats span_id-01 for RECORD_AND_SAMPLE' do sampler = OboeTestSampler.new( diff --git a/test/sampling/sampler_test.rb b/test/sampling/sampler_test.rb index 0a09bd10..2047e5a5 100644 --- a/test/sampling/sampler_test.rb +++ b/test/sampling/sampler_test.rb @@ -465,47 +465,12 @@ def replace_sampler(sampler) _(spans[0].attributes['BucketRate']).must_equal 0.1 end end -end -describe 'Sampler settings parsing, tracing mode resolution, HTTP metadata, and readiness' do describe 'parse_settings' do before do @sampler = TestSampler.new({ local_settings: {} }) end - it 'parses valid settings with all fields' do - unparsed = { - 'value' => 500_000, - 'timestamp' => Time.now.to_i, - 'ttl' => 120, - 'flags' => 'SAMPLE_START,SAMPLE_THROUGH_ALWAYS,TRIGGER_TRACE,OVERRIDE', - 'arguments' => { - 'BucketCapacity' => 100, - 'BucketRate' => 10, - 'TriggerRelaxedBucketCapacity' => 50, - 'TriggerRelaxedBucketRate' => 5, - 'TriggerStrictBucketCapacity' => 25, - 'TriggerStrictBucketRate' => 2, - 'SignatureKey' => 'test-key' - }, - 'warning' => 'Test warning message' - } - - result = @sampler.parse_settings(unparsed) - refute_nil result - assert_equal 500_000, result[:sample_rate] - assert_equal SolarWindsAPM::SampleSource::REMOTE, result[:sample_source] - assert result[:flags].anybits?(SolarWindsAPM::Flags::SAMPLE_START) - assert result[:flags].anybits?(SolarWindsAPM::Flags::SAMPLE_THROUGH_ALWAYS) - assert result[:flags].anybits?(SolarWindsAPM::Flags::TRIGGERED_TRACE) - assert result[:flags].anybits?(SolarWindsAPM::Flags::OVERRIDE) - assert_equal 'test-key', result[:signature_key] - assert_equal 'Test warning message', result[:warning] - assert_equal({ capacity: 100, rate: 10 }, result[:buckets][SolarWindsAPM::BucketType::DEFAULT]) - assert_equal({ capacity: 50, rate: 5 }, result[:buckets]['trigger_relaxed']) - assert_equal({ capacity: 25, rate: 2 }, result[:buckets]['trigger_strict']) - end - it 'returns nil for non-hash input' do assert_nil @sampler.parse_settings('not a hash') assert_nil @sampler.parse_settings(nil) @@ -658,57 +623,11 @@ def replace_sampler(sampler) end end - describe 'http_span_metadata' do + describe 'http_span_metadata additional' do before do @sampler = TestSampler.new({ local_settings: {} }) end - it 'returns http false for non-server spans' do - result = @sampler.http_span_metadata(OpenTelemetry::Trace::SpanKind::CLIENT, - { 'http.request.method' => 'GET' }) - assert_equal({ http: false }, result) - end - - it 'returns http false for server spans without http method' do - result = @sampler.http_span_metadata(OpenTelemetry::Trace::SpanKind::SERVER, - { 'network.transport' => 'udp' }) - assert_equal({ http: false }, result) - end - - it 'returns full metadata for new semconv server http spans' do - attrs = { - 'http.request.method' => 'POST', - 'http.response.status_code' => 201, - 'url.scheme' => 'https', - 'server.address' => 'example.com', - 'url.path' => '/api/v1/items' - } - - result = @sampler.http_span_metadata(OpenTelemetry::Trace::SpanKind::SERVER, attrs) - assert result[:http] - assert_equal 'POST', result[:method] - assert_equal 201, result[:status] - assert_equal 'https', result[:scheme] - assert_equal 'example.com', result[:hostname] - assert_equal '/api/v1/items', result[:path] - assert_equal 'https://example.com/api/v1/items', result[:url] - end - - it 'returns full metadata for old semconv server http spans' do - attrs = { - OpenTelemetry::SemanticConventions::Trace::HTTP_METHOD => 'GET', - OpenTelemetry::SemanticConventions::Trace::HTTP_STATUS_CODE => 200, - OpenTelemetry::SemanticConventions::Trace::HTTP_SCHEME => 'http', - OpenTelemetry::SemanticConventions::Trace::NET_HOST_NAME => 'old.example.com', - OpenTelemetry::SemanticConventions::Trace::HTTP_TARGET => '/old/path' - } - - result = @sampler.http_span_metadata(OpenTelemetry::Trace::SpanKind::SERVER, attrs) - assert result[:http] - assert_equal 'GET', result[:method] - assert_equal 200, result[:status] - end - it 'uses defaults when attributes are missing' do attrs = { 'http.request.method' => 'GET' } result = @sampler.http_span_metadata(OpenTelemetry::Trace::SpanKind::SERVER, attrs) diff --git a/test/sampling/settings_test.rb b/test/sampling/settings_test.rb index 5958303e..eff96243 100644 --- a/test/sampling/settings_test.rb +++ b/test/sampling/settings_test.rb @@ -121,10 +121,8 @@ end end end -end -describe 'SamplingSettings.merge remote/local flag merging and tracing mode precedence' do - describe 'merge' do + describe 'merge additional' do it 'merges remote and local settings with trigger mode enabled' do remote = { sample_rate: 500_000, @@ -195,9 +193,7 @@ assert result[:flags].anybits?(SolarWindsAPM::Flags::SAMPLE_THROUGH_ALWAYS) end end -end -describe 'SpanType classification (ROOT/ENTRY/LOCAL) and trace_id/span_id validation' do describe 'SpanType' do it 'returns ROOT for nil parent span context' do result = SolarWindsAPM::SpanType.span_type(nil) diff --git a/test/sampling/token_bucket_test.rb b/test/sampling/token_bucket_test.rb index 52de33c0..c5aa0979 100644 --- a/test/sampling/token_bucket_test.rb +++ b/test/sampling/token_bucket_test.rb @@ -117,4 +117,50 @@ assert bucket.tokens >= 0 assert bucket.tokens <= bucket.capacity end + + it 'tokens accessor returns current tokens' do + bucket = SolarWindsAPM::TokenBucket.new(SolarWindsAPM::TokenBucketSettings.new(5, 1, 'test')) + tokens = bucket.tokens + assert tokens <= 5 + assert tokens >= 0 + end + + it 'type accessor returns type' do + bucket = SolarWindsAPM::TokenBucket.new(SolarWindsAPM::TokenBucketSettings.new(5, 1, 'MY_TYPE')) + assert_equal 'MY_TYPE', bucket.type + end + + it 'update with TokenBucketSettings object' do + bucket = SolarWindsAPM::TokenBucket.new(SolarWindsAPM::TokenBucketSettings.new(5, 1, 'test')) + new_settings = SolarWindsAPM::TokenBucketSettings.new(10, 2, 'test') + bucket.update(new_settings) + assert_equal 10, bucket.capacity + assert_equal 2, bucket.rate + end + + it 'update with hash' do + bucket = SolarWindsAPM::TokenBucket.new(SolarWindsAPM::TokenBucketSettings.new(5, 1, 'test')) + bucket.update({ capacity: 20, rate: 5 }) + assert_equal 20, bucket.capacity + assert_equal 5, bucket.rate + end + + it 'update handles only rate change' do + bucket = SolarWindsAPM::TokenBucket.new(SolarWindsAPM::TokenBucketSettings.new(5, 1, 'test')) + bucket.update({ rate: 10 }) + assert_equal 5, bucket.capacity + assert_equal 10, bucket.rate + end + + it 'update handles negative rate gracefully' do + bucket = SolarWindsAPM::TokenBucket.new(SolarWindsAPM::TokenBucketSettings.new(5, 1, 'test')) + bucket.update({ rate: -5 }) + assert_equal 0, bucket.rate + end + + it 'update handles negative capacity gracefully' do + bucket = SolarWindsAPM::TokenBucket.new(SolarWindsAPM::TokenBucketSettings.new(5, 0, 'test')) + bucket.update({ capacity: -5 }) + assert_equal 0, bucket.capacity + end end diff --git a/test/sampling/trace_options_test.rb b/test/sampling/trace_options_test.rb index 0ed497f4..ba5ff41b 100644 --- a/test/sampling/trace_options_test.rb +++ b/test/sampling/trace_options_test.rb @@ -222,6 +222,16 @@ _(result.custom['custom-something']).must_equal 'value_thing' _(result.ignored).must_equal [['1', nil], ['2', nil], ['3', nil], ['4', nil], ['5', nil]] end + + it 'parses all option types in single header' do + header = "trigger-trace;sw-keys=check-id:123;custom-foo=bar;ts=#{Time.now.to_i}" + result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) + + assert result.trigger_trace + assert_equal 'check-id:123', result.sw_keys + assert_equal 'bar', result.custom['custom-foo'] + refute_nil result.timestamp + end end describe 'stringifyTraceOptionsResponse' do @@ -234,6 +244,23 @@ result = SolarWindsAPM::TraceOptions.stringify_trace_options_response(SolarWindsAPM::TraceOptionsResponse.new(SolarWindsAPM::Auth::OK, SolarWindsAPM::TriggerTrace::TRIGGER_TRACING_DISABLED, %w[invalid-key1 invalid_key2])) _(result).must_equal('auth:ok;trigger-trace:trigger-tracing-disabled;ignored:invalid-key1,invalid_key2') end + + it 'returns nil for nil response' do + assert_nil SolarWindsAPM::TraceOptions.stringify_trace_options_response(nil) + end + + it 'omits nil fields' do + response = SolarWindsAPM::TraceOptionsResponse.new(nil, 'ok', []) + result = SolarWindsAPM::TraceOptions.stringify_trace_options_response(response) + refute_includes result, 'auth' + assert_includes result, 'trigger-trace:ok' + end + + it 'returns empty string when all nil and empty' do + response = SolarWindsAPM::TraceOptionsResponse.new(nil, nil, []) + result = SolarWindsAPM::TraceOptions.stringify_trace_options_response(response) + assert_equal '', result + end end describe 'validateSignature' do @@ -268,169 +295,16 @@ end end -describe 'TraceOptions signature validation, response stringification, and header parsing' do - let(:logger) { Logger.new($stdout) } - - describe 'validate_signature' do - it 'returns ok for valid signature' do - key = SecureRandom.random_bytes(16) - header = 'trigger-trace;ts=12345' - timestamp = Time.now.to_i - digest = OpenSSL::HMAC.hexdigest('SHA1', key, header) - - result = SolarWindsAPM::TraceOptions.validate_signature(header, digest, key, timestamp) - assert_equal SolarWindsAPM::Auth::OK, result - end - - it 'returns no-signature-key when key is nil' do - result = SolarWindsAPM::TraceOptions.validate_signature('header', 'sig', nil, Time.now.to_i) - assert_equal SolarWindsAPM::Auth::NO_SIGNATURE_KEY, result - end - - it 'returns bad-timestamp when timestamp is nil' do - result = SolarWindsAPM::TraceOptions.validate_signature('header', 'sig', 'key', nil) - assert_equal SolarWindsAPM::Auth::BAD_TIMESTAMP, result - end - - it 'returns bad-timestamp when timestamp is too old' do - old_timestamp = Time.now.to_i - (6 * 60) - result = SolarWindsAPM::TraceOptions.validate_signature('header', 'sig', 'key', old_timestamp) - assert_equal SolarWindsAPM::Auth::BAD_TIMESTAMP, result - end - - it 'returns bad-signature for wrong signature' do - key = SecureRandom.random_bytes(16) - result = SolarWindsAPM::TraceOptions.validate_signature('header', 'wrong_signature', key, Time.now.to_i) - assert_equal SolarWindsAPM::Auth::BAD_SIGNATURE, result - end - end - - describe 'stringify_trace_options_response' do - it 'returns nil for nil response' do - assert_nil SolarWindsAPM::TraceOptions.stringify_trace_options_response(nil) - end - - it 'stringifies auth and trigger trace' do - response = SolarWindsAPM::TraceOptionsResponse.new('ok', 'ok', []) - result = SolarWindsAPM::TraceOptions.stringify_trace_options_response(response) - assert_includes result, 'auth:ok' - assert_includes result, 'trigger-trace:ok' - end - - it 'stringifies ignored keys' do - response = SolarWindsAPM::TraceOptionsResponse.new(nil, nil, %w[key1 key2]) - result = SolarWindsAPM::TraceOptions.stringify_trace_options_response(response) - assert_includes result, 'ignored:key1,key2' - end - - it 'omits nil fields' do - response = SolarWindsAPM::TraceOptionsResponse.new(nil, 'ok', []) - result = SolarWindsAPM::TraceOptions.stringify_trace_options_response(response) - refute_includes result, 'auth' - assert_includes result, 'trigger-trace:ok' - end - - it 'returns empty string when all nil and empty' do - response = SolarWindsAPM::TraceOptionsResponse.new(nil, nil, []) - result = SolarWindsAPM::TraceOptions.stringify_trace_options_response(response) - assert_equal '', result - end - end - - describe 'numeric_integer?' do - it 'returns true for valid integer strings' do - assert SolarWindsAPM::TraceOptions.numeric_integer?('12345') - assert SolarWindsAPM::TraceOptions.numeric_integer?('-1') - assert SolarWindsAPM::TraceOptions.numeric_integer?('0') - end - - it 'returns false for non-integer strings' do - refute SolarWindsAPM::TraceOptions.numeric_integer?('abc') - refute SolarWindsAPM::TraceOptions.numeric_integer?('12.34') - refute SolarWindsAPM::TraceOptions.numeric_integer?('') - end - end - - describe 'parse_trace_options' do - it 'parses all option types in single header' do - header = "trigger-trace;sw-keys=check-id:123;custom-foo=bar;ts=#{Time.now.to_i}" - result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) - - assert result.trigger_trace - assert_equal 'check-id:123', result.sw_keys - assert_equal 'bar', result.custom['custom-foo'] - refute_nil result.timestamp - end - - it 'ignores duplicate sw-keys' do - header = 'sw-keys=first;sw-keys=second' - result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) - - assert_equal 'first', result.sw_keys - assert_equal 1, result.ignored.size - end - - it 'ignores duplicate custom keys' do - header = 'custom-key=first;custom-key=second' - result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) - - assert_equal 'first', result.custom['custom-key'] - assert_equal 1, result.ignored.size - end - - it 'ignores unknown keys' do - header = 'unknown-key=value' - result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) - - assert_equal 1, result.ignored.size - assert_equal 'unknown-key', result.ignored[0][0] - end - - it 'ignores sw-keys with nil value' do - header = 'sw-keys' - result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) - - assert_nil result.sw_keys - assert_equal 1, result.ignored.size - end - - it 'ignores custom key with nil value' do - header = 'custom-key' - result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) - - assert_empty result.custom - assert_equal 1, result.ignored.size - end - - it 'ignores non-integer timestamp' do - header = 'ts=notanumber' - result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) - - assert_nil result.timestamp - assert_equal 1, result.ignored.size - end - - it 'ignores duplicate timestamps' do - header = 'ts=100;ts=200' - result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) - - assert_equal 100, result.timestamp - assert_equal 1, result.ignored.size - end - - it 'ignores timestamp without value' do - header = 'ts' - result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) - - assert_nil result.timestamp - assert_equal 1, result.ignored.size - end - - it 'handles values with equals signs' do - header = 'custom-key=value=with=equals' - result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) +describe 'numeric_integer?' do + it 'returns true for valid integer strings' do + assert SolarWindsAPM::TraceOptions.numeric_integer?('12345') + assert SolarWindsAPM::TraceOptions.numeric_integer?('-1') + assert SolarWindsAPM::TraceOptions.numeric_integer?('0') + end - assert_equal 'value=with=equals', result.custom['custom-key'] - end + it 'returns false for non-integer strings' do + refute SolarWindsAPM::TraceOptions.numeric_integer?('abc') + refute SolarWindsAPM::TraceOptions.numeric_integer?('12.34') + refute SolarWindsAPM::TraceOptions.numeric_integer?('') end end diff --git a/test/solarwinds_apm/config_test.rb b/test/solarwinds_apm/config_test.rb index 4107d342..65b2dfeb 100644 --- a/test/solarwinds_apm/config_test.rb +++ b/test/solarwinds_apm/config_test.rb @@ -136,13 +136,13 @@ _(SolarWindsAPM::Config[:dummy_key]).must_equal false end - it 'with wrong env, use default true ' do + it 'with wrong env, use default true' do ENV['DUMMY_KEY'] = 'foo' SolarWindsAPM::Config.enable_disable_config('DUMMY_KEY', :dummy_key, true, true, bool: true) _(SolarWindsAPM::Config[:dummy_key]).must_equal true end - it 'with wrong env, use default true ' do + it 'with wrong env, use default false' do ENV['DUMMY_KEY'] = 'foo' SolarWindsAPM::Config.enable_disable_config('DUMMY_KEY', :dummy_key, true, false, bool: true) _(SolarWindsAPM::Config[:dummy_key]).must_equal false @@ -297,10 +297,8 @@ ENV.delete('SW_APM_TAG_SQL') end end -end -describe 'Config env var precedence, type coercion, deprecated keys, and log level' do - describe 'enable_disable_config' do + describe 'enable_disable_config tested via []= assignment' do it 'uses env var when valid enabled/disabled value' do original = ENV['SW_APM_TRIGGER_TRACING_MODE'] ENV['SW_APM_TRIGGER_TRACING_MODE'] = 'disabled' @@ -542,53 +540,6 @@ end end - describe 'set_log_level' do - it 'sets logger level based on SW_APM_DEBUG_LEVEL' do - original = ENV['SW_APM_DEBUG_LEVEL'] - ENV['SW_APM_DEBUG_LEVEL'] = '4' - SolarWindsAPM::Config.set_log_level - assert_equal ::Logger::DEBUG, SolarWindsAPM.logger.level - ensure - if original - ENV['SW_APM_DEBUG_LEVEL'] = original - else - ENV.delete('SW_APM_DEBUG_LEVEL') - end - SolarWindsAPM.logger.level = 1 - end - - it 'uses default INFO for unknown log level' do - original_env = ENV['SW_APM_DEBUG_LEVEL'] - original_config = SolarWindsAPM::Config[:debug_level] - ENV['SW_APM_DEBUG_LEVEL'] = '99' - SolarWindsAPM::Config.set_log_level - assert_equal ::Logger::INFO, SolarWindsAPM.logger.level - ensure - if original_env - ENV['SW_APM_DEBUG_LEVEL'] = original_env - else - ENV.delete('SW_APM_DEBUG_LEVEL') - end - SolarWindsAPM::Config[:debug_level] = original_config if original_config - SolarWindsAPM.logger.level = 1 - end - - it 'creates nil logger for level -1' do - original = ENV['SW_APM_DEBUG_LEVEL'] - old_logger = SolarWindsAPM.logger - ENV['SW_APM_DEBUG_LEVEL'] = '-1' - SolarWindsAPM::Config.set_log_level - ensure - if original - ENV['SW_APM_DEBUG_LEVEL'] = original - else - ENV.delete('SW_APM_DEBUG_LEVEL') - end - SolarWindsAPM.logger = old_logger - SolarWindsAPM.logger.level = 1 - end - end - describe 'config_file_from_env' do it 'returns nil for non-existent file' do original = ENV['SW_APM_CONFIG_RUBY'] @@ -602,44 +553,6 @@ ENV.delete('SW_APM_CONFIG_RUBY') end end - - it 'returns file path when file exists' do - require 'tempfile' - tmp = Tempfile.new(['solarwinds_apm_config', '.rb']) - tmp.write("# test config\n") - tmp.close - - original = ENV['SW_APM_CONFIG_RUBY'] - ENV['SW_APM_CONFIG_RUBY'] = tmp.path - result = SolarWindsAPM::Config.config_file_from_env - assert_equal tmp.path, result - ensure - if original - ENV['SW_APM_CONFIG_RUBY'] = original - else - ENV.delete('SW_APM_CONFIG_RUBY') - end - tmp&.unlink - end - - it 'returns file from directory when dir contains config file' do - require 'tmpdir' - dir = Dir.mktmpdir - config_file = File.join(dir, 'solarwinds_apm_config.rb') - File.write(config_file, "# test config\n") - - original = ENV['SW_APM_CONFIG_RUBY'] - ENV['SW_APM_CONFIG_RUBY'] = dir - result = SolarWindsAPM::Config.config_file_from_env - assert_equal config_file, result - ensure - if original - ENV['SW_APM_CONFIG_RUBY'] = original - else - ENV.delete('SW_APM_CONFIG_RUBY') - end - FileUtils.rm_rf(dir) if dir - end end describe 'update! and merge!' do diff --git a/test/support/otlp_endpoint_test.rb b/test/support/otlp_endpoint_test.rb index 8f1a741b..cc208e0d 100644 --- a/test/support/otlp_endpoint_test.rb +++ b/test/support/otlp_endpoint_test.rb @@ -250,25 +250,6 @@ def assert_singal_headers_nil(general_singal_header: true) _(ENV.fetch('SW_APM_COLLECTOR', nil)).must_equal 'apm.collector.na-01.cloud.solarwinds.com:443' assert_signal_endpoint_default end -end - -describe 'OTLPEndPoint collector resolution, endpoint configuration, and token header injection' do - before do - # Save original env vars - @saved_env = {} - %w[SW_APM_COLLECTOR SW_APM_SERVICE_KEY OTEL_SERVICE_NAME - OTEL_RESOURCE_ATTRIBUTES OTEL_EXPORTER_OTLP_HEADERS - OTEL_EXPORTER_OTLP_ENDPOINT - OTEL_EXPORTER_OTLP_TRACES_HEADERS OTEL_EXPORTER_OTLP_TRACES_ENDPOINT - OTEL_EXPORTER_OTLP_METRICS_HEADERS OTEL_EXPORTER_OTLP_METRICS_ENDPOINT - OTEL_EXPORTER_OTLP_LOGS_HEADERS OTEL_EXPORTER_OTLP_LOGS_ENDPOINT].each do |key| - @saved_env[key] = ENV[key] - end - end - - after do - @saved_env.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v } - end describe 'initialize' do it 'initializes with nil token and service_name' do diff --git a/test/support/resource_detector_test.rb b/test/support/resource_detector_test.rb index 81ff216f..a74616cc 100644 --- a/test/support/resource_detector_test.rb +++ b/test/support/resource_detector_test.rb @@ -93,9 +93,7 @@ WebMock.reset! WebMock.allow_net_connect! end -end -describe 'ResourceDetector UAMS client ID, K8s/EC2/Azure/container detection, and utility methods' do describe 'detect' do it 'returns resource with uuid attribute' do WebMock.enable! @@ -119,47 +117,6 @@ end describe 'detect_uams_client_id' do - it 'reads uams client id from file when available' do - tmpfile = Tempfile.new('uamsclientid') - tmpfile.write('test-uams-id') - tmpfile.close - - stub_const = SolarWindsAPM::ResourceDetector - original_path = stub_const.const_get(:UAMS_CLIENT_PATH) - stub_const.send(:remove_const, :UAMS_CLIENT_PATH) - stub_const.const_set(:UAMS_CLIENT_PATH, tmpfile.path) - - result = stub_const.detect_uams_client_id - attrs = result.instance_variable_get(:@attributes) - - assert_equal 'test-uams-id', attrs['sw.uams.client.id'] - ensure - stub_const.send(:remove_const, :UAMS_CLIENT_PATH) - stub_const.const_set(:UAMS_CLIENT_PATH, original_path) - tmpfile&.unlink - end - - it 'falls back to API when file not found' do - WebMock.enable! - WebMock.stub_request(:get, SolarWindsAPM::ResourceDetector::UAMS_CLIENT_URL) - .to_return(status: 200, body: '{"uamsclient_id": "api-client-id"}', - headers: { 'Content-Type' => 'application/json' }) - - stub_const = SolarWindsAPM::ResourceDetector - original_path = stub_const.const_get(:UAMS_CLIENT_PATH) - stub_const.send(:remove_const, :UAMS_CLIENT_PATH) - stub_const.const_set(:UAMS_CLIENT_PATH, '/nonexistent/path/uamsclientid') - - result = stub_const.detect_uams_client_id - attrs = result.instance_variable_get(:@attributes) - - assert_equal 'api-client-id', attrs['sw.uams.client.id'] - ensure - stub_const.send(:remove_const, :UAMS_CLIENT_PATH) - stub_const.const_set(:UAMS_CLIENT_PATH, original_path) - WebMock.disable! - end - it 'handles API failure gracefully' do WebMock.enable! WebMock.stub_request(:get, SolarWindsAPM::ResourceDetector::UAMS_CLIENT_URL) @@ -182,15 +139,6 @@ end describe 'detect_k8s_attributes' do - it 'returns empty resource when not in k8s' do - ENV.delete('KUBERNETES_SERVICE_HOST') - ENV.delete('KUBERNETES_SERVICE_PORT') - - result = SolarWindsAPM::ResourceDetector.detect_k8s_attributes - attrs = result.instance_variable_get(:@attributes) - assert_empty attrs - end - it 'reads pod name from env variable' do ENV['KUBERNETES_SERVICE_HOST'] = '10.96.0.1' ENV['KUBERNETES_SERVICE_PORT'] = '443' @@ -220,21 +168,6 @@ ENV.delete('KUBERNETES_SERVICE_PORT') ENV.delete('SW_K8S_POD_NAMESPACE') end - - it 'reads pod uid from env variable' do - ENV['KUBERNETES_SERVICE_HOST'] = '10.96.0.1' - ENV['KUBERNETES_SERVICE_PORT'] = '443' - ENV['SW_K8S_POD_UID'] = 'b4683374-c415-4136-99bf-7fd72a0aa885' - - result = SolarWindsAPM::ResourceDetector.detect_k8s_attributes - attrs = result.instance_variable_get(:@attributes) - - assert_equal 'b4683374-c415-4136-99bf-7fd72a0aa885', attrs['k8s.pod.uid'] - ensure - ENV.delete('KUBERNETES_SERVICE_HOST') - ENV.delete('KUBERNETES_SERVICE_PORT') - ENV.delete('SW_K8S_POD_UID') - end end describe 'detect_ec2' do diff --git a/test/support/service_key_checker_test.rb b/test/support/service_key_checker_test.rb index 0b8cdc7a..259b1e0f 100644 --- a/test/support/service_key_checker_test.rb +++ b/test/support/service_key_checker_test.rb @@ -225,108 +225,16 @@ def service_key_ok?(service_key) assert_nil(ENV.fetch('OTEL_SERVICE_NAME', nil)) end -end -describe 'ServiceKeyChecker token/service_name parsing, env var overrides, and name sanitization' do - describe 'initialization' do - it 'returns nil token for non-ssl reporter' do - checker = SolarWindsAPM::ServiceKeyChecker.new('udp', false) - assert_nil checker.token - assert_nil checker.service_name - end - - it 'returns nil token for lambda environment' do - checker = SolarWindsAPM::ServiceKeyChecker.new('ssl', true) - assert_nil checker.token - assert_nil checker.service_name - end - - it 'parses valid service key from environment' do - original_key = ENV['SW_APM_SERVICE_KEY'] - ENV['SW_APM_SERVICE_KEY'] = 'test-token-key-123456789012345678901234567890123:my-service' - ENV.delete('OTEL_SERVICE_NAME') - ENV.delete('OTEL_RESOURCE_ATTRIBUTES') - - checker = SolarWindsAPM::ServiceKeyChecker.new('ssl', false) - assert_equal 'test-token-key-123456789012345678901234567890123', checker.token - assert_equal 'my-service', checker.service_name - ensure - ENV['SW_APM_SERVICE_KEY'] = original_key - end - - it 'returns nil token for empty service key' do - original_key = ENV['SW_APM_SERVICE_KEY'] - ENV['SW_APM_SERVICE_KEY'] = '' - - checker = SolarWindsAPM::ServiceKeyChecker.new('ssl', false) - assert_nil checker.token - ensure - ENV['SW_APM_SERVICE_KEY'] = original_key - end - - it 'returns nil token for missing service name' do - original_key = ENV['SW_APM_SERVICE_KEY'] - ENV['SW_APM_SERVICE_KEY'] = 'only-token-no-colon' - ENV.delete('OTEL_SERVICE_NAME') - - checker = SolarWindsAPM::ServiceKeyChecker.new('ssl', false) - assert_nil checker.token - ensure - ENV['SW_APM_SERVICE_KEY'] = original_key - end - - it 'uses OTEL_SERVICE_NAME override' do - original_key = ENV['SW_APM_SERVICE_KEY'] - original_otel = ENV['OTEL_SERVICE_NAME'] - ENV['SW_APM_SERVICE_KEY'] = 'test-token-key-123456789012345678901234567890123:original-service' - ENV['OTEL_SERVICE_NAME'] = 'otel-override' - - checker = SolarWindsAPM::ServiceKeyChecker.new('ssl', false) - assert_equal 'otel-override', checker.service_name - ensure - ENV['SW_APM_SERVICE_KEY'] = original_key - ENV['OTEL_SERVICE_NAME'] = original_otel - end - - it 'uses OTEL_RESOURCE_ATTRIBUTES service.name override' do - original_key = ENV['SW_APM_SERVICE_KEY'] - original_otel = ENV['OTEL_SERVICE_NAME'] - original_resource = ENV['OTEL_RESOURCE_ATTRIBUTES'] - ENV['SW_APM_SERVICE_KEY'] = 'test-token-key-123456789012345678901234567890123:original-service' - ENV.delete('OTEL_SERVICE_NAME') - ENV['OTEL_RESOURCE_ATTRIBUTES'] = 'service.name=resource-service,other=val' - - checker = SolarWindsAPM::ServiceKeyChecker.new('ssl', false) - assert_equal 'resource-service', checker.service_name - ensure - ENV['SW_APM_SERVICE_KEY'] = original_key - ENV['OTEL_SERVICE_NAME'] = original_otel - ENV['OTEL_RESOURCE_ATTRIBUTES'] = original_resource - end - - it 'transforms service name by lowercasing and removing invalid chars' do - original_key = ENV['SW_APM_SERVICE_KEY'] - ENV['SW_APM_SERVICE_KEY'] = 'test-token-key-123456789012345678901234567890123:My Service!@#$' - ENV.delete('OTEL_SERVICE_NAME') - ENV.delete('OTEL_RESOURCE_ATTRIBUTES') - - checker = SolarWindsAPM::ServiceKeyChecker.new('ssl', false) - assert_equal 'myservice', checker.service_name - ensure - ENV['SW_APM_SERVICE_KEY'] = original_key - end - - it 'truncates long service names to 255 chars' do - original_key = ENV['SW_APM_SERVICE_KEY'] - long_name = 'a' * 300 - ENV['SW_APM_SERVICE_KEY'] = "test-token-key-123456789012345678901234567890123:#{long_name}" - ENV.delete('OTEL_SERVICE_NAME') - ENV.delete('OTEL_RESOURCE_ATTRIBUTES') - - checker = SolarWindsAPM::ServiceKeyChecker.new('ssl', false) - assert checker.service_name.length <= 255 - ensure - ENV['SW_APM_SERVICE_KEY'] = original_key - end + it 'returns nil token for non-ssl reporter' do + checker = SolarWindsAPM::ServiceKeyChecker.new('udp', false) + assert_nil checker.token + assert_nil checker.service_name + end + + it 'returns nil token for lambda environment' do + checker = SolarWindsAPM::ServiceKeyChecker.new('ssl', true) + assert_nil checker.token + assert_nil checker.service_name end end From 027032adb916b19329afd18dc04bf15142460098 Mon Sep 17 00:00:00 2001 From: xuan-cao-swi Date: Wed, 18 Mar 2026 12:41:18 -0400 Subject: [PATCH 04/10] lint --- test/api/transaction_name_test.rb | 2 +- test/opentelemetry/otlp_processor_test.rb | 12 ++++++------ test/sampling/sampling_patch_test.rb | 6 +++--- test/sampling/settings_test.rb | 4 ++-- test/solarwinds_apm/config_test.rb | 20 +++++++++---------- test/support/log_formatters_test.rb | 2 +- test/support/logger_formatter_test.rb | 2 +- test/support/otlp_endpoint_test.rb | 24 +++++++++++------------ test/support/resource_detector_test.rb | 2 +- test/support/transaction_settings_test.rb | 8 ++++---- test/support/utils_test.rb | 12 ++++++------ 11 files changed, 47 insertions(+), 47 deletions(-) diff --git a/test/api/transaction_name_test.rb b/test/api/transaction_name_test.rb index a288c075..db1d71bb 100644 --- a/test/api/transaction_name_test.rb +++ b/test/api/transaction_name_test.rb @@ -11,7 +11,7 @@ describe 'API::TransactionName#set_transaction_name input validation and early returns' do before do - @original_enabled = ENV['SW_APM_ENABLED'] + @original_enabled = ENV.fetch('SW_APM_ENABLED', nil) end after do diff --git a/test/opentelemetry/otlp_processor_test.rb b/test/opentelemetry/otlp_processor_test.rb index 5908d116..6758fc19 100644 --- a/test/opentelemetry/otlp_processor_test.rb +++ b/test/opentelemetry/otlp_processor_test.rb @@ -384,14 +384,14 @@ describe 'force_flush' do it 'returns SUCCESS' do result = @processor.force_flush - assert_equal ::OpenTelemetry::SDK::Trace::Export::SUCCESS, result + assert_equal OpenTelemetry::SDK::Trace::Export::SUCCESS, result end end describe 'shutdown' do it 'returns SUCCESS' do result = @processor.shutdown - assert_equal ::OpenTelemetry::SDK::Trace::Export::SUCCESS, result + assert_equal OpenTelemetry::SDK::Trace::Export::SUCCESS, result end end @@ -469,13 +469,13 @@ describe 'calculate_span_time' do it 'calculates time difference in microseconds' do result = @processor.send(:calculate_span_time, start_time: 1_000_000_000, end_time: 2_000_000_000) - assert result > 0 + assert result.positive? end end describe 'error?' do it 'returns 1 for error status' do - span_data = create_span_data + create_span_data # Override status to error error_status = OpenTelemetry::Trace::Status.error('error') span_data_with_error = OpenTelemetry::SDK::Trace::SpanData.new( @@ -634,7 +634,7 @@ OpenTelemetry::Trace::TraceFlags.from_byte(0x01), OpenTelemetry::Trace::Tracestate::DEFAULT ) - span_data.define_singleton_method(:kind) { ::OpenTelemetry::Trace::SpanKind::SERVER } + span_data.define_singleton_method(:kind) { OpenTelemetry::Trace::SpanKind::SERVER } result = @processor.send(:meter_attributes, span_data) assert_equal 'POST', result['http.method'] @@ -654,7 +654,7 @@ OpenTelemetry::Trace::TraceFlags.from_byte(0x01), OpenTelemetry::Trace::Tracestate::DEFAULT ) - span_data.define_singleton_method(:kind) { ::OpenTelemetry::Trace::SpanKind::SERVER } + span_data.define_singleton_method(:kind) { OpenTelemetry::Trace::SpanKind::SERVER } result = @processor.send(:meter_attributes, span_data) refute result.key?('http.status_code') diff --git a/test/sampling/sampling_patch_test.rb b/test/sampling/sampling_patch_test.rb index aa6f7a7e..10ea543d 100644 --- a/test/sampling/sampling_patch_test.rb +++ b/test/sampling/sampling_patch_test.rb @@ -12,13 +12,13 @@ metric1 = Minitest::Mock.new metric1.expect(:data_points, []) - metrics = [metric1] + [metric1] # After reject!, empty metrics remain so we need the exporter # The patch calls super if any data_points are present # When all are empty it should return SUCCESS - exporter = ::OpenTelemetry::Exporter::OTLP::Metrics::MetricsExporter.new + exporter = OpenTelemetry::Exporter::OTLP::Metrics::MetricsExporter.new result = exporter.export([]) - assert_equal ::OpenTelemetry::SDK::Metrics::Export::SUCCESS, result + assert_equal OpenTelemetry::SDK::Metrics::Export::SUCCESS, result end end diff --git a/test/sampling/settings_test.rb b/test/sampling/settings_test.rb index eff96243..667bf072 100644 --- a/test/sampling/settings_test.rb +++ b/test/sampling/settings_test.rb @@ -261,9 +261,9 @@ describe 'Structs' do it 'TriggerTraceOptions has the right members' do - opts = SolarWindsAPM::TriggerTraceOptions.new(true, 12345, 'keys', {}, [], nil) + opts = SolarWindsAPM::TriggerTraceOptions.new(true, 12_345, 'keys', {}, [], nil) assert opts.trigger_trace - assert_equal 12345, opts.timestamp + assert_equal 12_345, opts.timestamp assert_equal 'keys', opts.sw_keys end diff --git a/test/solarwinds_apm/config_test.rb b/test/solarwinds_apm/config_test.rb index 65b2dfeb..d75f9846 100644 --- a/test/solarwinds_apm/config_test.rb +++ b/test/solarwinds_apm/config_test.rb @@ -300,7 +300,7 @@ describe 'enable_disable_config tested via []= assignment' do it 'uses env var when valid enabled/disabled value' do - original = ENV['SW_APM_TRIGGER_TRACING_MODE'] + original = ENV.fetch('SW_APM_TRIGGER_TRACING_MODE', nil) ENV['SW_APM_TRIGGER_TRACING_MODE'] = 'disabled' SolarWindsAPM::Config[:trigger_tracing_mode] = :enabled @@ -314,7 +314,7 @@ end it 'uses default for invalid env var' do - original = ENV['SW_APM_TRIGGER_TRACING_MODE'] + original = ENV.fetch('SW_APM_TRIGGER_TRACING_MODE', nil) ENV['SW_APM_TRIGGER_TRACING_MODE'] = 'invalid_value' SolarWindsAPM::Config[:trigger_tracing_mode] = :enabled @@ -328,7 +328,7 @@ end it 'accepts boolean config with true/false env var' do - original = ENV['SW_APM_TAG_SQL'] + original = ENV.fetch('SW_APM_TAG_SQL', nil) ENV['SW_APM_TAG_SQL'] = 'true' SolarWindsAPM::Config[:tag_sql] = false @@ -342,7 +342,7 @@ end it 'uses default for invalid boolean env var' do - original = ENV['SW_APM_TAG_SQL'] + original = ENV.fetch('SW_APM_TAG_SQL', nil) ENV['SW_APM_TAG_SQL'] = 'invalid_bool' SolarWindsAPM::Config[:tag_sql] = true @@ -356,7 +356,7 @@ end it 'accepts value from code when env var not set' do - original = ENV['SW_APM_TRIGGER_TRACING_MODE'] + original = ENV.fetch('SW_APM_TRIGGER_TRACING_MODE', nil) ENV.delete('SW_APM_TRIGGER_TRACING_MODE') SolarWindsAPM::Config[:trigger_tracing_mode] = :disabled @@ -370,7 +370,7 @@ end it 'uses default for invalid code value' do - original = ENV['SW_APM_TRIGGER_TRACING_MODE'] + original = ENV.fetch('SW_APM_TRIGGER_TRACING_MODE', nil) ENV.delete('SW_APM_TRIGGER_TRACING_MODE') SolarWindsAPM::Config[:trigger_tracing_mode] = 'invalid_string' @@ -470,7 +470,7 @@ end it 'handles tracing_mode assignment' do - original = ENV['SW_APM_TRIGGER_TRACING_MODE'] + original = ENV.fetch('SW_APM_TRIGGER_TRACING_MODE', nil) ENV.delete('SW_APM_TRIGGER_TRACING_MODE') SolarWindsAPM::Config[:tracing_mode] = :enabled assert_equal :enabled, SolarWindsAPM::Config[:tracing_mode] @@ -507,7 +507,7 @@ end it 'handles transaction_settings with Regexp object' do - settings = [{ regexp: /\/health/, tracing: :disabled }] + settings = [{ regexp: %r{/health}, tracing: :disabled }] SolarWindsAPM::Config[:transaction_settings] = settings refute_nil SolarWindsAPM::Config[:disabled_regexps] end @@ -542,7 +542,7 @@ describe 'config_file_from_env' do it 'returns nil for non-existent file' do - original = ENV['SW_APM_CONFIG_RUBY'] + original = ENV.fetch('SW_APM_CONFIG_RUBY', nil) ENV['SW_APM_CONFIG_RUBY'] = '/nonexistent/path/file.rb' result = SolarWindsAPM::Config.config_file_from_env assert_nil result @@ -562,7 +562,7 @@ end it 'merge! is an alias for update!' do - SolarWindsAPM::Config.merge!({ test_merge_key: 'test_value' }) + SolarWindsAPM::Config[:test_merge_key] = 'test_value' assert_equal 'test_value', SolarWindsAPM::Config[:test_merge_key] end end diff --git a/test/support/log_formatters_test.rb b/test/support/log_formatters_test.rb index 91469819..326a289d 100644 --- a/test/support/log_formatters_test.rb +++ b/test/support/log_formatters_test.rb @@ -36,7 +36,7 @@ original = SolarWindsAPM::Config[:log_traceId] SolarWindsAPM::Config[:log_traceId] = :always - logger = Logging.logger['test_logger'] + Logging.logger['test_logger'] event = Logging::LogEvent.new('test_logger', Logging::LEVELS['info'], 'test log message', false) assert_includes event.data, 'trace_id=' ensure diff --git a/test/support/logger_formatter_test.rb b/test/support/logger_formatter_test.rb index 72de1833..59577a22 100644 --- a/test/support/logger_formatter_test.rb +++ b/test/support/logger_formatter_test.rb @@ -10,7 +10,7 @@ describe 'Logger::Formatter trace ID injection, deduplication, and message edge cases' do before do - @formatter = ::Logger::Formatter.new + @formatter = Logger::Formatter.new end it 'passes through when log_traceId is :never' do diff --git a/test/support/otlp_endpoint_test.rb b/test/support/otlp_endpoint_test.rb index cc208e0d..6913255b 100644 --- a/test/support/otlp_endpoint_test.rb +++ b/test/support/otlp_endpoint_test.rb @@ -265,7 +265,7 @@ def assert_singal_headers_nil(general_singal_header: true) endpoint = SolarWindsAPM::OTLPEndPoint.new matches = ENV['SW_APM_COLLECTOR'].match(SolarWindsAPM::OTLPEndPoint::SWO_APM_ENDPOINT_REGEX) endpoint.send(:resolve_get_setting_endpoint, matches) - assert_equal 'apm.collector.na-01.cloud.solarwinds.com:443', ENV['SW_APM_COLLECTOR'] + assert_equal 'apm.collector.na-01.cloud.solarwinds.com:443', ENV.fetch('SW_APM_COLLECTOR', nil) end it 'falls back to default for invalid collector' do @@ -273,14 +273,14 @@ def assert_singal_headers_nil(general_singal_header: true) endpoint = SolarWindsAPM::OTLPEndPoint.new matches = ENV['SW_APM_COLLECTOR'].match(SolarWindsAPM::OTLPEndPoint::SWO_APM_ENDPOINT_REGEX) endpoint.send(:resolve_get_setting_endpoint, matches) - assert_equal SolarWindsAPM::OTLPEndPoint::SWO_APM_ENDPOINT_DEFAULT, ENV['SW_APM_COLLECTOR'] + assert_equal SolarWindsAPM::OTLPEndPoint::SWO_APM_ENDPOINT_DEFAULT, ENV.fetch('SW_APM_COLLECTOR', nil) end it 'falls back to default for empty collector' do ENV.delete('SW_APM_COLLECTOR') endpoint = SolarWindsAPM::OTLPEndPoint.new endpoint.send(:resolve_get_setting_endpoint, nil) - assert_equal SolarWindsAPM::OTLPEndPoint::SWO_APM_ENDPOINT_DEFAULT, ENV['SW_APM_COLLECTOR'] + assert_equal SolarWindsAPM::OTLPEndPoint::SWO_APM_ENDPOINT_DEFAULT, ENV.fetch('SW_APM_COLLECTOR', nil) end end @@ -293,7 +293,7 @@ def assert_singal_headers_nil(general_singal_header: true) matches = 'apm.collector.eu-01.cloud.solarwinds.com:443'.match(SolarWindsAPM::OTLPEndPoint::SWO_APM_ENDPOINT_REGEX) endpoint.configure_otlp_endpoint('TRACES', matches) - assert_equal 'https://otel.collector.eu-01.cloud.solarwinds.com:443/v1/traces', ENV['OTEL_EXPORTER_OTLP_TRACES_ENDPOINT'] + assert_equal 'https://otel.collector.eu-01.cloud.solarwinds.com:443/v1/traces', ENV.fetch('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT', nil) end it 'sets default endpoint when no matches and no endpoint' do @@ -302,14 +302,14 @@ def assert_singal_headers_nil(general_singal_header: true) endpoint = SolarWindsAPM::OTLPEndPoint.new endpoint.configure_otlp_endpoint('METRICS', nil) - assert_equal 'https://otel.collector.na-01.cloud.solarwinds.com:443/v1/metrics', ENV['OTEL_EXPORTER_OTLP_METRICS_ENDPOINT'] + assert_equal 'https://otel.collector.na-01.cloud.solarwinds.com:443/v1/metrics', ENV.fetch('OTEL_EXPORTER_OTLP_METRICS_ENDPOINT', nil) end it 'does not override existing signal endpoint' do ENV['OTEL_EXPORTER_OTLP_LOGS_ENDPOINT'] = 'https://custom.endpoint/v1/logs' endpoint = SolarWindsAPM::OTLPEndPoint.new endpoint.configure_otlp_endpoint('LOGS', nil) - assert_equal 'https://custom.endpoint/v1/logs', ENV['OTEL_EXPORTER_OTLP_LOGS_ENDPOINT'] + assert_equal 'https://custom.endpoint/v1/logs', ENV.fetch('OTEL_EXPORTER_OTLP_LOGS_ENDPOINT', nil) end it 'does not override when general endpoint is set' do @@ -317,7 +317,7 @@ def assert_singal_headers_nil(general_singal_header: true) ENV['OTEL_EXPORTER_OTLP_ENDPOINT'] = 'https://custom.general/endpoint' endpoint = SolarWindsAPM::OTLPEndPoint.new endpoint.configure_otlp_endpoint('TRACES', nil) - refute_equal 'https://otel.collector.na-01.cloud.solarwinds.com:443/v1/traces', ENV['OTEL_EXPORTER_OTLP_TRACES_ENDPOINT'] + refute_equal 'https://otel.collector.na-01.cloud.solarwinds.com:443/v1/traces', ENV.fetch('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT', nil) end end @@ -331,7 +331,7 @@ def assert_singal_headers_nil(general_singal_header: true) endpoint.instance_variable_set(:@token, 'my-token') endpoint.config_token('TRACES') - assert_includes ENV['OTEL_EXPORTER_OTLP_HEADERS'], 'Bearer my-token' + assert_includes ENV.fetch('OTEL_EXPORTER_OTLP_HEADERS', nil), 'Bearer my-token' end it 'sets token on signal headers when signal endpoint matches' do @@ -344,14 +344,14 @@ def assert_singal_headers_nil(general_singal_header: true) endpoint.instance_variable_set(:@token, 'my-token') endpoint.config_token('TRACES') - assert_includes ENV['OTEL_EXPORTER_OTLP_TRACES_HEADERS'], 'Bearer my-token' + assert_includes ENV.fetch('OTEL_EXPORTER_OTLP_TRACES_HEADERS', nil), 'Bearer my-token' end it 'does not set token when no token available' do ENV.delete('OTEL_EXPORTER_OTLP_HEADERS') endpoint = SolarWindsAPM::OTLPEndPoint.new endpoint.config_token('TRACES') - assert_nil ENV['OTEL_EXPORTER_OTLP_HEADERS'] + assert_nil ENV.fetch('OTEL_EXPORTER_OTLP_HEADERS', nil) end it 'does not override existing headers' do @@ -362,7 +362,7 @@ def assert_singal_headers_nil(general_singal_header: true) endpoint.instance_variable_set(:@token, 'my-token') endpoint.config_token('TRACES') - assert_equal 'existing=header', ENV['OTEL_EXPORTER_OTLP_TRACES_HEADERS'] + assert_equal 'existing=header', ENV.fetch('OTEL_EXPORTER_OTLP_TRACES_HEADERS', nil) end it 'sets fallback token when no endpoints configured' do @@ -375,7 +375,7 @@ def assert_singal_headers_nil(general_singal_header: true) endpoint.instance_variable_set(:@token, 'my-token') endpoint.config_token('TRACES') - assert_includes ENV['OTEL_EXPORTER_OTLP_HEADERS'], 'Bearer my-token' + assert_includes ENV.fetch('OTEL_EXPORTER_OTLP_HEADERS', nil), 'Bearer my-token' end end end diff --git a/test/support/resource_detector_test.rb b/test/support/resource_detector_test.rb index a74616cc..195c25c7 100644 --- a/test/support/resource_detector_test.rb +++ b/test/support/resource_detector_test.rb @@ -213,7 +213,7 @@ end it 'returns false for unsafe integers' do - refute SolarWindsAPM::ResourceDetector.safe_integer?((2**53)) + refute SolarWindsAPM::ResourceDetector.safe_integer?(2**53) refute SolarWindsAPM::ResourceDetector.safe_integer?(-(2**53)) end end diff --git a/test/support/transaction_settings_test.rb b/test/support/transaction_settings_test.rb index 34e8f9ee..490af76d 100644 --- a/test/support/transaction_settings_test.rb +++ b/test/support/transaction_settings_test.rb @@ -136,7 +136,7 @@ it 'returns disabled when url matches disabled regexp' do SolarWindsAPM::Config[:tracing_mode] = :enabled - SolarWindsAPM::Config[:disabled_regexps] = [/\/health/] + SolarWindsAPM::Config[:disabled_regexps] = [%r{/health}] SolarWindsAPM::Config[:enabled_regexps] = nil ts = SolarWindsAPM::TransactionSettings.new(url_path: '/health', name: 'test', kind: :server) @@ -145,7 +145,7 @@ it 'returns enabled when url matches enabled regexp' do SolarWindsAPM::Config[:tracing_mode] = :enabled - SolarWindsAPM::Config[:enabled_regexps] = [/\/api/] + SolarWindsAPM::Config[:enabled_regexps] = [%r{/api}] SolarWindsAPM::Config[:disabled_regexps] = nil ts = SolarWindsAPM::TransactionSettings.new(url_path: '/api/test', name: 'test', kind: :server) @@ -172,8 +172,8 @@ it 'disabled takes priority over enabled for url' do SolarWindsAPM::Config[:tracing_mode] = :enabled - SolarWindsAPM::Config[:disabled_regexps] = [/\/api/] - SolarWindsAPM::Config[:enabled_regexps] = [/\/api/] + SolarWindsAPM::Config[:disabled_regexps] = [%r{/api}] + SolarWindsAPM::Config[:enabled_regexps] = [%r{/api}] ts = SolarWindsAPM::TransactionSettings.new(url_path: '/api/test', name: 'test', kind: :server) assert_equal 0, ts.calculate_trace_mode diff --git a/test/support/utils_test.rb b/test/support/utils_test.rb index a47db70a..93e4278a 100644 --- a/test/support/utils_test.rb +++ b/test/support/utils_test.rb @@ -9,7 +9,7 @@ describe 'Utils tracestate formatting, traceparent construction, and Lambda detection' do it 'builds W3C traceparent string from span context' do span_context = OpenTelemetry::Trace::SpanContext.new(trace_id: "\xDD\x95\xC5l\xE3\x83\xCA\xF0\x95;S\x98i\xF9:{", - span_id: "\x8D\xB5\xDC?$l\x84W") + span_id: "\x8D\xB5\xDC?$l\x84W") result = SolarWindsAPM::Utils.traceparent_from_context(span_context) _(result).must_equal '00-dd95c56ce383caf0953b539869f93a7b-8db5dc3f246c8457-00' end @@ -63,8 +63,8 @@ describe 'determine_lambda' do it 'returns false when not in lambda' do - original_task_root = ENV['LAMBDA_TASK_ROOT'] - original_func_name = ENV['AWS_LAMBDA_FUNCTION_NAME'] + original_task_root = ENV.fetch('LAMBDA_TASK_ROOT', nil) + original_func_name = ENV.fetch('AWS_LAMBDA_FUNCTION_NAME', nil) ENV.delete('LAMBDA_TASK_ROOT') ENV.delete('AWS_LAMBDA_FUNCTION_NAME') @@ -75,7 +75,7 @@ end it 'returns true when LAMBDA_TASK_ROOT is set' do - original = ENV['LAMBDA_TASK_ROOT'] + original = ENV.fetch('LAMBDA_TASK_ROOT', nil) ENV['LAMBDA_TASK_ROOT'] = '/var/task' assert SolarWindsAPM::Utils.determine_lambda @@ -84,8 +84,8 @@ end it 'returns true when AWS_LAMBDA_FUNCTION_NAME is set' do - original_task_root = ENV['LAMBDA_TASK_ROOT'] - original_func_name = ENV['AWS_LAMBDA_FUNCTION_NAME'] + original_task_root = ENV.fetch('LAMBDA_TASK_ROOT', nil) + original_func_name = ENV.fetch('AWS_LAMBDA_FUNCTION_NAME', nil) ENV.delete('LAMBDA_TASK_ROOT') ENV['AWS_LAMBDA_FUNCTION_NAME'] = 'my-function' From f611b8241ffb2d254dacc9035f28ee4c869b77d8 Mon Sep 17 00:00:00 2001 From: xuan-cao-swi Date: Thu, 19 Mar 2026 14:49:54 -0400 Subject: [PATCH 05/10] codeql --- .rubocop.yml | 2 ++ test/solarwinds_apm/otel_config_test.rb | 27 ++++++++++++++++--------- test/support/resource_detector_test.rb | 9 +++++++-- 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index e6e525d4..54651a73 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -57,3 +57,5 @@ Style/StringLiterals: - 'Gemfile' Naming/PredicateMethod: Enabled: false +Style/OneClassPerFile: + Enabled: false diff --git a/test/solarwinds_apm/otel_config_test.rb b/test/solarwinds_apm/otel_config_test.rb index a16e6a71..1da1eed3 100644 --- a/test/solarwinds_apm/otel_config_test.rb +++ b/test/solarwinds_apm/otel_config_test.rb @@ -10,6 +10,7 @@ describe 'OTelConfig response propagator resolution, initialization validation, and config accessor' do describe 'resolve_response_propagator' do it 'creates new rack setting when none exists' do + config_map = nil config_map = SolarWindsAPM::OTelConfig.class_variable_get(:@@config_map) original = config_map['OpenTelemetry::Instrumentation::Rack'] config_map.delete('OpenTelemetry::Instrumentation::Rack') @@ -21,14 +22,15 @@ assert rack_setting[:response_propagators].is_a?(Array) assert_equal 1, rack_setting[:response_propagators].length ensure - if original + if config_map && original config_map['OpenTelemetry::Instrumentation::Rack'] = original - else + elsif config_map config_map.delete('OpenTelemetry::Instrumentation::Rack') end end it 'appends to existing array of response_propagators' do + config_map = nil config_map = SolarWindsAPM::OTelConfig.class_variable_get(:@@config_map) original = config_map['OpenTelemetry::Instrumentation::Rack'] @@ -41,14 +43,15 @@ assert_equal 2, rack_setting[:response_propagators].length assert_equal existing_propagator, rack_setting[:response_propagators][0] ensure - if original + if config_map && original config_map['OpenTelemetry::Instrumentation::Rack'] = original - else + elsif config_map config_map.delete('OpenTelemetry::Instrumentation::Rack') end end it 'sets response_propagators when nil in existing rack setting' do + config_map = nil config_map = SolarWindsAPM::OTelConfig.class_variable_get(:@@config_map) original = config_map['OpenTelemetry::Instrumentation::Rack'] @@ -60,14 +63,15 @@ assert rack_setting[:response_propagators].is_a?(Array) assert_equal 1, rack_setting[:response_propagators].length ensure - if original + if config_map && original config_map['OpenTelemetry::Instrumentation::Rack'] = original - else + elsif config_map config_map.delete('OpenTelemetry::Instrumentation::Rack') end end it 'warns when response_propagators is not an Array' do + config_map = nil config_map = SolarWindsAPM::OTelConfig.class_variable_get(:@@config_map) original = config_map['OpenTelemetry::Instrumentation::Rack'] @@ -79,9 +83,9 @@ rack_setting = config_map['OpenTelemetry::Instrumentation::Rack'] assert_equal 'not_an_array', rack_setting[:response_propagators] ensure - if original + if config_map && original config_map['OpenTelemetry::Instrumentation::Rack'] = original - else + elsif config_map config_map.delete('OpenTelemetry::Instrumentation::Rack') end end @@ -94,6 +98,8 @@ end it 'warns and returns for empty config_map' do + nil + original_map = nil config_map = SolarWindsAPM::OTelConfig.class_variable_get(:@@config_map) original_map = config_map.dup @@ -105,17 +111,18 @@ assert_nil result ensure - SolarWindsAPM::OTelConfig.class_variable_set(:@@config_map, original_map) + SolarWindsAPM::OTelConfig.class_variable_set(:@@config_map, original_map) if original_map end end describe '[] accessor' do it 'returns value for given key' do + config = nil config = SolarWindsAPM::OTelConfig.class_variable_get(:@@config) config[:test_key_otel] = 'test_value' assert_equal 'test_value', SolarWindsAPM::OTelConfig[:test_key_otel] ensure - config.delete(:test_key_otel) + config&.delete(:test_key_otel) end it 'returns nil for missing key' do diff --git a/test/support/resource_detector_test.rb b/test/support/resource_detector_test.rb index 195c25c7..9bb2085e 100644 --- a/test/support/resource_detector_test.rb +++ b/test/support/resource_detector_test.rb @@ -118,6 +118,9 @@ describe 'detect_uams_client_id' do it 'handles API failure gracefully' do + stub_const = nil + original_path = nil + WebMock.enable! WebMock.stub_request(:get, SolarWindsAPM::ResourceDetector::UAMS_CLIENT_URL) .to_return(status: 500, body: 'error') @@ -132,8 +135,10 @@ assert_nil attrs['sw.uams.client.id'] ensure - stub_const.send(:remove_const, :UAMS_CLIENT_PATH) - stub_const.const_set(:UAMS_CLIENT_PATH, original_path) + if stub_const && original_path + stub_const.send(:remove_const, :UAMS_CLIENT_PATH) + stub_const.const_set(:UAMS_CLIENT_PATH, original_path) + end WebMock.disable! end end From f078f72a27e6ae08f3df2effe74550304f00acf7 Mon Sep 17 00:00:00 2001 From: xuan-cao-swi Date: Tue, 24 Mar 2026 12:44:14 -0400 Subject: [PATCH 06/10] try codecov --- .github/workflows/run_unit_tests.yml | 8 ++++++++ Gemfile | 1 + test/initest_helper.rb | 8 +++++++- test/minitest_helper.rb | 8 +++++++- test/sampling_test_helper.rb | 8 +++++++- 5 files changed, 30 insertions(+), 3 deletions(-) diff --git a/.github/workflows/run_unit_tests.yml b/.github/workflows/run_unit_tests.yml index 7ff4efe2..7d026636 100644 --- a/.github/workflows/run_unit_tests.yml +++ b/.github/workflows/run_unit_tests.yml @@ -70,3 +70,11 @@ jobs: export RUN_TESTS=1 echo "testing with ruby version: $RUBY_VERSION" test/test_setup.sh + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage/coverage.xml + flags: ruby-${{ matrix.ruby }}-${{ matrix.os }} + fail_ci_if_error: false diff --git a/Gemfile b/Gemfile index 9a33b128..273ef950 100644 --- a/Gemfile +++ b/Gemfile @@ -32,6 +32,7 @@ group :development, :test do gem 'rubocop-rake', require: false gem 'code-scanning-rubocop', '~> 0.6.1' gem 'simplecov', require: false, group: :test + gem 'simplecov-cobertura', require: false, group: :test gem 'simplecov-console', require: false, group: :test gem 'webmock' if RUBY_VERSION >= '2.0.0' gem 'base64' if RUBY_VERSION >= '3.4.0' diff --git a/test/initest_helper.rb b/test/initest_helper.rb index 4d95efbd..1623a86c 100644 --- a/test/initest_helper.rb +++ b/test/initest_helper.rb @@ -9,7 +9,13 @@ require 'minitest/reporters' require './lib/solarwinds_apm/logger' require 'simplecov' -SimpleCov.start +require 'simplecov-cobertura' +SimpleCov.start do + formatter SimpleCov::Formatter::MultiFormatter.new([ + SimpleCov::Formatter::HTMLFormatter, + SimpleCov::Formatter::CoberturaFormatter + ]) +end SimpleCov.command_name ENV.fetch('SIMPLECOV_COMMAND_NAME', 'minitest') ENV['SW_APM_SERVICE_KEY'] = 'this-is-a-dummy-api-token-for-testing-111111111111111111111111111111111:test-service' diff --git a/test/minitest_helper.rb b/test/minitest_helper.rb index f44a87d6..37a9e028 100644 --- a/test/minitest_helper.rb +++ b/test/minitest_helper.rb @@ -4,7 +4,13 @@ # All rights reserved. require 'simplecov' -SimpleCov.start +require 'simplecov-cobertura' +SimpleCov.start do + formatter SimpleCov::Formatter::MultiFormatter.new([ + SimpleCov::Formatter::HTMLFormatter, + SimpleCov::Formatter::CoberturaFormatter + ]) +end SimpleCov.command_name ENV.fetch('SIMPLECOV_COMMAND_NAME', 'minitest') require 'minitest/autorun' diff --git a/test/sampling_test_helper.rb b/test/sampling_test_helper.rb index ea22f315..e9ccd4bf 100644 --- a/test/sampling_test_helper.rb +++ b/test/sampling_test_helper.rb @@ -8,7 +8,13 @@ require 'opentelemetry-test-helpers' require './lib/solarwinds_apm/sampling' require 'simplecov' -SimpleCov.start +require 'simplecov-cobertura' +SimpleCov.start do + formatter SimpleCov::Formatter::MultiFormatter.new([ + SimpleCov::Formatter::HTMLFormatter, + SimpleCov::Formatter::CoberturaFormatter + ]) +end SimpleCov.command_name ENV.fetch('SIMPLECOV_COMMAND_NAME', 'minitest') ENV['OTEL_METRICS_EXPORTER'] = 'none' From d5ab7ed24043f3eadf95999a3bce6054f2aa84e7 Mon Sep 17 00:00:00 2001 From: xuan-cao-swi Date: Tue, 24 Mar 2026 13:15:03 -0400 Subject: [PATCH 07/10] lint --- .github/workflows/run_unit_tests.yml | 2 +- test/initest_helper.rb | 6 +++--- test/minitest_helper.rb | 6 +++--- test/sampling_test_helper.rb | 6 +++--- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/run_unit_tests.yml b/.github/workflows/run_unit_tests.yml index 7d026636..d7b98f26 100644 --- a/.github/workflows/run_unit_tests.yml +++ b/.github/workflows/run_unit_tests.yml @@ -72,7 +72,7 @@ jobs: test/test_setup.sh - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage/coverage.xml diff --git a/test/initest_helper.rb b/test/initest_helper.rb index 1623a86c..c67b7cbe 100644 --- a/test/initest_helper.rb +++ b/test/initest_helper.rb @@ -12,9 +12,9 @@ require 'simplecov-cobertura' SimpleCov.start do formatter SimpleCov::Formatter::MultiFormatter.new([ - SimpleCov::Formatter::HTMLFormatter, - SimpleCov::Formatter::CoberturaFormatter - ]) + SimpleCov::Formatter::HTMLFormatter, + SimpleCov::Formatter::CoberturaFormatter + ]) end SimpleCov.command_name ENV.fetch('SIMPLECOV_COMMAND_NAME', 'minitest') diff --git a/test/minitest_helper.rb b/test/minitest_helper.rb index 37a9e028..998fb89b 100644 --- a/test/minitest_helper.rb +++ b/test/minitest_helper.rb @@ -7,9 +7,9 @@ require 'simplecov-cobertura' SimpleCov.start do formatter SimpleCov::Formatter::MultiFormatter.new([ - SimpleCov::Formatter::HTMLFormatter, - SimpleCov::Formatter::CoberturaFormatter - ]) + SimpleCov::Formatter::HTMLFormatter, + SimpleCov::Formatter::CoberturaFormatter + ]) end SimpleCov.command_name ENV.fetch('SIMPLECOV_COMMAND_NAME', 'minitest') diff --git a/test/sampling_test_helper.rb b/test/sampling_test_helper.rb index e9ccd4bf..1fb5848b 100644 --- a/test/sampling_test_helper.rb +++ b/test/sampling_test_helper.rb @@ -11,9 +11,9 @@ require 'simplecov-cobertura' SimpleCov.start do formatter SimpleCov::Formatter::MultiFormatter.new([ - SimpleCov::Formatter::HTMLFormatter, - SimpleCov::Formatter::CoberturaFormatter - ]) + SimpleCov::Formatter::HTMLFormatter, + SimpleCov::Formatter::CoberturaFormatter + ]) end SimpleCov.command_name ENV.fetch('SIMPLECOV_COMMAND_NAME', 'minitest') From 982750cde67623a05d802261fbe861fe1e370962 Mon Sep 17 00:00:00 2001 From: xuan-cao-swi Date: Tue, 24 Mar 2026 13:22:55 -0400 Subject: [PATCH 08/10] skip_validation --- .github/workflows/run_unit_tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/run_unit_tests.yml b/.github/workflows/run_unit_tests.yml index d7b98f26..4d116afe 100644 --- a/.github/workflows/run_unit_tests.yml +++ b/.github/workflows/run_unit_tests.yml @@ -78,3 +78,4 @@ jobs: files: coverage/coverage.xml flags: ruby-${{ matrix.ruby }}-${{ matrix.os }} fail_ci_if_error: false + skip_validation: true From 1b92979cfcb8ebcb81aa5bbf3d66510b7286e2c4 Mon Sep 17 00:00:00 2001 From: xuan-cao-swi Date: Wed, 25 Mar 2026 13:01:57 -0400 Subject: [PATCH 09/10] revision --- test/api/api_test.rb | 10 +- test/api/current_trace_info_test.rb | 10 +- test/api/transaction_name_test.rb | 99 ++++++++++--------- .../solarwinds_response_propagator_test.rb | 16 ++- test/sampling/json_sampler_test.rb | 78 ++++++++++++++- 5 files changed, 151 insertions(+), 62 deletions(-) diff --git a/test/api/api_test.rb b/test/api/api_test.rb index 19b6288a..0ac8e98b 100644 --- a/test/api/api_test.rb +++ b/test/api/api_test.rb @@ -10,11 +10,15 @@ describe 'API::OpenTelemetry#in_span delegation to OpenTelemetry tracer' do it 'returns nil and warns when block is nil' do - result = SolarWindsAPM::API.in_span('test_span') - assert_nil result + warned = false + SolarWindsAPM.logger.stub(:warn, ->(_msg = nil, &block) { warned = true if block&.call&.include?('please provide block') }) do + result = SolarWindsAPM::API.in_span('test_span') + assert_nil result + end + assert warned, 'Expected a warning to be logged when block is nil' end - it 'calls OpenTelemetry tracer in_span with a block' do + it 'calls in_span with a block and asserts the return value' do OpenTelemetry::SDK.configure result = SolarWindsAPM::API.in_span('test_span') do |span| refute_nil span diff --git a/test/api/current_trace_info_test.rb b/test/api/current_trace_info_test.rb index 15c49a94..3cf058bc 100644 --- a/test/api/current_trace_info_test.rb +++ b/test/api/current_trace_info_test.rb @@ -36,9 +36,9 @@ trace = SolarWindsAPM::API.current_trace_info result = trace.for_log - assert_includes result, 'trace_id=' - assert_includes result, 'span_id=' - assert_includes result, 'trace_flags=' + assert_match(/trace_id=[0-9a-f]{32}/, result) + assert_match(/span_id=[0-9a-f]{16}/, result) + assert_match(/trace_flags=[0-9a-f]{2}/, result) ensure SolarWindsAPM::Config[:log_traceId] = original end @@ -88,7 +88,7 @@ tracer.in_span('test_span') do trace = SolarWindsAPM::API.current_trace_info result = trace.for_log - assert_includes result, 'trace_id=' + assert_match(/trace_id=[0-9a-f]{32}/, result) end ensure SolarWindsAPM::Config[:log_traceId] = original @@ -104,7 +104,7 @@ trace = SolarWindsAPM::API.current_trace_info result = trace.for_log # The default sampler records & samples, so this should have trace info - assert_includes result, 'trace_id=' + assert_match(/trace_id=[0-9a-f]{32}/, result) end ensure SolarWindsAPM::Config[:log_traceId] = original diff --git a/test/api/transaction_name_test.rb b/test/api/transaction_name_test.rb index db1d71bb..ad475927 100644 --- a/test/api/transaction_name_test.rb +++ b/test/api/transaction_name_test.rb @@ -74,62 +74,69 @@ original_proc = SolarWindsAPM::OTelConfig[:metrics_processor] SolarWindsAPM::OTelConfig.class_variable_get(:@@config)[:metrics_processor] = stub_processor - # Without an active span, the span context is invalid - result = SolarWindsAPM::API.set_transaction_name('valid_name') - assert_equal false, result - ensure - SolarWindsAPM::OTelConfig.class_variable_get(:@@config)[:metrics_processor] = original_proc - end - - it 'sets transaction name within a valid span context' do - ENV['SW_APM_ENABLED'] = 'true' - - mock_txn_manager = Object.new - def mock_txn_manager.get_root_context_h(_trace_id) - 'abcdef1234567890-01' - end - - def mock_txn_manager.set(_key, _value); end - - mock_processor = Object.new - mock_processor.define_singleton_method(:txn_manager) { mock_txn_manager } - - original_proc = SolarWindsAPM::OTelConfig[:metrics_processor] - SolarWindsAPM::OTelConfig.class_variable_get(:@@config)[:metrics_processor] = mock_processor - OpenTelemetry::SDK.configure - tracer = OpenTelemetry.tracer_provider.tracer('test') + invalid_span = OpenTelemetry::Trace.non_recording_span(OpenTelemetry::Trace::SpanContext::INVALID) + context = OpenTelemetry::Trace.context_with_span(invalid_span) result = nil - tracer.in_span('test_span') do - result = SolarWindsAPM::API.set_transaction_name('custom_txn') + OpenTelemetry::Context.with_current(context) do + refute OpenTelemetry::Trace.current_span.context.valid? + result = SolarWindsAPM::API.set_transaction_name('valid_name') end - assert_equal true, result + assert_equal false, result ensure SolarWindsAPM::OTelConfig.class_variable_get(:@@config)[:metrics_processor] = original_proc end - it 'returns false when root context record not found in txn_manager' do - ENV['SW_APM_ENABLED'] = 'true' - - mock_txn_manager = Object.new - def mock_txn_manager.get_root_context_h(_trace_id) - nil + describe 'set_transaction_name with valid span context and processor' do + before do + ENV['SW_APM_ENABLED'] = 'true' + @set_calls = [] + set_calls = @set_calls + @mock_txn_manager = Object.new + @mock_txn_manager.define_singleton_method(:get_root_context_h) { |_trace_id| 'abcdef1234567890-01' } + @mock_txn_manager.define_singleton_method(:set) { |key, value| set_calls << [key, value] } + + mock_txn_manager = @mock_txn_manager + @mock_processor = Object.new + @mock_processor.define_singleton_method(:txn_manager) { mock_txn_manager } end - mock_processor = Object.new - mock_processor.define_singleton_method(:txn_manager) { mock_txn_manager } - - original_proc = SolarWindsAPM::OTelConfig[:metrics_processor] - SolarWindsAPM::OTelConfig.class_variable_get(:@@config)[:metrics_processor] = mock_processor + it 'sets transaction name within a valid span context' do + original_proc = SolarWindsAPM::OTelConfig[:metrics_processor] + SolarWindsAPM::OTelConfig.class_variable_get(:@@config)[:metrics_processor] = @mock_processor + + OpenTelemetry::SDK.configure + tracer = OpenTelemetry.tracer_provider.tracer('test') + result = nil + tracer.in_span('test_span') do |span| + # set_transaction_name will call :set which is mocked that inject kv into set_calls array. + result = SolarWindsAPM::API.set_transaction_name('custom_txn') + expected_key = "#{span.context.hex_trace_id}-abcdef1234567890" + assert_equal 1, @set_calls.length + assert_equal expected_key, @set_calls[0][0] + assert_equal 'custom_txn', @set_calls[0][1] + end + assert_equal true, result + ensure + SolarWindsAPM::OTelConfig.class_variable_get(:@@config)[:metrics_processor] = original_proc + end - OpenTelemetry::SDK.configure - tracer = OpenTelemetry.tracer_provider.tracer('test') - result = nil - tracer.in_span('test_span') do - result = SolarWindsAPM::API.set_transaction_name('custom_txn') + it 'returns false when root context record not found in txn_manager' do + @mock_txn_manager.define_singleton_method(:get_root_context_h) { |_trace_id| nil } + original_proc = SolarWindsAPM::OTelConfig[:metrics_processor] + SolarWindsAPM::OTelConfig.class_variable_get(:@@config)[:metrics_processor] = @mock_processor + + OpenTelemetry::SDK.configure + tracer = OpenTelemetry.tracer_provider.tracer('test') + result = nil + tracer.in_span('test_span') do + # :get_root_context_h is mocked to return nil so no record found, set_transaction_name should return false. + result = SolarWindsAPM::API.set_transaction_name('custom_txn') + end + assert_equal false, result + assert_empty @set_calls + ensure + SolarWindsAPM::OTelConfig.class_variable_get(:@@config)[:metrics_processor] = original_proc end - assert_equal false, result - ensure - SolarWindsAPM::OTelConfig.class_variable_get(:@@config)[:metrics_processor] = original_proc end end diff --git a/test/opentelemetry/solarwinds_response_propagator_test.rb b/test/opentelemetry/solarwinds_response_propagator_test.rb index 4cc63142..e3849865 100644 --- a/test/opentelemetry/solarwinds_response_propagator_test.rb +++ b/test/opentelemetry/solarwinds_response_propagator_test.rb @@ -24,9 +24,11 @@ describe 'inject' do it 'injects x-trace header for valid span context' do + raw_span_id = Random.bytes(8) + raw_trace_id = Random.bytes(16) span_context = OpenTelemetry::Trace::SpanContext.new( - span_id: Random.bytes(8), - trace_id: Random.bytes(16), + span_id: raw_span_id, + trace_id: raw_trace_id, trace_flags: OpenTelemetry::Trace::TraceFlags::SAMPLED, tracestate: OpenTelemetry::Trace::Tracestate.from_hash({}) ) @@ -36,19 +38,22 @@ carrier = {} @propagator.inject(carrier, context: context) + expected_x_trace = "00-#{raw_trace_id.unpack1('H*')}-#{raw_span_id.unpack1('H*')}-01" assert carrier.key?('x-trace') - assert_includes carrier['x-trace'], '00-' + assert_equal expected_x_trace, carrier['x-trace'] assert carrier.key?('Access-Control-Expose-Headers') assert_includes carrier['Access-Control-Expose-Headers'], 'x-trace' end it 'injects x-trace-options-response when xtrace_options_response in tracestate' do + raw_span_id = Random.bytes(8) + raw_trace_id = Random.bytes(16) tracestate = OpenTelemetry::Trace::Tracestate.from_hash({ 'xtrace_options_response' => 'auth:ok;trigger-trace:ok' }) span_context = OpenTelemetry::Trace::SpanContext.new( - span_id: Random.bytes(8), - trace_id: Random.bytes(16), + span_id: raw_span_id, + trace_id: raw_trace_id, trace_flags: OpenTelemetry::Trace::TraceFlags::SAMPLED, tracestate: tracestate ) @@ -59,6 +64,7 @@ @propagator.inject(carrier, context: context) assert carrier.key?('x-trace-options-response') + assert_equal 'auth=ok;trigger-trace=ok', carrier['x-trace-options-response'] assert_includes carrier['Access-Control-Expose-Headers'], 'x-trace-options-response' end diff --git a/test/sampling/json_sampler_test.rb b/test/sampling/json_sampler_test.rb index 2f3469d6..6a997a7d 100644 --- a/test/sampling/json_sampler_test.rb +++ b/test/sampling/json_sampler_test.rb @@ -176,12 +176,15 @@ ])) sampler = SolarWindsAPM::JsonSampler.new({}, @temp_path) - # Second call should skip due to not expired + # loop_check sets @expiry = timestamp + ttl (far future), so the next call returns early + # @expiry must be unchanged, proving loop_check returned early without re-reading the file + expiry_before = sampler.instance_variable_get(:@expiry) params = make_sample_params sampler.should_sample?(params) + assert_equal expiry_before, sampler.instance_variable_get(:@expiry) end - it 'does not re-read when file mtime unchanged' do + it 'does not re-read when file mtime unchanged xuan' do File.write(@temp_path, JSON.dump([ { 'flags' => 'SAMPLE_START,SAMPLE_THROUGH_ALWAYS', @@ -194,9 +197,78 @@ sampler = SolarWindsAPM::JsonSampler.new({}, @temp_path) sleep(0.1) - # Force expired to re-read, but mtime is the same + # Force expiry into the past so loop_check will pass `return if Time.now.to_i < @expiry - 10` + # Since the file mtime is unchanged, loop_check should return early and not update @expiry + forced_expiry = Time.now.to_i - 100 + sampler.instance_variable_set(:@expiry, forced_expiry) + params = make_sample_params + sampler.should_sample?(params) + assert_equal forced_expiry, sampler.instance_variable_get(:@expiry) + end + + it 'updates expiry when settings are expired and no prior mtime recorded' do + # Sampler is created with a missing file so loop_check on init returns early without + # setting @last_mtime, leaving it nil. The mtime guard is then skipped on the next call. + FileUtils.rm_f(@temp_path) + sampler = SolarWindsAPM::JsonSampler.new({}, @temp_path) + assert_nil sampler.instance_variable_get(:@last_mtime) + + new_timestamp = Time.now.to_i + new_ttl = 300 + File.write(@temp_path, JSON.dump([ + { + 'flags' => 'SAMPLE_START,SAMPLE_THROUGH_ALWAYS', + 'value' => 1_000_000, + 'arguments' => { 'BucketCapacity' => 100, 'BucketRate' => 10 }, + 'timestamp' => new_timestamp, + 'ttl' => new_ttl + } + ])) + + # Force expiry into the past so the time guard is cleared + sampler.instance_variable_set(:@expiry, Time.now.to_i - 100) + params = make_sample_params + sampler.should_sample?(params) + + # @expiry must now reflect the newly read file content + assert_equal new_timestamp + new_ttl, sampler.instance_variable_get(:@expiry) + end + + it 'updates expiry when settings are expired and file has changed since last read' do + File.write(@temp_path, JSON.dump([ + { + 'flags' => 'SAMPLE_START,SAMPLE_THROUGH_ALWAYS', + 'value' => 500_000, + 'arguments' => { 'BucketCapacity' => 50, 'BucketRate' => 5 }, + 'timestamp' => Time.now.to_i - 60, + 'ttl' => 10 + } + ])) + sampler = SolarWindsAPM::JsonSampler.new({}, @temp_path) + # @last_mtime is now set to the initial file's mtime + refute_nil sampler.instance_variable_get(:@last_mtime) + + # sleep to guarantee the next write gets a strictly newer mtime + sleep(1) + + new_timestamp = Time.now.to_i + new_ttl = 300 + File.write(@temp_path, JSON.dump([ + { + 'flags' => 'SAMPLE_START,SAMPLE_THROUGH_ALWAYS', + 'value' => 1_000_000, + 'arguments' => { 'BucketCapacity' => 100, 'BucketRate' => 10 }, + 'timestamp' => new_timestamp, + 'ttl' => new_ttl + } + ])) + + # Force expiry into the past so the time guard is cleared sampler.instance_variable_set(:@expiry, Time.now.to_i - 100) params = make_sample_params sampler.should_sample?(params) + + # @expiry must now equal timestamp + ttl from the updated file + assert_equal new_timestamp + new_ttl, sampler.instance_variable_get(:@expiry) end end From 26b4850df05d3360ee440ae43a0573529fd8200c Mon Sep 17 00:00:00 2001 From: xuan-cao-swi Date: Wed, 25 Mar 2026 13:23:18 -0400 Subject: [PATCH 10/10] typo --- test/sampling/json_sampler_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/sampling/json_sampler_test.rb b/test/sampling/json_sampler_test.rb index 6a997a7d..0a983a2e 100644 --- a/test/sampling/json_sampler_test.rb +++ b/test/sampling/json_sampler_test.rb @@ -184,7 +184,7 @@ assert_equal expiry_before, sampler.instance_variable_get(:@expiry) end - it 'does not re-read when file mtime unchanged xuan' do + it 'does not re-read when file mtime unchanged' do File.write(@temp_path, JSON.dump([ { 'flags' => 'SAMPLE_START,SAMPLE_THROUGH_ALWAYS',