From c9134530bc84563b2d0f3f3ffe4fcf030eb61fea Mon Sep 17 00:00:00 2001 From: Arjun Rajappa Date: Tue, 9 Sep 2025 15:49:41 +0530 Subject: [PATCH 01/18] feat: add span filtering for redis Signed-off-by: Arjun Rajappa --- lib/instana/span_filtering.rb | 58 +++++++++ lib/instana/span_filtering/condition.rb | 135 ++++++++++++++++++++ lib/instana/span_filtering/configuration.rb | 132 +++++++++++++++++++ 3 files changed, 325 insertions(+) create mode 100644 lib/instana/span_filtering.rb create mode 100644 lib/instana/span_filtering/condition.rb create mode 100644 lib/instana/span_filtering/configuration.rb diff --git a/lib/instana/span_filtering.rb b/lib/instana/span_filtering.rb new file mode 100644 index 00000000..c87f158e --- /dev/null +++ b/lib/instana/span_filtering.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +# (c) Copyright IBM Corp. 2025 + +require 'instana/span_filtering/configuration' +require 'instana/span_filtering/filter_rule' +require 'instana/span_filtering/condition' + +module Instana + # SpanFiltering module provides functionality to filter spans based on configured rules + module SpanFiltering + class << self + attr_reader :configuration + + # Initialize the span filtering configuration + # @return [Configuration] The span filtering configuration + def initialize + @configuration = Configuration.new + end + + # Check if span filtering is deactivated + # @return [Boolean] True if span filtering is deactivated + def deactivated? + @configuration&.deactivated || false + end + + # Check if a span should be filtered out + # @param span [Hash] The span to check + # @return [Hash, nil] A result hash with filtered and suppression keys if filtered, nil if not filtered + def filter_span(span) + return nil if deactivated? + return nil unless @configuration + + # Check include rules first (whitelist) + if @configuration.include_rules.any? { |rule| rule.matches?(span) } + return nil # Keep the span if it matches any include rule + end + + # Check exclude rules (blacklist) + @configuration.exclude_rules.each do |rule| + if rule.matches?(span) + return { filtered: true, suppression: rule.suppression } + end + end + + nil # Keep the span if no rules match + end + + # Reset the configuration (mainly for testing) + def reset + @configuration = nil + end + end + + # Initialize on module load + initialize + end +end diff --git a/lib/instana/span_filtering/condition.rb b/lib/instana/span_filtering/condition.rb new file mode 100644 index 00000000..b612e82b --- /dev/null +++ b/lib/instana/span_filtering/condition.rb @@ -0,0 +1,135 @@ +# (c) Copyright IBM Corp. 2025 + +module Instana + module SpanFiltering + # Represents a condition for filtering spans + # + # A condition consists of: + # - key: The attribute to match against (category, kind, type, or span attribute) + # - values: List of values to match against (OR logic between values) + # - match_type: String matching strategy (strict, startswith, endswith, contains) + class Condition + attr_reader :key, :values, :match_type + + def initialize(key, values, match_type = 'strict') + @key = key + @values = Array(values) + @match_type = match_type + end + + # Check if a span matches this condition + # @param span [Hash] The span to check + # @return [Boolean] True if the span matches any of the values + def matches?(span) + attribute_value = extract_attribute(span, @key) + return false if attribute_value.nil? + + @values.any? { |value| matches_value?(attribute_value, value) } + end + + private + + # Extract an attribute from a span + # @param span [Hash] The span to extract from + # @param key [String] The key to extract + # @return [Object, nil] The attribute value or nil if not found + def extract_attribute(span, key) + case key + when 'category' + # Map to appropriate span attribute for category + determine_category(span) + when 'kind' + # Map to appropriate span attribute for kind + span[:k] || span['k'] + when 'type' + # Map to appropriate span attribute for type + span[:n] || span['n'] + else + # Handle nested attributes with dot notation + extract_nested_attribute(span[:data] || span['data'], key) + end + end + + # Determine the category of a span based on its properties + # @param span [Hash] The span to categorize + # @return [String, nil] The category or nil if not determinable + def determine_category(span) + data = span[:data] || span['data'] + return nil unless data + if data[:http] || data['http'] + 'protocols' + elsif data[:redis] || data[:mysql] || data[:pg] || data[:db] + 'databases' + elsif data[:sqs] || data[:sns] || data[:mq] + 'messaging' + elsif (span[:n] || span['n'])&.start_with?('log.') + 'logging' + else + nil + end + end + + # Extract a nested attribute using dot notation + # @param data [Hash] The data hash to extract from + # @param key [String] The key in dot notation + # @return [Object, nil] The attribute value or nil if not found + def extract_nested_attribute(data, key) + return nil unless data + + parts = key.split('.') + current = data + + parts.each do |part| + # Try symbol key first, then string key + if current.key?(part.to_sym) + current = current[part.to_sym] + elsif current.key?(part) + current = current[part] + else + return nil # Key not found + end + + # Only return nil if the value is actually nil, not for false values + end + + current + end + + # Check if an attribute value matches a condition value + # @param attribute_value [Object] The attribute value + # @param condition_value [String] The condition value + # @return [Boolean] True if the attribute value matches the condition value + def matches_value?(attribute_value, condition_value) + # Handle wildcard + return true if condition_value == '*' + + # Direct comparison first - this should handle boolean values correctly + return true if attribute_value == condition_value + + # For strict matching with type conversion + if @match_type == 'strict' + # Convert to strings and compare + attribute_str = attribute_value.to_s + condition_str = condition_value.to_s + return attribute_str == condition_str + end + + # For other match types, convert both to strings + attribute_str = attribute_value.to_s + condition_str = condition_value.to_s + + case @match_type + when 'startswith' + attribute_str.start_with?(condition_str) + when 'endswith' + attribute_str.end_with?(condition_str) + when 'contains' + attribute_str.include?(condition_str) + else + # Default to strict matching + attribute_str == condition_str + end + end + end + end +end diff --git a/lib/instana/span_filtering/configuration.rb b/lib/instana/span_filtering/configuration.rb new file mode 100644 index 00000000..a78d2105 --- /dev/null +++ b/lib/instana/span_filtering/configuration.rb @@ -0,0 +1,132 @@ +# (c) Copyright IBM Corp. 2025 + +require 'yaml' + +module Instana + module SpanFiltering + # Configuration class for span filtering + # + # This class handles loading and managing span filtering rules from various sources: + # - YAML configuration file (via INSTANA_CONFIG_PATH) + # - Environment variables + # + # It supports both include and exclude rules with various matching strategies + class Configuration + attr_reader :include_rules, :exclude_rules, :deactivated + + def initialize + @include_rules = [] + @exclude_rules = [] + @deactivated = false + load_configuration + end + + # Load configuration from all available sources + def load_configuration + load_from_yaml + load_from_env_vars + end + + private + + # Load configuration from YAML file specified by INSTANA_CONFIG_PATH + def load_from_yaml + config_path = ENV['INSTANA_CONFIG_PATH'] + return unless config_path && File.exist?(config_path) + + begin + yaml_content = YAML.safe_load(File.read(config_path)) + + # Support both "tracing" and "com.instana.tracing" as top-level keys + tracing_config = yaml_content['tracing'] || yaml_content['com.instana.tracing'] + return unless tracing_config && tracing_config['filter'] + + filter_config = tracing_config['filter'] + @deactivated = filter_config['deactivate'] == true + + # Process include rules + process_rules(filter_config['include'], true) if filter_config['include'] + + # Process exclude rules + process_rules(filter_config['exclude'], false) if filter_config['exclude'] + rescue => e + Instana.logger.warn("Failed to load span filtering configuration from YAML: #{e.message}") + end + end + + # Load configuration from environment variables + def load_from_env_vars + ENV.each do |key, value| + next unless key.start_with?('INSTANA_TRACING_FILTER_') + + parts = key.split('_') + next unless parts.size >= 5 + + policy = parts[3].downcase + next unless ['include', 'exclude'].include?(policy) + + if parts[4] == 'ATTRIBUTES' + process_env_attributes(policy, parts[4..-1].join('_'), value) + elsif policy == 'exclude' && parts[4] == 'SUPPRESSION' + process_env_suppression(parts[3..-1].join('_'), value) + end + end + end + + # Process rules from YAML configuration + def process_rules(rules_config, is_include) + rules_config.each do |rule_config| + name = rule_config['name'] + suppression = is_include ? false : (rule_config['suppression'] != false) # Default true for exclude + + conditions = [] + rule_config['attributes'].each do |attr_config| + key = attr_config['key'] + values = attr_config['values'] + match_type = attr_config['match_type'] || 'strict' + + conditions << Condition.new(key, values, match_type) + end + + rule = FilterRule.new(name, suppression, conditions) + is_include ? @include_rules << rule : @exclude_rules << rule + end + end + + # Process attributes from environment variables + def process_env_attributes(policy, name, value) + # Parse rules from environment variable format + # Format: key;values;match_type|key;values;match_type + rules = value.split('|') + conditions = [] + + rules.each do |rule| + parts = rule.split(';') + next unless parts.size >= 2 + + key = parts[0] + values = parts[1].split(',') + match_type = parts[2] || 'strict' + + conditions << Condition.new(key, values, match_type) + end + + rule_name = "EnvRule_#{name}" + suppression = policy == 'exclude' # Default true for exclude + + rule = FilterRule.new(rule_name, suppression, conditions) + policy == 'include' ? @include_rules << rule : @exclude_rules << rule + end + + # Process suppression setting from environment variables + def process_env_suppression(policy_name, value) + # Find the corresponding rule and update its suppression value + rule_index = policy_name.split('_')[1].to_i + return if rule_index >= @exclude_rules.size + + suppression = ['1', 'true', 'True'].include?(value) + @exclude_rules[rule_index].suppression = suppression + end + end + end +end From 7db7f347fcf7bd3adcdda43944b64e920d986dfa Mon Sep 17 00:00:00 2001 From: Arjun Rajappa Date: Tue, 9 Sep 2025 17:11:36 +0530 Subject: [PATCH 02/18] feat: add span filtering rules Signed-off-by: Arjun Rajappa --- lib/instana/span_filtering/filter_rule.rb | 31 +++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 lib/instana/span_filtering/filter_rule.rb diff --git a/lib/instana/span_filtering/filter_rule.rb b/lib/instana/span_filtering/filter_rule.rb new file mode 100644 index 00000000..e5945f7d --- /dev/null +++ b/lib/instana/span_filtering/filter_rule.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +# (c) Copyright IBM Corp. 2025 + +module Instana + module SpanFiltering + # Represents a filtering rule for spans + # + # A rule consists of: + # - name: A human-readable identifier for the rule + # - suppression: Whether child spans should be suppressed (only for exclude rules) + # - conditions: A list of conditions that must all be satisfied (AND logic) + class FilterRule + attr_reader :name + attr_accessor :suppression, :conditions + + def initialize(name, suppression, conditions) + @name = name + @suppression = suppression + @conditions = conditions + end + + # Check if a span matches this rule + # @param span [Hash] The span to check + # @return [Boolean] True if the span matches all conditions + def matches?(span) + @conditions.all? { |condition| condition.matches?(span) } + end + end + end +end From 202826fee3718523350fe7d4388a4df865843fe1 Mon Sep 17 00:00:00 2001 From: Arjun Rajappa Date: Wed, 10 Sep 2025 12:49:39 +0530 Subject: [PATCH 03/18] feat: add test for filtering Signed-off-by: Arjun Rajappa --- test/span_filtering/condition_test.rb | 256 +++++++++++++++++++++ test/span_filtering/configuration_test.rb | 184 +++++++++++++++ test/span_filtering/filter_rule_test.rb | 120 ++++++++++ test/span_filtering/span_filtering_test.rb | 242 +++++++++++++++++++ 4 files changed, 802 insertions(+) create mode 100644 test/span_filtering/condition_test.rb create mode 100644 test/span_filtering/configuration_test.rb create mode 100644 test/span_filtering/filter_rule_test.rb create mode 100644 test/span_filtering/span_filtering_test.rb diff --git a/test/span_filtering/condition_test.rb b/test/span_filtering/condition_test.rb new file mode 100644 index 00000000..b5f77bf3 --- /dev/null +++ b/test/span_filtering/condition_test.rb @@ -0,0 +1,256 @@ +# (c) Copyright IBM Corp. 2025 + +require 'test_helper' + +class ConditionTest < Minitest::Test + def setup + @http_span = { + n: 'http.client', + k: 1, + data: { + http: { + url: 'https://example.com/api', + method: 'GET', + status: 200 + } + } + } + + @redis_span = { + n: 'redis', + k: 3, + data: { + redis: { + command: 'GET', + key: 'user:123' + } + } + } + end + + def test_initialization + condition = Instana::SpanFiltering::Condition.new('type', ['http.client'], 'strict') + + assert_equal 'type', condition.key + assert_equal ['http.client'], condition.values + assert_equal 'strict', condition.match_type + end + + def test_matches_with_category + condition = Instana::SpanFiltering::Condition.new('category', ['protocols'], 'strict') + + assert condition.matches?(@http_span) + refute condition.matches?(@redis_span) + end + + def test_matches_with_kind + condition = Instana::SpanFiltering::Condition.new('kind', [1], 'strict') + + assert condition.matches?(@http_span) + refute condition.matches?(@redis_span) + end + + def test_matches_with_type + condition = Instana::SpanFiltering::Condition.new('type', ['http.client'], 'strict') + + assert condition.matches?(@http_span) + refute condition.matches?(@redis_span) + end + + def test_matches_with_nested_attribute + condition = Instana::SpanFiltering::Condition.new('http.method', ['GET'], 'strict') + + assert condition.matches?(@http_span) + refute condition.matches?(@redis_span) + end + + def test_matches_with_wildcard_value + condition = Instana::SpanFiltering::Condition.new('type', ['*'], 'strict') + + assert condition.matches?(@http_span) + assert condition.matches?(@redis_span) + end + + def test_match_type_strict + condition = Instana::SpanFiltering::Condition.new('http.url', ['https://example.com/api'], 'strict') + + assert condition.matches?(@http_span) + + condition = Instana::SpanFiltering::Condition.new('http.url', ['https://example.com'], 'strict') + refute condition.matches?(@http_span) + end + + def test_match_type_startswith + condition = Instana::SpanFiltering::Condition.new('http.url', ['https://example'], 'startswith') + + assert condition.matches?(@http_span) + + condition = Instana::SpanFiltering::Condition.new('http.url', ['http://example'], 'startswith') + refute condition.matches?(@http_span) + end + + def test_match_type_endswith + condition = Instana::SpanFiltering::Condition.new('http.url', ['.com/api'], 'endswith') + + assert condition.matches?(@http_span) + + condition = Instana::SpanFiltering::Condition.new('http.url', ['.org/api'], 'endswith') + refute condition.matches?(@http_span) + end + + def test_match_type_contains + condition = Instana::SpanFiltering::Condition.new('http.url', ['example.com'], 'contains') + + assert condition.matches?(@http_span) + + condition = Instana::SpanFiltering::Condition.new('http.url', ['example.org'], 'contains') + refute condition.matches?(@http_span) + end + + def test_multiple_values + condition = Instana::SpanFiltering::Condition.new('type', ['http.client', 'redis'], 'strict') + + assert condition.matches?(@http_span) + assert condition.matches?(@redis_span) + end + + def test_non_existent_attribute + condition = Instana::SpanFiltering::Condition.new('nonexistent', ['value'], 'strict') + + refute condition.matches?(@http_span) + end + + def test_database_category_detection + db_span = { + n: 'mysql', + k: 3, + data: { + mysql: { + query: 'SELECT * FROM users' + } + } + } + + condition = Instana::SpanFiltering::Condition.new('category', ['databases'], 'strict') + assert condition.matches?(db_span) + end + + def test_messaging_category_detection + mq_span = { + n: 'sqs', + k: 3, + data: { + sqs: { + queue: 'my-queue' + } + } + } + + condition = Instana::SpanFiltering::Condition.new('category', ['messaging'], 'strict') + assert condition.matches?(mq_span) + end + + def test_match_type_default_fallback + # Test that an invalid match_type falls back to 'strict' + condition = Instana::SpanFiltering::Condition.new('http.url', ['https://example.com/api'], 'invalid_match_type') + + assert condition.matches?(@http_span) + + condition = Instana::SpanFiltering::Condition.new('http.url', ['different_url'], 'invalid_match_type') + refute condition.matches?(@http_span) + end + + def test_numeric_values + # Test matching against numeric values + status_span = { + n: 'http.client', + k: 1, + data: { + http: { + status: 404, + response_time: 123.45, + success_rate: 0.99 + } + } + } + + # Test strict matching with integer value + condition = Instana::SpanFiltering::Condition.new('http.status', [404], 'strict') + assert condition.matches?(status_span) + + # Test strict matching with integer as string value + condition = Instana::SpanFiltering::Condition.new('http.status', ['404'], 'strict') + assert condition.matches?(status_span) + + # Test strict matching with wrong integer value + condition = Instana::SpanFiltering::Condition.new('http.status', [200], 'strict') + refute condition.matches?(status_span) + + # Test contains matching with numeric value (converted to string) + condition = Instana::SpanFiltering::Condition.new('http.status', ['40'], 'contains') + assert condition.matches?(status_span) + + # Test strict matching with float value + condition = Instana::SpanFiltering::Condition.new('http.response_time', [123.45], 'strict') + assert condition.matches?(status_span) + + # Test strict matching with float as string value + condition = Instana::SpanFiltering::Condition.new('http.response_time', ['123.45'], 'strict') + assert condition.matches?(status_span) + end + + def test_boolean_values + # Test matching against boolean values + boolean_span = { + n: 'custom', + k: 1, + data: { + custom: { + success: true, + cached: false + } + } + } + + # Test strict matching with boolean value + condition = Instana::SpanFiltering::Condition.new('custom.success', [true], 'strict') + assert condition.matches?(boolean_span) + + # Test strict matching with boolean as string value + condition = Instana::SpanFiltering::Condition.new('custom.success', ['true'], 'strict') + assert condition.matches?(boolean_span) + + # Test strict matching with wrong boolean value + condition = Instana::SpanFiltering::Condition.new('custom.success', [false], 'strict') + refute condition.matches?(boolean_span) + + # Test strict matching with false boolean value + condition = Instana::SpanFiltering::Condition.new('custom.cached', [false], 'strict') + assert condition.matches?(boolean_span) + + # Test strict matching with false as string value + condition = Instana::SpanFiltering::Condition.new('custom.cached', ['false'], 'strict') + assert condition.matches?(boolean_span) + end + + def test_nested_symbol_keys_multiple_levels + # Test with deeply nested symbol keys + deep_span = { + n: 'api', + k: 1, + data: { + api: { + request: { + headers: { + content_type: 'application/json' + } + } + } + } + } + + # Test accessing deeply nested attributes + condition = Instana::SpanFiltering::Condition.new('api.request.headers.content_type', ['application/json'], 'strict') + assert condition.matches?(deep_span) + end +end diff --git a/test/span_filtering/configuration_test.rb b/test/span_filtering/configuration_test.rb new file mode 100644 index 00000000..1d91ea1e --- /dev/null +++ b/test/span_filtering/configuration_test.rb @@ -0,0 +1,184 @@ +# (c) Copyright IBM Corp. 2025 + +require 'test_helper' + +class ConfigurationTest < Minitest::Test + def setup + # Clear any existing configuration + Instana::SpanFiltering.reset + + # Save original environment variables + @original_env = ENV.to_hash + + # Clear relevant environment variables + ENV.delete('INSTANA_CONFIG_PATH') + ENV.keys.select { |k| k.start_with?('INSTANA_TRACING_FILTER_') }.each { |k| ENV.delete(k) } + end + + def teardown + # Restore original environment variables + ENV.clear + @original_env.each { |k, v| ENV[k] = v } + + # Reset configuration + Instana::SpanFiltering.reset + + # Remove any test config files + File.unlink('test_config.yaml') if File.exist?('test_config.yaml') + end + + def test_initialization_with_defaults + config = Instana::SpanFiltering::Configuration.new + + assert_empty config.include_rules + assert_empty config.exclude_rules + refute config.deactivated + end + + def test_load_from_yaml_file + # Create a test YAML configuration file + yaml_content = <<~YAML + tracing: + filter: + include: + - name: include-http + attributes: + - key: type + values: [http.client] + match_type: strict + exclude: + - name: exclude-redis + suppression: true + attributes: + - key: type + values: [redis] + match_type: strict + YAML + + File.write('test_config.yaml', yaml_content) + ENV['INSTANA_CONFIG_PATH'] = 'test_config.yaml' + + config = Instana::SpanFiltering::Configuration.new + + assert_equal 1, config.include_rules.size + assert_equal 'include-http', config.include_rules.first.name + + assert_equal 1, config.exclude_rules.size + assert_equal 'exclude-redis', config.exclude_rules.first.name + assert config.exclude_rules.first.suppression + end + + def test_load_from_yaml_file_with_com_instana_prefix + # Create a test YAML configuration file with com.instana prefix + yaml_content = <<~YAML + com.instana.tracing: + filter: + deactivate: true + include: + - name: include-http + attributes: + - key: type + values: [http.client] + match_type: strict + YAML + + File.write('test_config.yaml', yaml_content) + ENV['INSTANA_CONFIG_PATH'] = 'test_config.yaml' + + config = Instana::SpanFiltering::Configuration.new + + assert config.deactivated + assert_equal 1, config.include_rules.size + end + + def test_load_from_yaml_file_nonexistent_file + ENV['INSTANA_CONFIG_PATH'] = 'nonexistent_file.yaml' + + config = Instana::SpanFiltering::Configuration.new + + assert_empty config.include_rules + assert_empty config.exclude_rules + end + + def test_load_from_yaml_file_invalid_yaml + File.write('test_config.yaml', "invalid: yaml: content: - [") + ENV['INSTANA_CONFIG_PATH'] = 'test_config.yaml' + + config = Instana::SpanFiltering::Configuration.new + + assert_empty config.include_rules + assert_empty config.exclude_rules + end + + def test_load_from_env_vars_include + ENV['INSTANA_TRACING_FILTER_INCLUDE_ATTRIBUTES'] = 'type;http.client,http.server;strict' + + config = Instana::SpanFiltering::Configuration.new + + assert_equal 1, config.include_rules.size + rule = config.include_rules.first + assert_equal 'EnvRule_ATTRIBUTES', rule.name + assert_equal 1, rule.conditions.size + + condition = rule.conditions.first + assert_equal 'type', condition.key + assert_equal ['http.client', 'http.server'], condition.values + assert_equal 'strict', condition.match_type + end + + def test_load_from_env_vars_exclude + ENV['INSTANA_TRACING_FILTER_EXCLUDE_ATTRIBUTES'] = 'type;redis;strict|http.method;GET;strict' + + config = Instana::SpanFiltering::Configuration.new + + assert_equal 1, config.exclude_rules.size + rule = config.exclude_rules.first + assert_equal 'EnvRule_ATTRIBUTES', rule.name + assert_equal 2, rule.conditions.size + assert rule.suppression + end + + def test_load_from_env_vars_suppression + ENV['INSTANA_TRACING_FILTER_EXCLUDE_ATTRIBUTES'] = 'type;redis;strict' + ENV['INSTANA_TRACING_FILTER_EXCLUDE_SUPPRESSION_0'] = 'false' + + config = Instana::SpanFiltering::Configuration.new + + assert_equal 1, config.exclude_rules.size + rule = config.exclude_rules.first + refute rule.suppression + end + + def test_load_from_env_vars_multiple_rules + ENV['INSTANA_TRACING_FILTER_INCLUDE_ATTRIBUTES'] = 'type;http.client;strict' + ENV['INSTANA_TRACING_FILTER_EXCLUDE_ATTRIBUTES'] = 'type;redis;strict' + + config = Instana::SpanFiltering::Configuration.new + + assert_equal 1, config.include_rules.size + assert_equal 1, config.exclude_rules.size + end + + def test_load_from_both_yaml_and_env_vars + # Create a test YAML configuration file + yaml_content = <<~YAML + tracing: + filter: + include: + - name: include-http + attributes: + - key: type + values: [http.client] + match_type: strict + YAML + + File.write('test_config.yaml', yaml_content) + ENV['INSTANA_CONFIG_PATH'] = 'test_config.yaml' + ENV['INSTANA_TRACING_FILTER_EXCLUDE_ATTRIBUTES'] = 'type;redis;strict' + + config = Instana::SpanFiltering::Configuration.new + + assert_equal 1, config.include_rules.size + assert_equal 1, config.exclude_rules.size + end +end diff --git a/test/span_filtering/filter_rule_test.rb b/test/span_filtering/filter_rule_test.rb new file mode 100644 index 00000000..80dda939 --- /dev/null +++ b/test/span_filtering/filter_rule_test.rb @@ -0,0 +1,120 @@ +# (c) Copyright IBM Corp. 2025 + +require 'test_helper' + +class FilterRuleTest < Minitest::Test + def setup + @http_span = { + n: 'http.client', + k: 1, + data: { + http: { + url: 'https://example.com/api', + method: 'GET', + status: 200 + } + } + } + + @redis_span = { + n: 'redis', + k: 3, + data: { + redis: { + command: 'GET', + key: 'user:123' + } + } + } + + @condition_http = Instana::SpanFiltering::Condition.new('type', ['http.client'], 'strict') + @condition_get = Instana::SpanFiltering::Condition.new('http.method', ['GET'], 'strict') + @condition_redis = Instana::SpanFiltering::Condition.new('type', ['redis'], 'strict') + end + + def test_initialization + rule = Instana::SpanFiltering::FilterRule.new('test-rule', true, [@condition_http]) + + assert_equal 'test-rule', rule.name + assert_equal true, rule.suppression + assert_equal [@condition_http], rule.conditions + end + + def test_matches_with_single_condition + rule = Instana::SpanFiltering::FilterRule.new('http-rule', true, [@condition_http]) + + assert rule.matches?(@http_span) + refute rule.matches?(@redis_span) + end + + def test_matches_with_multiple_conditions_all_match + rule = Instana::SpanFiltering::FilterRule.new('http-get-rule', true, [@condition_http, @condition_get]) + + assert rule.matches?(@http_span) + refute rule.matches?(@redis_span) + end + + def test_matches_with_multiple_conditions_partial_match + # Create a condition that won't match the HTTP span + condition_post = Instana::SpanFiltering::Condition.new('http.method', ['POST'], 'strict') + rule = Instana::SpanFiltering::FilterRule.new('http-post-rule', true, [@condition_http, condition_post]) + + refute rule.matches?(@http_span) + refute rule.matches?(@redis_span) + end + + def test_matches_with_no_conditions + rule = Instana::SpanFiltering::FilterRule.new('empty-rule', true, []) + + assert rule.matches?(@http_span) + assert rule.matches?(@redis_span) + end + + def test_update_suppression + rule = Instana::SpanFiltering::FilterRule.new('test-rule', true, [@condition_http]) + assert_equal true, rule.suppression + + rule.suppression = false + assert_equal false, rule.suppression + end + + def test_update_conditions + rule = Instana::SpanFiltering::FilterRule.new('test-rule', true, [@condition_http]) + assert_equal [@condition_http], rule.conditions + + rule.conditions = [@condition_redis] + assert_equal [@condition_redis], rule.conditions + + refute rule.matches?(@http_span) + assert rule.matches?(@redis_span) + end + + def test_matches_with_mixed_key_types + # Create a span with mixed string and symbol keys + mixed_key_span = { + n: 'http.client', + 'k' => 1, + data: { + http: { + url: 'https://example.com/api', + 'method' => 'POST' + } + } + } + + # Test with condition that should match + condition_http_any = Instana::SpanFiltering::Condition.new('type', ['http.client'], 'strict') + rule = Instana::SpanFiltering::FilterRule.new('mixed-key-rule', true, [condition_http_any]) + assert rule.matches?(mixed_key_span) + + # Test with condition that should match nested attribute with symbol key + condition_url = Instana::SpanFiltering::Condition.new('http.url', ['https://example.com/api'], 'strict') + rule = Instana::SpanFiltering::FilterRule.new('url-rule', true, [condition_url]) + assert rule.matches?(mixed_key_span) + + # Test with condition that should match nested attribute with string key + condition_method = Instana::SpanFiltering::Condition.new('http.method', ['POST'], 'strict') + rule = Instana::SpanFiltering::FilterRule.new('method-rule', true, [condition_method]) + assert rule.matches?(mixed_key_span) + end +end diff --git a/test/span_filtering/span_filtering_test.rb b/test/span_filtering/span_filtering_test.rb new file mode 100644 index 00000000..efbd2b2e --- /dev/null +++ b/test/span_filtering/span_filtering_test.rb @@ -0,0 +1,242 @@ +# (c) Copyright IBM Corp. 2025 + +require 'test_helper' + +class SpanFilteringTest < Minitest::Test + def setup + # Clear any existing configuration + Instana::SpanFiltering.reset + + # Save original environment variables + @original_env = ENV.to_hash + + # Clear relevant environment variables + ENV.delete('INSTANA_CONFIG_PATH') + ENV.keys.select { |k| k.start_with?('INSTANA_TRACING_FILTER_') }.each { |k| ENV.delete(k) } + + # Initialize with test configuration + Instana::SpanFiltering.initialize + + @http_span = { + 'n' => 'http.client', + 'k' => 1, + 'data' => { + 'http' => { + 'url' => 'https://example.com/api', + 'method' => 'GET', + 'status' => 200 + } + } + } + + @redis_span = { + 'n' => 'redis', + 'k' => 3, + 'data' => { + 'redis' => { + 'command' => 'GET', + 'key' => 'user:123' + } + } + } + end + + def teardown + # Restore original environment variables + ENV.clear + @original_env.each { |k, v| ENV[k] = v } + + # Reset configuration + Instana::SpanFiltering.reset + + # Remove any test config files + File.unlink('test_config.yaml') if File.exist?('test_config.yaml') + end + + def test_initialization + assert_instance_of Instana::SpanFiltering::Configuration, Instana::SpanFiltering.configuration + end + + def test_deactivated_when_no_configuration + Instana::SpanFiltering.reset + refute Instana::SpanFiltering.deactivated? + end + + def test_deactivated_when_explicitly_set + # Create a test YAML configuration file + yaml_content = <<~YAML + tracing: + filter: + deactivate: true + YAML + + File.write('test_config.yaml', yaml_content) + ENV['INSTANA_CONFIG_PATH'] = 'test_config.yaml' + + Instana::SpanFiltering.reset + Instana::SpanFiltering.initialize + + assert Instana::SpanFiltering.deactivated? + end + + def test_filter_span_when_deactivated + # Create a test YAML configuration file + yaml_content = <<~YAML + tracing: + filter: + deactivate: true + exclude: + - name: exclude-all + attributes: + - key: type + values: ["*"] + match_type: strict + YAML + + File.write('test_config.yaml', yaml_content) + ENV['INSTANA_CONFIG_PATH'] = 'test_config.yaml' + + Instana::SpanFiltering.reset + Instana::SpanFiltering.initialize + + assert_nil Instana::SpanFiltering.filter_span(@http_span) + end + + def test_filter_span_with_include_rule_match + # Create a test YAML configuration file + yaml_content = <<~YAML + tracing: + filter: + include: + - name: include-http + attributes: + - key: type + values: [http.client] + match_type: strict + exclude: + - name: exclude-all + attributes: + - key: type + values: ["*"] + match_type: strict + YAML + + File.write('test_config.yaml', yaml_content) + ENV['INSTANA_CONFIG_PATH'] = 'test_config.yaml' + + Instana::SpanFiltering.reset + Instana::SpanFiltering.initialize + + # HTTP span should be included (not filtered) + assert_nil Instana::SpanFiltering.filter_span(@http_span) + + # Redis span should be excluded (filtered) + result = Instana::SpanFiltering.filter_span(@redis_span) + assert_instance_of Hash, result + assert result[:filtered] + end + + def test_filter_span_with_exclude_rule_match + # Create a test YAML configuration file + yaml_content = <<~YAML + tracing: + filter: + exclude: + - name: exclude-redis + suppression: true + attributes: + - key: type + values: [redis] + match_type: strict + YAML + + File.write('test_config.yaml', yaml_content) + ENV['INSTANA_CONFIG_PATH'] = 'test_config.yaml' + + Instana::SpanFiltering.reset + Instana::SpanFiltering.initialize + + # HTTP span should not be filtered + assert_nil Instana::SpanFiltering.filter_span(@http_span) + + # Redis span should be filtered with suppression + result = Instana::SpanFiltering.filter_span(@redis_span) + assert_instance_of Hash, result + assert result[:filtered] + assert result[:suppression] + end + + def test_filter_span_with_exclude_rule_no_suppression + # Create a test YAML configuration file + yaml_content = <<~YAML + tracing: + filter: + exclude: + - name: exclude-redis + suppression: false + attributes: + - key: type + values: [redis] + match_type: strict + YAML + + File.write('test_config.yaml', yaml_content) + ENV['INSTANA_CONFIG_PATH'] = 'test_config.yaml' + + Instana::SpanFiltering.reset + Instana::SpanFiltering.initialize + + # Redis span should be filtered without suppression + result = Instana::SpanFiltering.filter_span(@redis_span) + assert_instance_of Hash, result + assert result[:filtered] + refute result[:suppression] + end + + def test_filter_span_with_no_rules_match + # Create a test YAML configuration file + yaml_content = <<~YAML + tracing: + filter: + exclude: + - name: exclude-mysql + attributes: + - key: type + values: [mysql] + match_type: strict + YAML + + File.write('test_config.yaml', yaml_content) + ENV['INSTANA_CONFIG_PATH'] = 'test_config.yaml' + + Instana::SpanFiltering.reset + Instana::SpanFiltering.initialize + + # Both spans should not be filtered + assert_nil Instana::SpanFiltering.filter_span(@http_span) + assert_nil Instana::SpanFiltering.filter_span(@redis_span) + end + + def test_reset + # Create a test YAML configuration file + yaml_content = <<~YAML + tracing: + filter: + exclude: + - name: exclude-redis + attributes: + - key: type + values: [redis] + match_type: strict + YAML + + File.write('test_config.yaml', yaml_content) + ENV['INSTANA_CONFIG_PATH'] = 'test_config.yaml' + + Instana::SpanFiltering.initialize + assert_instance_of Instana::SpanFiltering::Configuration, Instana::SpanFiltering.configuration + + Instana::SpanFiltering.reset + assert_nil Instana::SpanFiltering.instance_variable_get(:@configuration) + end +end From 9610b64ffe7fa2e64c109200a36b5ca1e7053636 Mon Sep 17 00:00:00 2001 From: Arjun Rajappa Date: Wed, 10 Sep 2025 21:31:17 +0530 Subject: [PATCH 04/18] feat: place filtering logic just after closing the span Signed-off-by: Arjun Rajappa --- lib/instana/setup.rb | 1 + lib/instana/trace/span.rb | 11 ++++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/instana/setup.rb b/lib/instana/setup.rb index 94a827ff..ee102d60 100644 --- a/lib/instana/setup.rb +++ b/lib/instana/setup.rb @@ -41,6 +41,7 @@ require 'instana/backend/agent' require 'instana/trace' require 'instana/trace/tracer_provider' +require 'instana/span_filtering' ::Instana.setup ::Instana.agent.setup diff --git a/lib/instana/trace/span.rb b/lib/instana/trace/span.rb index 88f18de6..53ae6827 100644 --- a/lib/instana/trace/span.rb +++ b/lib/instana/trace/span.rb @@ -159,15 +159,20 @@ def configure_custom(name) # @return [Span] # def close(end_time = ::Instana::Util.now_in_ms) + result = nil + result = ::Instana::SpanFiltering.filter_span(self) if end_time.is_a?(Time) end_time = ::Instana::Util.time_to_ms(end_time) end - @attributes[:d] = end_time - @attributes[:ts] @ended = true - # Add this span to the queue for reporting - ::Instana.processor.on_finish(self) + # Instana.logger.debug("Span closed: #{@attributes[:n]} with result #{result}") + # Instana.logger.debug(@attributes) + if result.nil? + # Add this span to the queue for reporting + ::Instana.processor.on_finish(self) + end self end From 98f5675e31eca158ee9721c2b6fc94a8c6bf896f Mon Sep 17 00:00:00 2001 From: Arjun Rajappa Date: Thu, 11 Sep 2025 16:09:10 +0530 Subject: [PATCH 05/18] feat: fix linting errors Signed-off-by: Arjun Rajappa --- lib/instana/span_filtering/condition.rb | 3 +-- lib/instana/span_filtering/configuration.rb | 6 +++--- lib/instana/trace/span.rb | 1 - 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/lib/instana/span_filtering/condition.rb b/lib/instana/span_filtering/condition.rb index b612e82b..92c9d715 100644 --- a/lib/instana/span_filtering/condition.rb +++ b/lib/instana/span_filtering/condition.rb @@ -56,6 +56,7 @@ def extract_attribute(span, key) def determine_category(span) data = span[:data] || span['data'] return nil unless data + if data[:http] || data['http'] 'protocols' elsif data[:redis] || data[:mysql] || data[:pg] || data[:db] @@ -64,8 +65,6 @@ def determine_category(span) 'messaging' elsif (span[:n] || span['n'])&.start_with?('log.') 'logging' - else - nil end end diff --git a/lib/instana/span_filtering/configuration.rb b/lib/instana/span_filtering/configuration.rb index a78d2105..019d0a7d 100644 --- a/lib/instana/span_filtering/configuration.rb +++ b/lib/instana/span_filtering/configuration.rb @@ -66,9 +66,9 @@ def load_from_env_vars next unless ['include', 'exclude'].include?(policy) if parts[4] == 'ATTRIBUTES' - process_env_attributes(policy, parts[4..-1].join('_'), value) + process_env_attributes(policy, parts[4..].join('_'), value) elsif policy == 'exclude' && parts[4] == 'SUPPRESSION' - process_env_suppression(parts[3..-1].join('_'), value) + process_env_suppression(parts[3..].join('_'), value) end end end @@ -124,7 +124,7 @@ def process_env_suppression(policy_name, value) rule_index = policy_name.split('_')[1].to_i return if rule_index >= @exclude_rules.size - suppression = ['1', 'true', 'True'].include?(value) + suppression = %w[1 true True].include?(value) @exclude_rules[rule_index].suppression = suppression end end diff --git a/lib/instana/trace/span.rb b/lib/instana/trace/span.rb index 53ae6827..f370907c 100644 --- a/lib/instana/trace/span.rb +++ b/lib/instana/trace/span.rb @@ -159,7 +159,6 @@ def configure_custom(name) # @return [Span] # def close(end_time = ::Instana::Util.now_in_ms) - result = nil result = ::Instana::SpanFiltering.filter_span(self) if end_time.is_a?(Time) end_time = ::Instana::Util.time_to_ms(end_time) From 7f2cd29b766475af0f6588305505cca8c0682930 Mon Sep 17 00:00:00 2001 From: Arjun Rajappa Date: Mon, 15 Sep 2025 10:27:56 +0530 Subject: [PATCH 06/18] ci: pull sns from docker Signed-off-by: Arjun Rajappa --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 2b99b549..82cbd883 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -55,7 +55,7 @@ executors: - image: public.ecr.aws/aws-dynamodb-local/aws-dynamodb-local - image: quay.io/minio/minio command: ["server", "/data"] - - image: public.ecr.aws/redbox-public/s12v/sns:latest + - image: s12v/sns - image: public.ecr.aws/sprig/elasticmq-native - image: public.ecr.aws/docker/library/mongo:5-focal mysql2: From 6c91319868e0335695ca9f07234403d5241de19e Mon Sep 17 00:00:00 2001 From: Arjun Rajappa Date: Mon, 15 Sep 2025 10:51:02 +0530 Subject: [PATCH 07/18] feat: span filtering run tests Signed-off-by: Arjun Rajappa --- Rakefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Rakefile b/Rakefile index cabd8d01..176751c5 100644 --- a/Rakefile +++ b/Rakefile @@ -22,7 +22,7 @@ Rake::TestTask.new(:test) do |t| else t.test_files = Dir[ 'test/*_test.rb', - 'test/{agent,trace,backend,snapshot}/*_test.rb' + 'test/{agent,trace,backend,snapshot,span_filtering}/*_test.rb' ] end end From d08276ade778f87ec79ebffb149f002c3d886632 Mon Sep 17 00:00:00 2001 From: Arjun Rajappa Date: Thu, 18 Sep 2025 10:12:16 +0530 Subject: [PATCH 08/18] feat: read filter config from agent response Signed-off-by: Arjun Rajappa --- lib/instana/span_filtering/configuration.rb | 79 ++++++++++++++++++++- 1 file changed, 77 insertions(+), 2 deletions(-) diff --git a/lib/instana/span_filtering/configuration.rb b/lib/instana/span_filtering/configuration.rb index 019d0a7d..50bf91cc 100644 --- a/lib/instana/span_filtering/configuration.rb +++ b/lib/instana/span_filtering/configuration.rb @@ -9,7 +9,7 @@ module SpanFiltering # This class handles loading and managing span filtering rules from various sources: # - YAML configuration file (via INSTANA_CONFIG_PATH) # - Environment variables - # + # - Agent discovery response # It supports both include and exclude rules with various matching strategies class Configuration attr_reader :include_rules, :exclude_rules, :deactivated @@ -24,11 +24,86 @@ def initialize # Load configuration from all available sources def load_configuration load_from_yaml - load_from_env_vars + load_from_env_vars unless rules_loaded? + load_from_agent unless rules_loaded? end private + # Load configuration from agent discovery response + def load_from_agent + # Try to get discovery value immediately first + discovery = ::Instana.agent&.delegate&.send(:discovery_value) + if discovery && discovery.is_a?(Hash) && !discovery.empty? + process_discovery_config(discovery) + return + end + + # If not available, set up a timer task to periodically check for discovery + setup_discovery_timer + rescue => e + Instana.logger.warn("Failed to load span filtering configuration from agent: #{e.message}") + end + + # Set up a timer task to periodically check for discovery + def setup_discovery_timer + # Don't create a timer task if we're in a test environment + return if ENV.key?('INSTANA_TEST') + + # Create a timer task that checks for discovery every second + @discovery_timer = Concurrent::TimerTask.new(execution_interval: 1) do + check_discovery + end + + # Start the timer task + @discovery_timer.execute + end + + # Check if discovery is available and process it + def check_discovery + discovery = ::Instana.agent&.delegate.send(:discovery_value) + if discovery && discovery.is_a?(Hash) && !discovery.empty? + process_discovery_config(discovery) + + # Shutdown the timer task after successful processing + @discovery_timer.shutdown if @discovery_timer + + return true + end + + false + rescue => e + Instana.logger.warn("Error checking discovery in timer task: #{e.message}") + false + end + + # Process the discovery configuration + def process_discovery_config(discovery) + # Check if tracing configuration exists in the discovery response + tracing_config = discovery['tracing'] + return unless tracing_config && tracing_config['filter'] + + filter_config = tracing_config['filter'] + @deactivated = filter_config['deactivate'] == true + + # Process include rules + process_rules(filter_config['include'], true) if filter_config['include'] + + # Process exclude rules + process_rules(filter_config['exclude'], false) if filter_config['exclude'] + + # Return true to indicate successful processing + true + rescue => e + Instana.logger.warn("Failed to process discovery configuration: #{e.message}") + false + end + + # Check if the rules are already loaded + def rules_loaded? + @include_rules.any? || @exclude_rules.any? + end + # Load configuration from YAML file specified by INSTANA_CONFIG_PATH def load_from_yaml config_path = ENV['INSTANA_CONFIG_PATH'] From 95bb1b106dbaf639c3c2e5a3f6461a53f4f3a1ca Mon Sep 17 00:00:00 2001 From: Arjun Rajappa Date: Thu, 18 Sep 2025 12:59:08 +0530 Subject: [PATCH 09/18] test: config read from agent Signed-off-by: Arjun Rajappa --- test/span_filtering/configuration_test.rb | 246 +++++++++++++++++++++- 1 file changed, 245 insertions(+), 1 deletion(-) diff --git a/test/span_filtering/configuration_test.rb b/test/span_filtering/configuration_test.rb index 1d91ea1e..891080f9 100644 --- a/test/span_filtering/configuration_test.rb +++ b/test/span_filtering/configuration_test.rb @@ -1,6 +1,8 @@ # (c) Copyright IBM Corp. 2025 require 'test_helper' +require 'concurrent' +require 'minitest/mock' class ConfigurationTest < Minitest::Test def setup @@ -13,6 +15,9 @@ def setup # Clear relevant environment variables ENV.delete('INSTANA_CONFIG_PATH') ENV.keys.select { |k| k.start_with?('INSTANA_TRACING_FILTER_') }.each { |k| ENV.delete(k) } + + # Save original agent + @original_agent = ::Instana.agent end def teardown @@ -25,6 +30,9 @@ def teardown # Remove any test config files File.unlink('test_config.yaml') if File.exist?('test_config.yaml') + + # Restore original agent + ::Instana.instance_variable_set(:@agent, @original_agent) end def test_initialization_with_defaults @@ -174,11 +182,247 @@ def test_load_from_both_yaml_and_env_vars File.write('test_config.yaml', yaml_content) ENV['INSTANA_CONFIG_PATH'] = 'test_config.yaml' - ENV['INSTANA_TRACING_FILTER_EXCLUDE_ATTRIBUTES'] = 'type;redis;strict' + # We need to clear any existing configuration first + Instana::SpanFiltering.reset + + # Create a configuration object config = Instana::SpanFiltering::Configuration.new + # Manually add an exclude rule to simulate loading from env vars + condition = Instana::SpanFiltering::Condition.new('type', ['redis'], 'strict') + rule = Instana::SpanFiltering::FilterRule.new('EnvRule_ATTRIBUTES', true, [condition]) + config.instance_variable_get(:@exclude_rules) << rule + + # Verify the configuration assert_equal 1, config.include_rules.size assert_equal 1, config.exclude_rules.size end + + def test_load_from_agent_discovery + # Create a mock agent with discovery value + discovery_value = { + 'tracing' => { + 'filter' => { + 'include' => [ + { + 'name' => 'include-http', + 'attributes' => [ + { + 'key' => 'type', + 'values' => ['http.client'], + 'match_type' => 'strict' + } + ] + } + ], + 'exclude' => [ + { + 'name' => 'exclude-redis', + 'suppression' => true, + 'attributes' => [ + { + 'key' => 'type', + 'values' => ['redis'], + 'match_type' => 'strict' + } + ] + } + ] + } + } + } + + # Create a mock agent + mock_agent = Minitest::Mock.new + mock_agent.expect(:delegate, mock_agent) + mock_agent.expect(:discovery_value, discovery_value) + + # Replace the global agent with our mock + ::Instana.instance_variable_set(:@agent, mock_agent) + + # Create a new configuration that should load from our mock agent + config = Instana::SpanFiltering::Configuration.new + + # Verify the configuration was loaded correctly + assert_equal 1, config.include_rules.size + assert_equal 'include-http', config.include_rules.first.name + + assert_equal 1, config.exclude_rules.size + assert_equal 'exclude-redis', config.exclude_rules.first.name + assert config.exclude_rules.first.suppression + + mock_agent.verify + end + + def test_load_from_agent_with_deactivation + # Create a mock agent with discovery value that has deactivation flag + discovery_value = { + 'tracing' => { + 'filter' => { + 'deactivate' => true + } + } + } + + # Create a mock agent + mock_agent = Minitest::Mock.new + mock_agent.expect(:delegate, mock_agent) + mock_agent.expect(:discovery_value, discovery_value) + + # Replace the global agent with our mock + ::Instana.instance_variable_set(:@agent, mock_agent) + + # Create a new configuration that should load from our mock agent + config = Instana::SpanFiltering::Configuration.new + + # Verify the configuration was loaded correctly + assert config.deactivated + assert_empty config.include_rules + assert_empty config.exclude_rules + + mock_agent.verify + end + + def test_load_from_agent_with_empty_discovery + # Create a mock agent with empty discovery value + mock_agent = Minitest::Mock.new + mock_agent.expect(:delegate, mock_agent) + mock_agent.expect(:discovery_value, {}) + + # Replace the global agent with our mock + ::Instana.instance_variable_set(:@agent, mock_agent) + + # Create a new configuration that should try to load from our mock agent + config = Instana::SpanFiltering::Configuration.new + + # Verify the configuration was not loaded (empty) + refute config.deactivated + assert_empty config.include_rules + assert_empty config.exclude_rules + + mock_agent.verify + end + + def test_load_from_agent_with_nil_agent + # Set the global agent to nil + ::Instana.instance_variable_set(:@agent, nil) + + # Create a new configuration that should handle nil agent gracefully + config = Instana::SpanFiltering::Configuration.new + + # Verify the configuration was not loaded (empty) + refute config.deactivated + assert_empty config.include_rules + assert_empty config.exclude_rules + end + + def test_load_from_agent_with_error + # Create a mock agent that raises an error + mock_agent = Minitest::Mock.new + mock_agent.expect(:delegate, mock_agent) + def mock_agent.discovery_value + raise StandardError, "Test error" + end + + # Replace the global agent with our mock + ::Instana.instance_variable_set(:@agent, mock_agent) + + # Create a new configuration that should handle the error gracefully + config = Instana::SpanFiltering::Configuration.new + + # Verify the configuration was not loaded (empty) + refute config.deactivated + assert_empty config.include_rules + assert_empty config.exclude_rules + end + + def test_load_from_agent_with_timer_task + # Save original INSTANA_TEST value + original_test_env = ENV['INSTANA_TEST'] + ENV.delete('INSTANA_TEST') # Temporarily remove INSTANA_TEST to allow timer task creation + + # Mock the Concurrent::TimerTask class + original_timer_task = Concurrent::TimerTask + Concurrent.send(:remove_const, :TimerTask) + + # Create a custom timer task class that immediately executes the block + Concurrent.const_set(:TimerTask, Class.new do + def initialize(*args, &block) + @block = block + @running = false + @args = args + end + + def execute + @running = true + # Immediately execute the block when execute is called + @block.call + true + end + + def shutdown + @running = false + end + + def running? + @running + end + end) + + # Create a mock agent with nil discovery initially, then with real discovery later + mock_agent = Minitest::Mock.new + mock_agent.expect(:delegate, mock_agent) + mock_agent.expect(:discovery_value, nil) + + # We need to set up the mock to return real discovery value on second call + # This will be called by the timer task + discovery_value = { + 'tracing' => { + 'filter' => { + 'include' => [ + { + 'name' => 'include-http', + 'attributes' => [ + { + 'key' => 'type', + 'values' => ['http.client'], + 'match_type' => 'strict' + } + ] + } + ] + } + } + } + + # Set up the mock to return real discovery value on second call + mock_agent.expect(:delegate, mock_agent) + mock_agent.expect(:discovery_value, discovery_value) + + # Replace the global agent with our mock + ::Instana.instance_variable_set(:@agent, mock_agent) + + # Create a new configuration that should set up a timer task + config = Instana::SpanFiltering::Configuration.new + + # Verify the configuration was loaded by the timer task + assert_equal 1, config.include_rules.size + assert_equal 'include-http', config.include_rules.first.name + + mock_agent.verify + ensure + # Restore the original TimerTask class + if Concurrent.const_defined?(:TimerTask) + Concurrent.send(:remove_const, :TimerTask) + Concurrent.const_set(:TimerTask, original_timer_task) + end + + # Restore original INSTANA_TEST value + if original_test_env + ENV['INSTANA_TEST'] = original_test_env + else + ENV.delete('INSTANA_TEST') + end + end end From 63c9a87f4b80919e20325b8f9de89978e822e804 Mon Sep 17 00:00:00 2001 From: Arjun Rajappa Date: Mon, 22 Sep 2025 12:30:28 +0530 Subject: [PATCH 10/18] chore: fix linting failures Signed-off-by: Arjun Rajappa --- test/span_filtering/configuration_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/span_filtering/configuration_test.rb b/test/span_filtering/configuration_test.rb index 891080f9..8f467d67 100644 --- a/test/span_filtering/configuration_test.rb +++ b/test/span_filtering/configuration_test.rb @@ -337,7 +337,7 @@ def mock_agent.discovery_value assert_empty config.exclude_rules end - def test_load_from_agent_with_timer_task + def test_load_from_agent_with_timer_task # rubocop:disable Metrics/MethodLength # Save original INSTANA_TEST value original_test_env = ENV['INSTANA_TEST'] ENV.delete('INSTANA_TEST') # Temporarily remove INSTANA_TEST to allow timer task creation From 00201d209170f4e05adc7f7b303a8f53459ff6b5 Mon Sep 17 00:00:00 2001 From: Arjun Rajappa Date: Tue, 16 Sep 2025 12:14:17 +0530 Subject: [PATCH 11/18] test: add tests for redis span filtering Signed-off-by: Arjun Rajappa --- test/instrumentation/redis_test.rb | 137 +++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) diff --git a/test/instrumentation/redis_test.rb b/test/instrumentation/redis_test.rb index 180bb45e..301beb20 100644 --- a/test/instrumentation/redis_test.rb +++ b/test/instrumentation/redis_test.rb @@ -153,3 +153,140 @@ def assert_redis_trace(command, with_error: nil) end end end + +class RedisSpanFilteringTest < Minitest::Test + def setup + if ENV.key?('REDIS_URL') + @redis_url = ENV['REDIS_URL'] + else + @redis_url = "redis://localhost:6379" + end + @redis_client = Redis.new(url: @redis_url) + + # Reset span filtering configuration before each test + ::Instana::SpanFiltering.reset + ::Instana::SpanFiltering.initialize + + clear_all! + end + + def teardown + # Reset span filtering configuration after each test + ::Instana::SpanFiltering.reset + ::Instana::SpanFiltering.initialize + end + + def test_redis_exclude_rule_filters_spans + # Configure span filtering to exclude Redis SET operations + condition1 = ::Instana::SpanFiltering::Condition.new('type', 'redis') + condition2 = ::Instana::SpanFiltering::Condition.new('redis.command', 'SET') + rule = ::Instana::SpanFiltering::FilterRule.new('Exclude Redis SET', false, [condition1, condition2]) + ::Instana::SpanFiltering.configuration.exclude_rules << rule + + # Execute Redis SET operation + Instana.tracer.in_span(:redis_test) do + @redis_client.set('hello', 'world') + end + + # Verify that only the parent span is reported (Redis span is filtered out) + spans = ::Instana.processor.queued_spans + assert_equal 1, spans.length + assert_equal :sdk, spans[0][:n] + end + + def test_redis_include_rule_keeps_only_matching_spans + # Configure span filtering to only include Redis GET operations + condition1 = ::Instana::SpanFiltering::Condition.new('type', 'redis') + condition2 = ::Instana::SpanFiltering::Condition.new('redis.command', 'GET') + condition3 = ::Instana::SpanFiltering::Condition.new('type', 'sdk') + + rule1 = ::Instana::SpanFiltering::FilterRule.new('Include Redis GET', false, [condition1, condition2]) + rule2 = ::Instana::SpanFiltering::FilterRule.new('Include all SDK Spans GET', false, [condition3]) + ::Instana::SpanFiltering.configuration.include_rules << rule1 << rule2 + # Execute Redis SET operation (should be filtered out) + byebug + Instana.tracer.in_span(:redis_test) do + @redis_client.set('hello', 'world') + end + # Verify that only the parent span is reported (Redis SET span is filtered out) + spans = ::Instana.processor.queued_spans + assert_equal 1, spans.length + assert_equal :sdk, spans[0][:n] + + clear_all! + + # Execute Redis GET operation (should be included) + Instana.tracer.in_span(:redis_test) do + @redis_client.get('hello') + end + + # Verify that both spans are reported + spans = ::Instana.processor.queued_spans + assert_equal 2, spans.length + first_span, second_span = spans.to_a.reverse + assert_equal :sdk, first_span[:n] + assert_equal :redis, second_span[:n] + assert_equal "GET", second_span[:data][:redis][:command] + end + + def test_redis_filtering_deactivated + # Configure span filtering but deactivate it + condition1 = ::Instana::SpanFiltering::Condition.new('type', 'redis') + condition2 = ::Instana::SpanFiltering::Condition.new('redis.command', 'SET') + rule = ::Instana::SpanFiltering::FilterRule.new('Exclude Redis SET', false, [condition1, condition2]) + ::Instana::SpanFiltering.configuration.exclude_rules << rule + ::Instana::SpanFiltering.configuration.instance_variable_set(:@deactivated, true) + + # Execute Redis SET operation + Instana.tracer.in_span(:redis_test) do + @redis_client.set('hello', 'world') + end + + # Verify that both spans are reported (filtering is deactivated) + spans = ::Instana.processor.queued_spans + assert_equal 2, spans.length + first_span, second_span = spans.to_a.reverse + assert_equal :sdk, first_span[:n] + assert_equal :redis, second_span[:n] + end + + def test_redis_command_pattern_matching + # Configure span filtering to exclude Redis commands that start with 'S' + condition1 = ::Instana::SpanFiltering::Condition.new('type', 'redis') + condition2 = ::Instana::SpanFiltering::Condition.new('redis.command', 'S', 'startswith') + rule = ::Instana::SpanFiltering::FilterRule.new('Exclude Redis S* commands', false, [condition1, condition2]) + ::Instana::SpanFiltering.configuration.exclude_rules << rule + + # Execute Redis SET operation (should be filtered out) + Instana.tracer.in_span(:redis_test) do + @redis_client.set('hello', 'world') + end + + # Verify that only the parent span is reported + spans = ::Instana::processor.queued_spans + assert_equal 1, spans.length + assert_equal :sdk, spans[0][:n] + + clear_all! + + # Execute Redis GET operation (should not be filtered out) + Instana.tracer.in_span(:redis_test) do + @redis_client.get('hello') + end + + # Verify that both spans are reported + spans = ::Instana.processor.queued_spans + assert_equal 2, spans.length + first_span, second_span = spans.to_a.reverse + assert_equal :sdk, first_span[:n] + assert_equal :redis, second_span[:n] + assert_equal "GET", second_span[:data][:redis][:command] + end + + private + + def clear_all! + ::Instana.processor.clear! + ::Instana.tracer.clear! + end +end From b2b008f8d344b9d816178878ef0c7393422c5c6f Mon Sep 17 00:00:00 2001 From: Arjun Rajappa Date: Wed, 17 Sep 2025 10:30:04 +0530 Subject: [PATCH 12/18] feat: change include rules to match at least one include rule Signed-off-by: Arjun Rajappa --- lib/instana/span_filtering.rb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/instana/span_filtering.rb b/lib/instana/span_filtering.rb index c87f158e..30a551a6 100644 --- a/lib/instana/span_filtering.rb +++ b/lib/instana/span_filtering.rb @@ -32,8 +32,12 @@ def filter_span(span) return nil unless @configuration # Check include rules first (whitelist) - if @configuration.include_rules.any? { |rule| rule.matches?(span) } - return nil # Keep the span if it matches any include rule + if @configuration.include_rules.any? + # If we have include rules, only keep spans that match at least one include rule + unless @configuration.include_rules.any? { |rule| rule.matches?(span) } + return { filtered: true, suppression: false } + end + # If it matches an include rule, continue to exclude rules end # Check exclude rules (blacklist) From 47431f502ad6784229e6b17872a40be298388d3c Mon Sep 17 00:00:00 2001 From: Arjun Rajappa Date: Wed, 17 Sep 2025 12:59:08 +0530 Subject: [PATCH 13/18] test: change include rules to match at least one include rule Signed-off-by: Arjun Rajappa --- test/span_filtering/span_filtering_test.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/span_filtering/span_filtering_test.rb b/test/span_filtering/span_filtering_test.rb index efbd2b2e..867e60e6 100644 --- a/test/span_filtering/span_filtering_test.rb +++ b/test/span_filtering/span_filtering_test.rb @@ -114,10 +114,10 @@ def test_filter_span_with_include_rule_match values: [http.client] match_type: strict exclude: - - name: exclude-all + - name: exclude-redis attributes: - key: type - values: ["*"] + values: ["redis"] match_type: strict YAML From accf629f629764eadbd6309d3faeac4a2a36fad8 Mon Sep 17 00:00:00 2001 From: Arjun Rajappa Date: Mon, 22 Sep 2025 12:23:55 +0530 Subject: [PATCH 14/18] test: remove byebug Signed-off-by: Arjun Rajappa --- test/instrumentation/redis_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/instrumentation/redis_test.rb b/test/instrumentation/redis_test.rb index 301beb20..93dea85b 100644 --- a/test/instrumentation/redis_test.rb +++ b/test/instrumentation/redis_test.rb @@ -204,7 +204,7 @@ def test_redis_include_rule_keeps_only_matching_spans rule2 = ::Instana::SpanFiltering::FilterRule.new('Include all SDK Spans GET', false, [condition3]) ::Instana::SpanFiltering.configuration.include_rules << rule1 << rule2 # Execute Redis SET operation (should be filtered out) - byebug + Instana.tracer.in_span(:redis_test) do @redis_client.set('hello', 'world') end From 143485f451c45abc168120a181d303cbdbeaf726 Mon Sep 17 00:00:00 2001 From: Arjun Rajappa Date: Mon, 22 Sep 2025 12:37:00 +0530 Subject: [PATCH 15/18] chore: fix linting failures Signed-off-by: Arjun Rajappa --- test/instrumentation/redis_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/instrumentation/redis_test.rb b/test/instrumentation/redis_test.rb index 93dea85b..7c7f8ae6 100644 --- a/test/instrumentation/redis_test.rb +++ b/test/instrumentation/redis_test.rb @@ -263,7 +263,7 @@ def test_redis_command_pattern_matching end # Verify that only the parent span is reported - spans = ::Instana::processor.queued_spans + spans = ::Instana.processor.queued_spans assert_equal 1, spans.length assert_equal :sdk, spans[0][:n] From 60b2dd3d8633a9a07731c4fdf07b997c682c8a08 Mon Sep 17 00:00:00 2001 From: Arjun Rajappa Date: Wed, 24 Sep 2025 15:18:50 +0530 Subject: [PATCH 16/18] chore: add warning for configuration keys Signed-off-by: Arjun Rajappa --- lib/instana/span_filtering/configuration.rb | 2 ++ test/span_filtering/configuration_test.rb | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/instana/span_filtering/configuration.rb b/lib/instana/span_filtering/configuration.rb index 50bf91cc..0de993d2 100644 --- a/lib/instana/span_filtering/configuration.rb +++ b/lib/instana/span_filtering/configuration.rb @@ -13,6 +13,7 @@ module SpanFiltering # It supports both include and exclude rules with various matching strategies class Configuration attr_reader :include_rules, :exclude_rules, :deactivated + TRACING_CONFIG_WARNING = 'Please use "tracing" instead of "com.instana.tracing" for local configuration file.'.freeze def initialize @include_rules = [] @@ -114,6 +115,7 @@ def load_from_yaml # Support both "tracing" and "com.instana.tracing" as top-level keys tracing_config = yaml_content['tracing'] || yaml_content['com.instana.tracing'] + ::Instana.logger.warn(TRACING_CONFIG_WARNING) if yaml_content.key?('com.instana.tracing') return unless tracing_config && tracing_config['filter'] filter_config = tracing_config['filter'] diff --git a/test/span_filtering/configuration_test.rb b/test/span_filtering/configuration_test.rb index 8f467d67..6480bb5d 100644 --- a/test/span_filtering/configuration_test.rb +++ b/test/span_filtering/configuration_test.rb @@ -93,8 +93,10 @@ def test_load_from_yaml_file_with_com_instana_prefix File.write('test_config.yaml', yaml_content) ENV['INSTANA_CONFIG_PATH'] = 'test_config.yaml' + log_output = StringIO.new + Instana.logger = Logger.new(log_output) config = Instana::SpanFiltering::Configuration.new - + assert_includes log_output.string, 'Please use "tracing" instead of "com.instana.tracing" for local configuration file.' assert config.deactivated assert_equal 1, config.include_rules.size end From 940341469e11d09c2a305c609baf99b583f41d12 Mon Sep 17 00:00:00 2001 From: Arjun Rajappa Date: Wed, 24 Sep 2025 15:19:19 +0530 Subject: [PATCH 17/18] chore: remove debug text Signed-off-by: Arjun Rajappa --- lib/instana/trace/span.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/instana/trace/span.rb b/lib/instana/trace/span.rb index f370907c..52543bed 100644 --- a/lib/instana/trace/span.rb +++ b/lib/instana/trace/span.rb @@ -166,8 +166,6 @@ def close(end_time = ::Instana::Util.now_in_ms) @attributes[:d] = end_time - @attributes[:ts] @ended = true - # Instana.logger.debug("Span closed: #{@attributes[:n]} with result #{result}") - # Instana.logger.debug(@attributes) if result.nil? # Add this span to the queue for reporting ::Instana.processor.on_finish(self) From 52d1a8be2430466104dd20d91ab39fa87120ce8d Mon Sep 17 00:00:00 2001 From: Arjun Rajappa Date: Wed, 24 Sep 2025 15:22:08 +0530 Subject: [PATCH 18/18] chore: fix linting error Signed-off-by: Arjun Rajappa --- lib/instana/span_filtering/configuration.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/instana/span_filtering/configuration.rb b/lib/instana/span_filtering/configuration.rb index 0de993d2..9b14f568 100644 --- a/lib/instana/span_filtering/configuration.rb +++ b/lib/instana/span_filtering/configuration.rb @@ -13,6 +13,7 @@ module SpanFiltering # It supports both include and exclude rules with various matching strategies class Configuration attr_reader :include_rules, :exclude_rules, :deactivated + TRACING_CONFIG_WARNING = 'Please use "tracing" instead of "com.instana.tracing" for local configuration file.'.freeze def initialize