diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c2f47762..dbc53e5e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -167,6 +167,18 @@ For comprehensive Ruby coding standards including: **Refer to [.github/instructions/ruby.instructions.md](.github/instructions/ruby.instructions.md)** which is automatically applied to all Ruby files based on the `applyTo` glob pattern. +## Test Assertion Preferences + +When generating or modifying test code: + +- **Always use `assert_equal`** for exact value matching. Never use `assert_includes` for partial string matching when the full expected value is deterministic. +- If `assert_includes` is unavoidable due to non-deterministic output, add a comment explaining why. +- Use **`assert_instance_of`** instead of `refute_nil` when verifying object types. +- Assert **full object content** (exact string values, complete attributes) rather than just checking key existence. +- When code under test logs warnings or errors, **stub the logger** to capture and assert the log message. +- Use `assert_equal expected, actual` form consistently. Avoid mixing `_(x).must_equal y` and `assert_equal` in the same file. +- Use `assert_match` with regex for format patterns (e.g., UUIDs, hex strings). + ## Versioning and Releases This project uses Semantic Versioning: diff --git a/.github/instructions/coding.instructions.md b/.github/instructions/coding.instructions.md index 89d34b3c..42ded140 100644 --- a/.github/instructions/coding.instructions.md +++ b/.github/instructions/coding.instructions.md @@ -36,4 +36,12 @@ applyTo: "**/*.rb,**/*.yml,**/*.sh,Rakefile" - One way to improve the readability and clarity of functions is to encapsulate nested if/else statements into other functions. - Encapsulating such logic into a function with a descriptive name clarifies its purpose and simplifies code comprehension. +## Testing assertion preferences +- Always use `assert_equal` (or `must_equal`) to assert exact expected values. Never use `assert_includes` for partial string matching when the full expected value is deterministic. +- If `assert_includes` must be used due to non-deterministic output, add a comment explaining why partial matching is necessary. +- Use `assert_instance_of` to verify object types instead of `refute_nil`. +- Assert full object content (e.g., exact string values, complete attribute values) rather than just checking for key existence or non-nil. +- When code logs warnings or errors, add logger stubbing in the test to capture and assert the exact log message via `assert_equal` or `assert_match`. +- Use `assert_equal` consistently instead of mixing `_(x).must_equal y` and `assert_equal y, x` within the same test file. + \ No newline at end of file diff --git a/.github/instructions/ruby.instructions.md b/.github/instructions/ruby.instructions.md index d802c7a4..67b69f62 100644 --- a/.github/instructions/ruby.instructions.md +++ b/.github/instructions/ruby.instructions.md @@ -236,6 +236,29 @@ end - Use Minitest expectations: `assert`, `refute`, `assert_equal` - Prefer `assert` and `refute` over `assert_equal true/false` +### Test assertion preferences + +- **Always use `assert_equal`** to assert exact expected values. Never use `assert_includes` for partial string matching when the full expected value is deterministic. +- If `assert_includes` must be used due to non-deterministic output, add a comment explaining why partial matching is necessary (e.g., `# assert_includes used because order is non-deterministic`). +- Use **`assert_instance_of`** to verify object types instead of `refute_nil` (e.g., `assert_instance_of OpenTelemetry::Context, context` instead of `refute_nil context`). +- Assert **full object content** (exact string values, complete attribute values) rather than just checking for key existence or non-nil. +- When code under test logs warnings or errors, **add logger stubbing** to capture and assert the exact log message: + +```ruby +warned = false +SolarWindsAPM.logger.stub(:warn, ->(_msg = nil, &block) { warned = true if block&.call&.include?('expected message') }) do + # code under test +end +assert warned, 'Expected warning to be logged' +``` + +- Use `assert_equal` consistently instead of mixing `_(x).must_equal y` and `assert_equal y, x` within the same test file. Prefer `assert_equal expected, actual` form. +- Use `assert_match` with regex when asserting format patterns (e.g., UUID format, hex strings): + +```ruby +assert_match(/^[0-9a-f]{32}$/, result['trace_id']) +``` + Example: ```ruby describe 'SolarWindsAPM::TokenBucket' do diff --git a/.github/workflows/run_unit_tests.yml b/.github/workflows/run_unit_tests.yml index 7ff4efe2..4d116afe 100644 --- a/.github/workflows/run_unit_tests.yml +++ b/.github/workflows/run_unit_tests.yml @@ -70,3 +70,12 @@ 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@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage/coverage.xml + flags: ruby-${{ matrix.ruby }}-${{ matrix.os }} + fail_ci_if_error: false + skip_validation: true 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/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/api/api_test.rb b/test/api/api_test.rb new file mode 100644 index 00000000..2623832b --- /dev/null +++ b/test/api/api_test.rb @@ -0,0 +1,139 @@ +# 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 + 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 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 + 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 + warned = false + SolarWindsAPM.logger.stub(:warn, ->(_msg = nil, &block) { warned = true if block&.call&.include?('increment_metric is deprecated') }) do + result = SolarWindsAPM::API.increment_metric('test_metric', 1, false, {}) + assert_equal false, result + end + assert warned, 'Expected a deprecation warning to be logged for increment_metric' + end + + it 'summary_metric returns false with deprecation' do + warned = false + SolarWindsAPM.logger.stub(:warn, ->(_msg = nil, &block) { warned = true if block&.call&.include?('summary_metric is deprecated') }) do + result = SolarWindsAPM::API.summary_metric('test_metric', 5.0, 1, false, {}) + assert_equal false, result + end + assert warned, 'Expected a deprecation warning to be logged for summary_metric' + end +end + +describe 'API::Tracer#add_tracer method wrapping with span instrumentation' do + let(:sdk) { OpenTelemetry::SDK } + let(:exporter) { sdk::Trace::Export::InMemorySpanExporter.new } + let(:span_processor) { sdk::Trace::Export::SimpleSpanProcessor.new(exporter) } + + before do + ENV['OTEL_SERVICE_NAME'] = __FILE__ + OpenTelemetry.tracer_provider = sdk::Trace::TracerProvider.new.tap do |provider| + provider.add_span_processor(span_processor) + end + end + + after do + ENV.delete('OTEL_SERVICE_NAME') + end + + 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 + + instance = klass.new + result = instance.greeting + assert_equal 'hello', result + + spans = exporter.finished_spans + skip if spans.empty? + assert_equal 1, spans.size + assert_equal 'greeting_span', spans[0].name + assert_equal :internal, spans[0].kind + 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 + + instance = klass.new + result = instance.work + assert_equal 'done', result + + spans = exporter.finished_spans + skip if spans.empty? + assert_equal 1, spans.size + assert spans[0].name.end_with?('/add_tracer'), "Expected span name to end with '/add_tracer', got #{spans[0].name}" + assert_equal :internal, spans[0].kind + 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 + + instance = klass.new + result = instance.compute + assert_equal 100, result + + spans = exporter.finished_spans + skip if spans.empty? + assert_equal 1, spans.size + assert_equal 'compute_span', spans[0].name + assert_equal :consumer, spans[0].kind + assert_equal 'bar', spans[0].attributes['foo'] + 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..198044da --- /dev/null +++ b/test/api/current_trace_info_test.rb @@ -0,0 +1,146 @@ +# 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_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 + + it 'returns hash for hash_for_log when log_traceId is :always' do + original = SolarWindsAPM::Config[:log_traceId] + SolarWindsAPM::Config[:log_traceId] = :always + original_service_name = ENV.fetch('OTEL_SERVICE_NAME', nil) + ENV['OTEL_SERVICE_NAME'] = 'test-service-name' + + trace = SolarWindsAPM::API.current_trace_info + result = trace.hash_for_log + assert_match(/^[0-9a-f]{32}$/, result['trace_id']) + assert_match(/^[0-9a-f]{16}$/, result['span_id']) + assert_match(/^[0-9a-f]{2}$/, result['trace_flags']) + assert_equal 'test-service-name', result['resource.service.name'] + ensure + SolarWindsAPM::Config[:log_traceId] = original + ENV['OTEL_SERVICE_NAME'] = original_service_name + 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_match(/trace_id=[0-9a-f]{32}/, result) + 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_match(/trace_id=[0-9a-f]{32}/, result) + 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_equal true, 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..ad475927 --- /dev/null +++ b/test/api/transaction_name_test.rb @@ -0,0 +1,142 @@ +# 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.fetch('SW_APM_ENABLED', nil) + 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 + + OpenTelemetry::SDK.configure + invalid_span = OpenTelemetry::Trace.non_recording_span(OpenTelemetry::Trace::SpanContext::INVALID) + context = OpenTelemetry::Trace.context_with_span(invalid_span) + result = nil + OpenTelemetry::Context.with_current(context) do + refute OpenTelemetry::Trace.current_span.context.valid? + result = SolarWindsAPM::API.set_transaction_name('valid_name') + end + assert_equal false, result + ensure + SolarWindsAPM::OTelConfig.class_variable_get(:@@config)[:metrics_processor] = original_proc + end + + 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 + + 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 + + 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 + end +end diff --git a/test/initest_helper.rb b/test/initest_helper.rb index bdc26fad..c67b7cbe 100644 --- a/test/initest_helper.rb +++ b/test/initest_helper.rb @@ -9,7 +9,14 @@ 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 9d4b46da..998fb89b 100644 --- a/test/minitest_helper.rb +++ b/test/minitest_helper.rb @@ -3,6 +3,16 @@ # Copyright (c) 2016 SolarWinds, LLC. # All rights reserved. +require 'simplecov' +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' require 'minitest/spec' require 'minitest/reporters' @@ -25,9 +35,6 @@ require './lib/solarwinds_apm/constants' require 'opentelemetry-exporter-otlp-metrics' -require 'simplecov' -SimpleCov.start - # 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..c83541e6 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,37 +21,37 @@ @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, end_time: span_data.end_timestamp) - _(result).must_equal 44_853 + assert_equal 44_853, result result = @processor.send(:calculate_span_time, start_time: span_data.start_timestamp, end_time: nil) - _(result).must_equal 0 + assert_equal 0, result result = @processor.send(:calculate_span_time, start_time: nil, end_time: span_data.end_timestamp) - _(result).must_equal 0 + assert_equal 0, result 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( @@ -376,4 +380,291 @@ _(result['sw.is_error']).must_equal false end 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 nil span to trigger NoMethodError inside on_start + span = nil + bad_context = nil + + logged_msg = nil + SolarWindsAPM.logger.stub(:info, ->(_msg = nil, &block) { logged_msg = block&.call }) do + @processor.on_start(span, bad_context) + end + refute_nil logged_msg + assert_match(/processor on_start error:/, logged_msg) + 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 '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.positive? + end + end + + describe 'error?' do + it 'returns 1 for error status' do + 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 + 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 + 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 '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 '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'] + assert_equal false, result['sw.is_error'] + assert_equal 'test_txn', result['sw.transaction'] + 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..a0f1d611 100644 --- a/test/opentelemetry/solarwinds_propagator_test.rb +++ b/test/opentelemetry/solarwinds_propagator_test.rb @@ -9,20 +9,23 @@ 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 @text_map_propagator = SolarWindsAPM::OpenTelemetry::SolarWindsPropagator::TextMapPropagator.new + @propagator = @text_map_propagator @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 +35,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 +51,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 +68,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 +85,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 +103,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 @@ -117,4 +120,122 @@ _(@mock.verify).must_equal true 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 '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) + assert_instance_of OpenTelemetry::Context, context + end + + it 'handles exceptions gracefully' do + carrier = nil + context = @propagator.extract(carrier, context: OpenTelemetry::Context.empty) + assert_instance_of OpenTelemetry::Context, context + end + end + + describe 'inject' do + it 'injects sw tracestate when no existing tracestate' do + span_id = Random.bytes(8) + trace_id = Random.bytes(16) + span_context = OpenTelemetry::Trace::SpanContext.new( + span_id: span_id, + trace_id: trace_id, + 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) + + expected_sw = "#{span_id.unpack1('H*')}-01" + assert_equal "sw=#{expected_sw}", carrier['tracestate'] + end + + it 'updates existing tracestate with sw value' do + span_id = Random.bytes(8) + trace_id = Random.bytes(16) + span_context = OpenTelemetry::Trace::SpanContext.new( + span_id: span_id, + trace_id: trace_id, + 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) + + expected_sw = "#{span_id.unpack1('H*')}-01" + assert_equal "other=value,sw=#{expected_sw}", carrier['tracestate'] + 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_id = Random.bytes(8) + trace_id = Random.bytes(16) + span_context = OpenTelemetry::Trace::SpanContext.new( + span_id: span_id, + trace_id: trace_id, + 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) + + expected_sw = "#{span_id.unpack1('H*')}-01" + assert_equal "sw=#{expected_sw}", carrier['tracestate'] + end + + it 'sets trace flag 00 for non-sampled spans' do + span_id = Random.bytes(8) + trace_id = Random.bytes(16) + span_context = OpenTelemetry::Trace::SpanContext.new( + span_id: span_id, + trace_id: trace_id, + 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) + + expected_sw = "#{span_id.unpack1('H*')}-00" + assert_equal "sw=#{expected_sw}", carrier['tracestate'] + 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..5d09727d --- /dev/null +++ b/test/opentelemetry/solarwinds_response_propagator_test.rb @@ -0,0 +1,96 @@ +# 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 + raw_span_id = Random.bytes(8) + raw_trace_id = Random.bytes(16) + span_context = OpenTelemetry::Trace::SpanContext.new( + span_id: raw_span_id, + trace_id: raw_trace_id, + 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) + + expected_x_trace = "00-#{raw_trace_id.unpack1('H*')}-#{raw_span_id.unpack1('H*')}-01" + assert carrier.key?('x-trace') + assert_equal expected_x_trace, carrier['x-trace'] + assert carrier.key?('Access-Control-Expose-Headers') + assert_equal 'x-trace', carrier['Access-Control-Expose-Headers'] + 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: raw_span_id, + trace_id: raw_trace_id, + 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_equal 'auth=ok;trigger-trace=ok', carrier['x-trace-options-response'] + assert_equal 'x-trace,x-trace-options-response', carrier['Access-Control-Expose-Headers'] + 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..bdd632ce --- /dev/null +++ b/test/patch/sw_dbo_utils_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/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 |span| + trace_id = span.context.hex_trace_id + span_id = span.context.hex_span_id + expected_traceparent = "00-#{trace_id}-#{span_id}-01" + result = SolarWindsAPM::Patch::TagSql::SWODboUtils.annotate_span_and_sql('SELECT 1') + assert_equal "SELECT 1 /*traceparent='#{expected_traceparent}'*/", result + end + 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/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/dice_test.rb b/test/sampling/dice_test.rb index 72211f0c..6ef399af 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 @@ -48,4 +50,26 @@ dice.update(rate: 0) 500.times { refute dice.roll } end + + 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 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..7d76e09a 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,4 +145,145 @@ assert_equal span.attributes.keys, %w[SampleRate SampleSource BucketCapacity BucketRate] end end + + it 'handles invalid JSON file content' do + File.write(@temp_path, 'not valid json{{{') + logged_msg = nil + SolarWindsAPM.logger.stub(:error, ->(_msg = nil, &block) { logged_msg = block&.call }) do + sampler = SolarWindsAPM::JsonSampler.new({}, @temp_path) + refute_nil sampler + end + refute_nil logged_msg + assert_match(/JSON parsing error in #{Regexp.escape(@temp_path)}/, logged_msg) + end + + it 'handles invalid settings structure (not single element array)' do + File.write(@temp_path, JSON.dump([{ flags: 'a' }, { flags: 'b' }])) + logged_msg = nil + SolarWindsAPM.logger.stub(:error, ->(_msg = nil, &block) { logged_msg = block&.call }) do + sampler = SolarWindsAPM::JsonSampler.new({}, @temp_path) + refute_nil sampler + end + refute_nil logged_msg + assert_match(/Invalid settings file content/, logged_msg) + end + + it 'handles empty array in settings file' do + File.write(@temp_path, JSON.dump([])) + logged_msg = nil + SolarWindsAPM.logger.stub(:error, ->(_msg = nil, &block) { logged_msg = block&.call }) do + sampler = SolarWindsAPM::JsonSampler.new({}, @temp_path) + refute_nil sampler + end + refute_nil logged_msg + assert_match(/Invalid settings file content/, logged_msg) + 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) + + # 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 + 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 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 diff --git a/test/sampling/metrics_test.rb b/test/sampling/metrics_test.rb new file mode 100644 index 00000000..44433cb9 --- /dev/null +++ b/test/sampling/metrics_test.rb @@ -0,0 +1,35 @@ +# 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 + before do + @original_meter_provider = OpenTelemetry.meter_provider + OpenTelemetry.meter_provider = OpenTelemetry::SDK::Metrics::MeterProvider.new + end + + after do + OpenTelemetry.meter_provider = @original_meter_provider + end + + it 'initializes counters' do + counter = SolarWindsAPM::Metrics::Counter.new + assert_instance_of OpenTelemetry::SDK::Metrics::Instrument::Counter, counter[:request_count] + assert_instance_of OpenTelemetry::SDK::Metrics::Instrument::Counter, counter[:sample_count] + assert_instance_of OpenTelemetry::SDK::Metrics::Instrument::Counter, counter[:trace_count] + assert_instance_of OpenTelemetry::SDK::Metrics::Instrument::Counter, counter[:through_trace_count] + assert_instance_of OpenTelemetry::SDK::Metrics::Instrument::Counter, counter[:triggered_trace_count] + assert_instance_of OpenTelemetry::SDK::Metrics::Instrument::Counter, 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..3e24bf91 100644 --- a/test/sampling/oboe_sampler_test.rb +++ b/test/sampling/oboe_sampler_test.rb @@ -97,7 +97,7 @@ sample = sampler.should_sample?(params) assert_equal TEST_OTEL_SAMPLING_DECISION::DROP, sample.instance_variable_get(:@decision) assert_empty sample.attributes - assert_includes sample.tracestate['xtrace_options_response'], 'auth:no-signature-key' + assert_equal 'auth:no-signature-key', sample.tracestate['xtrace_options_response'] check_counters(@metric_exporter, ['trace.service.request_count']) end @@ -129,7 +129,7 @@ sample = sampler.should_sample?(params) assert_equal TEST_OTEL_SAMPLING_DECISION::DROP, sample.instance_variable_get(:@decision) assert_empty sample.attributes - assert_includes sample.tracestate['xtrace_options_response'], 'auth:bad-timestamp' + assert_equal 'auth:bad-timestamp', sample.tracestate['xtrace_options_response'] check_counters(@metric_exporter, ['trace.service.request_count']) end @@ -161,7 +161,7 @@ sample = sampler.should_sample?(params) assert_equal TEST_OTEL_SAMPLING_DECISION::DROP, sample.instance_variable_get(:@decision) assert_empty sample.attributes - assert_includes sample.tracestate['xtrace_options_response'], 'auth:bad-signature' + assert_equal 'auth:bad-signature', sample.tracestate['xtrace_options_response'] check_counters(@metric_exporter, ['trace.service.request_count']) end @@ -219,7 +219,7 @@ params = make_sample_params(parent: false) sample = sampler.should_sample?(params) assert_equal sample.attributes, { 'custom-key' => 'value', 'SWKeys' => 'sw-values' } - assert_includes sample.tracestate['xtrace_options_response'], 'trigger-trace:not-requested' + assert_equal 'trigger-trace:not-requested', sample.tracestate['xtrace_options_response'] end it 'ignores trigger-trace' do @@ -235,8 +235,7 @@ params = make_sample_params(parent: false) sample = sampler.should_sample?(params) assert_equal sample.attributes, { 'custom-key' => 'value' } - assert_includes sample.tracestate['xtrace_options_response'], 'trigger-trace:settings-not-available' - assert_includes sample.tracestate['xtrace_options_response'], 'ignored:invalid-key' + assert_equal 'trigger-trace:settings-not-available;ignored:invalid-key', sample.tracestate['xtrace_options_response'] end end @@ -265,7 +264,7 @@ sample = sampler.should_sample?(params) _(sample.attributes['custom-key']).must_equal 'value' _(sample.attributes['SWKeys']).must_equal 'sw-values' - assert_includes sample.tracestate['xtrace_options_response'], 'trigger-trace:not-requested' + assert_equal 'trigger-trace:not-requested', sample.tracestate['xtrace_options_response'] end it 'ignores trigger-trace' do @@ -290,8 +289,7 @@ sample = sampler.should_sample?(params) _(sample.attributes['custom-key']).must_equal 'value' - assert_includes sample.tracestate['xtrace_options_response'], 'trigger-trace:ignored' - assert_includes sample.tracestate['xtrace_options_response'], 'ignored:invalid-key' + assert_equal 'trigger-trace:ignored;ignored:invalid-key', sample.tracestate['xtrace_options_response'] end end @@ -448,7 +446,7 @@ _(sample.attributes['BucketCapacity']).must_equal 10 _(sample.attributes['BucketRate']).must_equal 5 - assert_includes sample.tracestate['xtrace_options_response'], 'trigger-trace:ok' + assert_equal 'trigger-trace:ok', sample.tracestate['xtrace_options_response'] check_counters(@metric_exporter, [ 'trace.service.request_count', @@ -487,8 +485,7 @@ _(sample.attributes['BucketCapacity']).must_equal 0 _(sample.attributes['BucketRate']).must_equal 0 - assert_includes sample.tracestate['xtrace_options_response'], 'trigger-trace:rate-exceeded' - assert_includes sample.tracestate['xtrace_options_response'], 'ignored:invalid-key' + assert_equal 'trigger-trace:rate-exceeded;ignored:invalid-key', sample.tracestate['xtrace_options_response'] check_counters(@metric_exporter, ['trace.service.request_count']) end @@ -529,8 +526,7 @@ _(sample.attributes['BucketCapacity']).must_equal 20 _(sample.attributes['BucketRate']).must_equal 10 - assert_includes sample.tracestate['xtrace_options_response'], 'auth:ok' - assert_includes sample.tracestate['xtrace_options_response'], 'trigger-trace:ok' + assert_equal 'auth:ok;trigger-trace:ok', sample.tracestate['xtrace_options_response'] check_counters(@metric_exporter, [ 'trace.service.request_count', @@ -572,9 +568,7 @@ _(sample.attributes['BucketCapacity']).must_equal 0 _(sample.attributes['BucketRate']).must_equal 0 - assert_includes sample.tracestate['xtrace_options_response'], 'auth:ok' - assert_includes sample.tracestate['xtrace_options_response'], 'trigger-trace:rate-exceeded' - assert_includes sample.tracestate['xtrace_options_response'], 'ignored:invalid-key' + assert_equal 'auth:ok;trigger-trace:rate-exceeded;ignored:invalid-key', sample.tracestate['xtrace_options_response'] check_counters(@metric_exporter, ['trace.service.request_count']) end @@ -605,8 +599,7 @@ sample = sampler.should_sample?(params) assert_equal TEST_OTEL_SAMPLING_DECISION::RECORD_ONLY, sample.instance_variable_get(:@decision) assert_equal sample.attributes, { 'custom-key' => 'value' } - assert_includes sample.tracestate['xtrace_options_response'], 'trigger-trace:trigger-tracing-disabled' - assert_includes sample.tracestate['xtrace_options_response'], 'ignored:invalid-key' + assert_equal 'trigger-trace:trigger-tracing-disabled;ignored:invalid-key', sample.tracestate['xtrace_options_response'] check_counters(@metric_exporter, ['trace.service.request_count']) end @@ -636,7 +629,7 @@ _(sample.attributes['custom-key']).must_equal 'value' _(sample.attributes['SWKeys']).must_equal 'sw-values' - assert_includes sample.tracestate['xtrace_options_response'], 'trigger-trace:not-requested' + assert_equal 'trigger-trace:not-requested', sample.tracestate['xtrace_options_response'] end it 'records and samples when dice success and sufficient capacity' do @@ -747,8 +740,7 @@ _(sample.attributes['custom-key']).must_equal 'value' - assert_includes sample.tracestate['xtrace_options_response'], 'trigger-trace:tracing-disabled' - assert_includes sample.tracestate['xtrace_options_response'], 'ignored:invalid-key' + assert_equal 'trigger-trace:tracing-disabled;ignored:invalid-key', sample.tracestate['xtrace_options_response'] end it 'records when SAMPLE_THROUGH_ALWAYS set' do @@ -795,19 +787,257 @@ 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 'parent_based_algo' do + 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) + puts "result: #{result.inspect}" + assert_equal 'auth:ok;trigger-trace:ignored', result.tracestate['xtrace_options_response'] + 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_equal 'auth:ok;trigger-trace:ok', result.tracestate['xtrace_options_response'] + 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 + end + + describe 'settings management' do + 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_equal 'trigger-trace:not-requested', result.tracestate['xtrace_options_response'] + 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_match(/^[0-9a-f]{16}-01$/, 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) + assert_match(/^[0-9a-f]{16}-01$/, result.tracestate['sw']) + 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 - # 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' }) @@ -829,4 +1059,28 @@ 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( + 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_equal "#{parent.context.hex_span_id}-01", result + 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_equal "#{parent.context.hex_span_id}-00", result + end + end end diff --git a/test/sampling/sampler_test.rb b/test/sampling/sampler_test.rb index b2967f8b..e62029bd 100644 --- a/test/sampling/sampler_test.rb +++ b/test/sampling/sampler_test.rb @@ -465,4 +465,224 @@ def replace_sampler(sampler) _(spans[0].attributes['BucketRate']).must_equal 0.1 end end + + describe 'parse_settings' do + before do + @sampler = TestSampler.new({ local_settings: {} }) + 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) + assert_equal SolarWindsAPM::TracingMode::NEVER, settings[:tracing_mode] + ensure + SolarWindsAPM::Config[:transaction_settings] = nil + end + end + + describe 'http_span_metadata additional' do + before do + @sampler = TestSampler.new({ local_settings: {} }) + 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..d17eca43 --- /dev/null +++ b/test/sampling/sampling_patch_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/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, []) + + [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 with expected name and kind + assert_equal 'test_span', span.name + assert_equal :internal, span.kind + 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 + assert_equal 'test_span', span.name + + warned = false + OpenTelemetry.logger.stub(:warn, ->(_msg = nil) { warned = true }) do + span.finish + end + assert warned, 'Expected a warning to be logged on double finish' + end +end diff --git a/test/sampling/settings_test.rb b/test/sampling/settings_test.rb index 2e44078c..667bf072 100644 --- a/test/sampling/settings_test.rb +++ b/test/sampling/settings_test.rb @@ -121,4 +121,170 @@ end end end + + describe 'merge additional' 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 + + 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, 12_345, 'keys', {}, [], nil) + assert opts.trigger_trace + assert_equal 12_345, 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/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 3c4973cd..c7e4a328 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) @@ -220,48 +222,89 @@ _(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 + ts = Time.now.to_i + header = "trigger-trace;sw-keys=check-id:123;custom-foo=bar;ts=#{ts}" + result = SolarWindsAPM::TraceOptions.parse_trace_options(header, logger) + + assert_equal true, result.trigger_trace + assert_equal 'check-id:123', result.sw_keys + assert_equal 'bar', result.custom['custom-foo'] + assert_equal ts, result.timestamp + end 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 + + 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) + assert_equal 'trigger-trace:ok', result + 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 - 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 '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 diff --git a/test/sampling_test_helper.rb b/test/sampling_test_helper.rb index 677c4853..1fb5848b 100644 --- a/test/sampling_test_helper.rb +++ b/test/sampling_test_helper.rb @@ -8,7 +8,14 @@ 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' diff --git a/test/solarwinds_apm/config_test.rb b/test/solarwinds_apm/config_test.rb index 6d9e1806..1a18ca2f 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 @@ -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 @@ -297,4 +297,310 @@ ENV.delete('SW_APM_TAG_SQL') end end + + describe 'enable_disable_config tested via []= assignment' do + it 'uses env var when valid enabled/disabled value' do + original = ENV.fetch('SW_APM_TRIGGER_TRACING_MODE', nil) + 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.fetch('SW_APM_TRIGGER_TRACING_MODE', nil) + 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.fetch('SW_APM_TAG_SQL', nil) + 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.fetch('SW_APM_TAG_SQL', nil) + 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.fetch('SW_APM_TRIGGER_TRACING_MODE', nil) + 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.fetch('SW_APM_TRIGGER_TRACING_MODE', nil) + 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] + warned = false + SolarWindsAPM.logger.stub(:warn, ->(_msg = nil, &block) { warned = true if block&.call&.include?('[Deprecated] sampling_rate') }) do + SolarWindsAPM::Config[:sampling_rate] = 100 + end + assert warned, 'Expected a deprecation warning for sampling_rate' + ensure + SolarWindsAPM::Config.class_variable_get(:@@config)[:sampling_rate] = original + end + + it 'warns on deprecated sample_rate' do + original = SolarWindsAPM::Config[:sample_rate] + warned = false + SolarWindsAPM.logger.stub(:warn, ->(_msg = nil, &block) { warned = true if block&.call&.include?('[Deprecated] sample_rate') }) do + SolarWindsAPM::Config[:sample_rate] = 100 + end + assert warned, 'Expected a deprecation warning for sample_rate' + 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] + warned = false + SolarWindsAPM.logger.stub(:warn, ->(_msg = nil, &block) { warned = true if block&.call&.include?(':ec2_metadata_timeout is deprecated') }) do + SolarWindsAPM::Config[:ec2_metadata_timeout] = 1000 + end + assert warned, 'Expected a deprecation warning for ec2_metadata_timeout' + ensure + SolarWindsAPM::Config.class_variable_get(:@@config)[:ec2_metadata_timeout] = original + end + + it 'warns on deprecated http_proxy' do + original = SolarWindsAPM::Config[:http_proxy] + warned = false + SolarWindsAPM.logger.stub(:warn, ->(_msg = nil, &block) { warned = true if block&.call&.include?(':http_proxy is deprecated') }) do + SolarWindsAPM::Config[:http_proxy] = 'http://proxy' + end + assert warned, 'Expected a deprecation warning for 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] + warned = false + SolarWindsAPM.logger.stub(:warn, ->(_msg = nil, &block) { warned = true if block&.call&.include?(':hostname_alias is deprecated') }) do + SolarWindsAPM::Config[:hostname_alias] = 'alias' + end + assert warned, 'Expected a deprecation warning for hostname_alias' + ensure + SolarWindsAPM::Config.class_variable_get(:@@config)[:hostname_alias] = original + end + + it 'warns on deprecated log_args' do + original = SolarWindsAPM::Config[:log_args] + warned = false + SolarWindsAPM.logger.stub(:warn, ->(_msg = nil, &block) { warned = true if block&.call&.include?(':log_args is deprecated') }) do + SolarWindsAPM::Config[:log_args] = true + end + assert warned, 'Expected a deprecation warning for log_args' + ensure + SolarWindsAPM::Config.class_variable_get(:@@config)[:log_args] = original + end + + it 'handles tracing_mode assignment' do + 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] + 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 + assert_equal [Regexp.new('/health')], SolarWindsAPM::Config[:disabled_regexps] + end + + it 'handles transaction_settings with enabled regexp' do + settings = [{ regexp: '/api', tracing: :enabled }] + SolarWindsAPM::Config[:transaction_settings] = settings + assert_equal [Regexp.new('/api')], 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: %r{/health}, tracing: :disabled }] + SolarWindsAPM::Config[:transaction_settings] = settings + assert_equal [%r{/health}], 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, so disabled_regexps should be nil + assert_nil SolarWindsAPM::Config[:disabled_regexps] + end + + it 'handles transaction_settings with empty regexp string' do + settings = [{ regexp: '', tracing: :disabled }] + SolarWindsAPM::Config[:transaction_settings] = settings + # Empty regexp string is filtered out + assert_nil SolarWindsAPM::Config[:disabled_regexps] + end + + it 'handles transaction_settings with empty Regexp' do + settings = [{ regexp: Regexp.new(''), tracing: :disabled }] + SolarWindsAPM::Config[:transaction_settings] = settings + # Empty Regexp (inspects as //) is filtered out + assert_nil SolarWindsAPM::Config[:disabled_regexps] + end + + it 'handles transaction_settings without tracing key' do + settings = [{ regexp: '/test' }] + SolarWindsAPM::Config[:transaction_settings] = settings + # No tracing key defaults to disabled + assert_equal [Regexp.new('/test')], SolarWindsAPM::Config[:disabled_regexps] + end + + it 'handles generic key assignment' do + SolarWindsAPM::Config[:custom_key] = 'custom_value' + assert_equal 'custom_value', SolarWindsAPM::Config[:custom_key] + end + end + + describe 'config_file_from_env' do + it 'returns nil for non-existent file' do + 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 + ensure + if original + ENV['SW_APM_CONFIG_RUBY'] = original + else + ENV.delete('SW_APM_CONFIG_RUBY') + end + 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[: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..1da1eed3 --- /dev/null +++ b/test/solarwinds_apm/otel_config_test.rb @@ -0,0 +1,139 @@ +# 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 = nil + 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 config_map && original + config_map['OpenTelemetry::Instrumentation::Rack'] = original + 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'] + + 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 config_map && original + config_map['OpenTelemetry::Instrumentation::Rack'] = original + 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'] + + 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 config_map && original + config_map['OpenTelemetry::Instrumentation::Rack'] = original + 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'] + + 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 config_map && original + config_map['OpenTelemetry::Instrumentation::Rack'] = original + elsif config_map + 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 + nil + original_map = nil + 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) 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) + 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..07c7437e --- /dev/null +++ b/test/support/log_formatters_test.rb @@ -0,0 +1,59 @@ +# 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) + trace = SolarWindsAPM::API.current_trace_info + expected_trace_info = trace.for_log + assert_equal "test message #{expected_trace_info}", entry.message + 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) + assert_equal 'test message', entry.message + 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 + + Logging.logger['test_logger'] + event = Logging::LogEvent.new('test_logger', Logging::LEVELS['info'], 'test log message', false) + trace = SolarWindsAPM::API.current_trace_info + expected_trace_info = trace.for_log + assert_equal "test log message #{expected_trace_info}", event.data + 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) + assert_equal 'test log message', event.data + 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..066e13c4 --- /dev/null +++ b/test/support/logger_formatter_test.rb @@ -0,0 +1,83 @@ +# 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') + trace = SolarWindsAPM::API.current_trace_info + expected_trace_info = trace.for_log + assert_equal "hello world #{expected_trace_info}", output.split(' -- TestProg: ', 2).last.strip + 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)') + trace = SolarWindsAPM::API.current_trace_info + expected_trace_info = trace.for_log + assert_equal "test error (StandardError) #{expected_trace_info}", output.split(' -- TestProg: ', 2).last.strip + 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") + trace = SolarWindsAPM::API.current_trace_info + expected_trace_info = trace.for_log + assert_equal "hello #{expected_trace_info}\n\n", output.split(' -- TestProg: ', 2).last.chomp + 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..a3351d78 100644 --- a/test/support/otlp_endpoint_test.rb +++ b/test/support/otlp_endpoint_test.rb @@ -32,9 +32,9 @@ def assert_signal_endpoint_nil end def assert_signal_endpoint_default - _(ENV.fetch('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT', nil)).must_equal 'https://otel.collector.na-01.cloud.solarwinds.com:443/v1/traces' - _(ENV.fetch('OTEL_EXPORTER_OTLP_METRICS_ENDPOINT', nil)).must_equal 'https://otel.collector.na-01.cloud.solarwinds.com:443/v1/metrics' - _(ENV.fetch('OTEL_EXPORTER_OTLP_LOGS_ENDPOINT', nil)).must_equal 'https://otel.collector.na-01.cloud.solarwinds.com:443/v1/logs' + assert_equal 'https://otel.collector.na-01.cloud.solarwinds.com:443/v1/traces', ENV.fetch('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT', nil) + assert_equal 'https://otel.collector.na-01.cloud.solarwinds.com:443/v1/metrics', ENV.fetch('OTEL_EXPORTER_OTLP_METRICS_ENDPOINT', nil) + assert_equal 'https://otel.collector.na-01.cloud.solarwinds.com:443/v1/logs', ENV.fetch('OTEL_EXPORTER_OTLP_LOGS_ENDPOINT', nil) end def assert_singal_headers_nil(general_singal_header: true) @@ -52,7 +52,7 @@ def assert_singal_headers_nil(general_singal_header: true) assert_nil(@endpoint.instance_variable_get(:@token)) - _(ENV.fetch('OTEL_EXPORTER_OTLP_ENDPOINT', nil)).must_equal 'http://0.0.0.0:4317' + assert_equal 'http://0.0.0.0:4317', ENV.fetch('OTEL_EXPORTER_OTLP_ENDPOINT', nil) assert_nil(ENV.fetch('OTEL_EXPORTER_OTLP_HEADERS', nil)) assert_signal_endpoint_nil @@ -65,9 +65,9 @@ def assert_singal_headers_nil(general_singal_header: true) _setup - _(@endpoint.instance_variable_get(:@token)).must_equal '0123456789abcde0123456789abcde0123456789abcde0123456789abcde1234' - _(ENV.fetch('OTEL_EXPORTER_OTLP_ENDPOINT', nil)).must_equal 'http://0.0.0.0:4317' - _(ENV.fetch('OTEL_EXPORTER_OTLP_HEADERS', nil)).must_equal 'authorization=Bearer 0123456789abcde0123456789abcde0123456789abcde0123456789abcde1234' + assert_equal '0123456789abcde0123456789abcde0123456789abcde0123456789abcde1234', @endpoint.instance_variable_get(:@token) + assert_equal 'http://0.0.0.0:4317', ENV.fetch('OTEL_EXPORTER_OTLP_ENDPOINT', nil) + assert_equal 'authorization=Bearer 0123456789abcde0123456789abcde0123456789abcde0123456789abcde1234', ENV.fetch('OTEL_EXPORTER_OTLP_HEADERS', nil) assert_signal_endpoint_nil assert_singal_headers_nil(general_singal_header: false) @@ -80,11 +80,11 @@ def assert_singal_headers_nil(general_singal_header: true) _setup - _(@endpoint.instance_variable_get(:@token)).must_equal '0123456789abcde0123456789abcde0123456789abcde0123456789abcde1234' - _(ENV.fetch('OTEL_EXPORTER_OTLP_METRICS_HEADERS', nil)).must_equal 'authorization=Bearer bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' + assert_equal '0123456789abcde0123456789abcde0123456789abcde0123456789abcde1234', @endpoint.instance_variable_get(:@token) + assert_equal 'authorization=Bearer bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', ENV.fetch('OTEL_EXPORTER_OTLP_METRICS_HEADERS', nil) assert_nil(ENV.fetch('OTEL_EXPORTER_OTLP_TRACES_HEADERS', nil)) assert_nil(ENV.fetch('OTEL_EXPORTER_OTLP_LOGS_HEADERS', nil)) - _(ENV.fetch('OTEL_EXPORTER_OTLP_HEADERS', nil)).must_equal 'authorization=Bearer aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + assert_equal 'authorization=Bearer aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', ENV.fetch('OTEL_EXPORTER_OTLP_HEADERS', nil) assert_signal_endpoint_default end @@ -98,7 +98,7 @@ def assert_singal_headers_nil(general_singal_header: true) assert_nil(@endpoint.instance_variable_get(:@token)) assert_nil(ENV.fetch('OTEL_EXPORTER_OTLP_HEADERS', nil)) - _(ENV.fetch('OTEL_EXPORTER_OTLP_ENDPOINT', nil)).must_equal 'https://otel.collector.na-01.cloud.solarwinds.com:443' + assert_equal 'https://otel.collector.na-01.cloud.solarwinds.com:443', ENV.fetch('OTEL_EXPORTER_OTLP_ENDPOINT', nil) assert_signal_endpoint_nil assert_singal_headers_nil @@ -110,10 +110,10 @@ def assert_singal_headers_nil(general_singal_header: true) _setup - _(@endpoint.instance_variable_get(:@token)).must_equal '0123456789abcde0123456789abcde0123456789abcde0123456789abcde1234' + assert_equal '0123456789abcde0123456789abcde0123456789abcde0123456789abcde1234', @endpoint.instance_variable_get(:@token) - _(ENV.fetch('OTEL_EXPORTER_OTLP_HEADERS', nil)).must_equal 'authorization=Bearer 0123456789abcde0123456789abcde0123456789abcde0123456789abcde1234' - _(ENV.fetch('OTEL_EXPORTER_OTLP_ENDPOINT', nil)).must_equal 'https://otel.collector.na-01.cloud.solarwinds.com:443' + assert_equal 'authorization=Bearer 0123456789abcde0123456789abcde0123456789abcde0123456789abcde1234', ENV.fetch('OTEL_EXPORTER_OTLP_HEADERS', nil) + assert_equal 'https://otel.collector.na-01.cloud.solarwinds.com:443', ENV.fetch('OTEL_EXPORTER_OTLP_ENDPOINT', nil) assert_signal_endpoint_nil assert_singal_headers_nil(general_singal_header: false) @@ -138,7 +138,7 @@ def assert_singal_headers_nil(general_singal_header: true) _setup - _(@endpoint.instance_variable_get(:@token)).must_equal '0123456789abcde0123456789abcde0123456789abcde0123456789abcde1234' + assert_equal '0123456789abcde0123456789abcde0123456789abcde0123456789abcde1234', @endpoint.instance_variable_get(:@token) assert_nil(ENV.fetch('OTEL_EXPORTER_OTLP_ENDPOINT', nil)) assert_signal_endpoint_default @@ -150,10 +150,10 @@ def assert_singal_headers_nil(general_singal_header: true) assert_nil(ENV.fetch('OTEL_EXPORTER_OTLP_ENDPOINT', nil)) - _(ENV.fetch('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT', nil)).must_equal 'https://otel.collector.na-01.cloud.solarwinds.com:443/v1/traces' - _(ENV.fetch('OTEL_EXPORTER_OTLP_METRICS_ENDPOINT', nil)).must_equal 'https://otel.collector.na-01.cloud.solarwinds.com:443/v1/metrics' - _(ENV.fetch('OTEL_EXPORTER_OTLP_LOGS_ENDPOINT', nil)).must_equal 'https://otel.collector.na-01.cloud.solarwinds.com:443/v1/logs' - _(ENV.fetch('SW_APM_COLLECTOR', nil)).must_equal 'apm.collector.na-01.cloud.solarwinds.com:443' + assert_equal 'https://otel.collector.na-01.cloud.solarwinds.com:443/v1/traces', ENV.fetch('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT', nil) + assert_equal 'https://otel.collector.na-01.cloud.solarwinds.com:443/v1/metrics', ENV.fetch('OTEL_EXPORTER_OTLP_METRICS_ENDPOINT', nil) + assert_equal 'https://otel.collector.na-01.cloud.solarwinds.com:443/v1/logs', ENV.fetch('OTEL_EXPORTER_OTLP_LOGS_ENDPOINT', nil) + assert_equal 'apm.collector.na-01.cloud.solarwinds.com:443', ENV.fetch('SW_APM_COLLECTOR', nil) end it 'OTEL ENDPOINT to local and no SW_APM_COLLECTOR' do @@ -161,8 +161,8 @@ def assert_singal_headers_nil(general_singal_header: true) _setup - _(ENV.fetch('OTEL_EXPORTER_OTLP_ENDPOINT', nil)).must_equal 'http://localhost:4317' - _(ENV.fetch('SW_APM_COLLECTOR', nil)).must_equal 'apm.collector.na-01.cloud.solarwinds.com:443' + assert_equal 'http://localhost:4317', ENV.fetch('OTEL_EXPORTER_OTLP_ENDPOINT', nil) + assert_equal 'apm.collector.na-01.cloud.solarwinds.com:443', ENV.fetch('SW_APM_COLLECTOR', nil) assert_signal_endpoint_nil end @@ -174,10 +174,10 @@ def assert_singal_headers_nil(general_singal_header: true) assert_nil(ENV.fetch('OTEL_EXPORTER_OTLP_ENDPOINT', nil)) - _(ENV.fetch('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT', nil)).must_equal 'https://otel.collector.na-02.cloud.solarwinds.com:443/v1/traces' - _(ENV.fetch('OTEL_EXPORTER_OTLP_METRICS_ENDPOINT', nil)).must_equal 'https://otel.collector.na-02.cloud.solarwinds.com:443/v1/metrics' - _(ENV.fetch('OTEL_EXPORTER_OTLP_LOGS_ENDPOINT', nil)).must_equal 'https://otel.collector.na-02.cloud.solarwinds.com:443/v1/logs' - _(ENV.fetch('SW_APM_COLLECTOR', nil)).must_equal 'apm.collector.na-02.cloud.solarwinds.com:443' + assert_equal 'https://otel.collector.na-02.cloud.solarwinds.com:443/v1/traces', ENV.fetch('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT', nil) + assert_equal 'https://otel.collector.na-02.cloud.solarwinds.com:443/v1/metrics', ENV.fetch('OTEL_EXPORTER_OTLP_METRICS_ENDPOINT', nil) + assert_equal 'https://otel.collector.na-02.cloud.solarwinds.com:443/v1/logs', ENV.fetch('OTEL_EXPORTER_OTLP_LOGS_ENDPOINT', nil) + assert_equal 'apm.collector.na-02.cloud.solarwinds.com:443', ENV.fetch('SW_APM_COLLECTOR', nil) end it 'OTEL ENDPOINT to local and with SW_APM_COLLECTOR' do @@ -186,8 +186,8 @@ def assert_singal_headers_nil(general_singal_header: true) _setup - _(ENV.fetch('OTEL_EXPORTER_OTLP_ENDPOINT', nil)).must_equal 'http://localhost:4317' - _(ENV.fetch('SW_APM_COLLECTOR', nil)).must_equal 'apm.collector.na-01.cloud.solarwinds.com:443' + assert_equal 'http://localhost:4317', ENV.fetch('OTEL_EXPORTER_OTLP_ENDPOINT', nil) + assert_equal 'apm.collector.na-01.cloud.solarwinds.com:443', ENV.fetch('SW_APM_COLLECTOR', nil) assert_signal_endpoint_nil end @@ -198,10 +198,10 @@ def assert_singal_headers_nil(general_singal_header: true) _setup - _(ENV.fetch('OTEL_EXPORTER_OTLP_METRICS_ENDPOINT', nil)).must_equal 'http://special.host:4317/v1/metrics' - _(ENV.fetch('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT', nil)).must_equal 'https://otel.collector.na-01.cloud.solarwinds.com:443/v1/traces' - _(ENV.fetch('OTEL_EXPORTER_OTLP_LOGS_ENDPOINT', nil)).must_equal 'https://otel.collector.na-01.cloud.solarwinds.com:443/v1/logs' - _(ENV.fetch('SW_APM_COLLECTOR', nil)).must_equal 'apm.collector.na-01.cloud.solarwinds.com:443' + assert_equal 'http://special.host:4317/v1/metrics', ENV.fetch('OTEL_EXPORTER_OTLP_METRICS_ENDPOINT', nil) + assert_equal 'https://otel.collector.na-01.cloud.solarwinds.com:443/v1/traces', ENV.fetch('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT', nil) + assert_equal 'https://otel.collector.na-01.cloud.solarwinds.com:443/v1/logs', ENV.fetch('OTEL_EXPORTER_OTLP_LOGS_ENDPOINT', nil) + assert_equal 'apm.collector.na-01.cloud.solarwinds.com:443', ENV.fetch('SW_APM_COLLECTOR', nil) end it 'OTEL METRICS ENDPOINT to special and SW_APM_COLLECTOR to special location' do @@ -210,10 +210,10 @@ def assert_singal_headers_nil(general_singal_header: true) _setup - _(ENV.fetch('OTEL_EXPORTER_OTLP_METRICS_ENDPOINT', nil)).must_equal 'http://special.host:4317/v1/metrics' - _(ENV.fetch('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT', nil)).must_equal 'https://otel.collector.eu-01.cloud.solarwinds.com:443/v1/traces' - _(ENV.fetch('OTEL_EXPORTER_OTLP_LOGS_ENDPOINT', nil)).must_equal 'https://otel.collector.eu-01.cloud.solarwinds.com:443/v1/logs' - _(ENV.fetch('SW_APM_COLLECTOR', nil)).must_equal 'apm.collector.eu-01.cloud.solarwinds.com:443' + assert_equal 'http://special.host:4317/v1/metrics', ENV.fetch('OTEL_EXPORTER_OTLP_METRICS_ENDPOINT', nil) + assert_equal 'https://otel.collector.eu-01.cloud.solarwinds.com:443/v1/traces', ENV.fetch('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT', nil) + assert_equal 'https://otel.collector.eu-01.cloud.solarwinds.com:443/v1/logs', ENV.fetch('OTEL_EXPORTER_OTLP_LOGS_ENDPOINT', nil) + assert_equal 'apm.collector.eu-01.cloud.solarwinds.com:443', ENV.fetch('SW_APM_COLLECTOR', nil) end it 'OTLP endpoint without port' do @@ -223,12 +223,12 @@ def assert_singal_headers_nil(general_singal_header: true) _setup - _(ENV.fetch('OTEL_EXPORTER_OTLP_METRICS_ENDPOINT', nil)).must_equal 'https://otel.collector.na-01.cloud.solarwinds.com/v1/metrics' - _(ENV.fetch('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT', nil)).must_equal 'https://otel.collector.eu-01.cloud.solarwinds.com:443/v1/traces' - _(ENV.fetch('OTEL_EXPORTER_OTLP_LOGS_ENDPOINT', nil)).must_equal 'https://otel.collector.eu-01.cloud.solarwinds.com:443/v1/logs' - _(ENV.fetch('SW_APM_COLLECTOR', nil)).must_equal 'apm.collector.eu-01.cloud.solarwinds.com' + assert_equal 'https://otel.collector.na-01.cloud.solarwinds.com/v1/metrics', ENV.fetch('OTEL_EXPORTER_OTLP_METRICS_ENDPOINT', nil) + assert_equal 'https://otel.collector.eu-01.cloud.solarwinds.com:443/v1/traces', ENV.fetch('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT', nil) + assert_equal 'https://otel.collector.eu-01.cloud.solarwinds.com:443/v1/logs', ENV.fetch('OTEL_EXPORTER_OTLP_LOGS_ENDPOINT', nil) + assert_equal 'apm.collector.eu-01.cloud.solarwinds.com', ENV.fetch('SW_APM_COLLECTOR', nil) - _(ENV.fetch('OTEL_EXPORTER_OTLP_METRICS_HEADERS', nil)).must_equal 'authorization=Bearer 0123456789abcde0123456789abcde0123456789abcde0123456789abcde1234' + assert_equal 'authorization=Bearer 0123456789abcde0123456789abcde0123456789abcde0123456789abcde1234', ENV.fetch('OTEL_EXPORTER_OTLP_METRICS_HEADERS', nil) end it 'swo endpoint without port' do @@ -236,10 +236,10 @@ def assert_singal_headers_nil(general_singal_header: true) _setup - _(ENV.fetch('OTEL_EXPORTER_OTLP_METRICS_ENDPOINT', nil)).must_equal 'https://otel.collector.eu-01.cloud.solarwinds.com:443/v1/metrics' - _(ENV.fetch('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT', nil)).must_equal 'https://otel.collector.eu-01.cloud.solarwinds.com:443/v1/traces' - _(ENV.fetch('OTEL_EXPORTER_OTLP_LOGS_ENDPOINT', nil)).must_equal 'https://otel.collector.eu-01.cloud.solarwinds.com:443/v1/logs' - _(ENV.fetch('SW_APM_COLLECTOR', nil)).must_equal 'apm.collector.eu-01.cloud.solarwinds.com' + assert_equal 'https://otel.collector.eu-01.cloud.solarwinds.com:443/v1/metrics', ENV.fetch('OTEL_EXPORTER_OTLP_METRICS_ENDPOINT', nil) + assert_equal 'https://otel.collector.eu-01.cloud.solarwinds.com:443/v1/traces', ENV.fetch('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT', nil) + assert_equal 'https://otel.collector.eu-01.cloud.solarwinds.com:443/v1/logs', ENV.fetch('OTEL_EXPORTER_OTLP_LOGS_ENDPOINT', nil) + assert_equal 'apm.collector.eu-01.cloud.solarwinds.com', ENV.fetch('SW_APM_COLLECTOR', nil) end it 'swo endpoint without port but in wrong format fallback to default' do @@ -247,7 +247,135 @@ def assert_singal_headers_nil(general_singal_header: true) _setup - _(ENV.fetch('SW_APM_COLLECTOR', nil)).must_equal 'apm.collector.na-01.cloud.solarwinds.com:443' + assert_equal 'apm.collector.na-01.cloud.solarwinds.com:443', ENV.fetch('SW_APM_COLLECTOR', nil) assert_signal_endpoint_default 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.fetch('SW_APM_COLLECTOR', nil) + 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.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.fetch('SW_APM_COLLECTOR', nil) + 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.fetch('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT', nil) + 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.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.fetch('OTEL_EXPORTER_OTLP_LOGS_ENDPOINT', nil) + 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.fetch('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT', nil) + 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_equal 'authorization=Bearer my-token', ENV.fetch('OTEL_EXPORTER_OTLP_HEADERS', nil) + 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_equal 'authorization=Bearer my-token', ENV.fetch('OTEL_EXPORTER_OTLP_TRACES_HEADERS', nil) + 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.fetch('OTEL_EXPORTER_OTLP_HEADERS', nil) + 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.fetch('OTEL_EXPORTER_OTLP_TRACES_HEADERS', nil) + 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_equal 'authorization=Bearer my-token', ENV.fetch('OTEL_EXPORTER_OTLP_HEADERS', nil) + end + end end diff --git a/test/support/resource_detector_test.rb b/test/support/resource_detector_test.rb index 2e2a9c37..0974260e 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) @@ -87,4 +93,170 @@ WebMock.reset! WebMock.allow_net_connect! end + + 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_match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, attrs['service.instance.id']) + ensure + WebMock.disable! + end + end + + 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') + + 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 + 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 + + describe 'detect_k8s_attributes' do + 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 + 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 + + error_logged = false + SolarWindsAPM.logger.stub(:error, ->(_msg = nil, &block) { error_logged = true if block&.call&.include?('failed. Error: detector failed') }) do + result = SolarWindsAPM::ResourceDetector.run_opentelemetry_detector(mock_detector) + attrs = result.instance_variable_get(:@attributes) + assert_empty attrs + end + assert error_logged, 'Expected error to be logged when detector fails' + end + end end diff --git a/test/support/service_key_checker_test.rb b/test/support/service_key_checker_test.rb index 604971a4..259b1e0f 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 @@ -224,4 +225,16 @@ def service_key_ok?(service_key) assert_nil(ENV.fetch('OTEL_SERVICE_NAME', nil)) 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 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..490af76d 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] = [%r{/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] = [%r{/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] = [%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 + 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..7d4240de 100644 --- a/test/support/utils_test.rb +++ b/test/support/utils_test.rb @@ -6,22 +6,91 @@ 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:{", - span_id: "\x8D\xB5\xDC?$l\x84W") +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") + result = SolarWindsAPM::Utils.traceparent_from_context(span_context) + assert_equal '00-dd95c56ce383caf0953b539869f93a7b-8db5dc3f246c8457-00', result + end + + 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) + assert_equal 'sw=0000000000000000-01', result + end - @tracestate = OpenTelemetry::Trace::Tracestate.from_hash({ 'sw' => '0000000000000000-01' }) - @utils = SolarWindsAPM::Utils + 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_equal 'sw=1234-01,other=value', result + end end - it 'test trace_state_header' do - result = @utils.trace_state_header(@tracestate) - _(result).must_equal 'sw=0000000000000000-01' + 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_equal '00-dd95c56ce383caf0953b539869f93a7b-8db5dc3f246c8457-01', result + 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_equal '00-dd95c56ce383caf0953b539869f93a7b-8db5dc3f246c8457-00', result + end end - it 'test traceparent_from_context' do - result = @utils.traceparent_from_context(@span_context) - _(result).must_equal '00-dd95c56ce383caf0953b539869f93a7b-8db5dc3f246c8457-00' + describe 'determine_lambda' do + it 'returns false when not in lambda' do + 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') + + 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.fetch('LAMBDA_TASK_ROOT', nil) + 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.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' + + 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