diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index 3e6812b..c981835 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -15,10 +15,6 @@ jobs: strategy: matrix: ruby-version: - - '2.3' - - '2.5' - - '2.6' - - '2.7' - '3.0' - '3.1' - '3.2' @@ -34,3 +30,8 @@ jobs: bundler-cache: true # runs 'bundle install' and caches installed gems automatically - name: Run tests run: bundle exec rake + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: mixpanel/mixpanel-ruby diff --git a/demo/flags/local_flags.rb b/demo/flags/local_flags.rb new file mode 100644 index 0000000..c89e2a0 --- /dev/null +++ b/demo/flags/local_flags.rb @@ -0,0 +1,25 @@ +require 'mixpanel-ruby' + +# Configuration +PROJECT_TOKEN = "" +FLAG_KEY = "sample-flag" +FLAG_FALLBACK_VARIANT = "control" +USER_CONTEXT = { "distinct_id" => "ruby-demo-user" } +API_HOST = "api.mixpanel.com" +SHOULD_POLL_CONTINUOUSLY = true +POLLING_INTERVAL_SECONDS = 15 + +local_config = { + api_host: API_HOST, + enable_polling: SHOULD_POLL_CONTINUOUSLY, + polling_interval_in_seconds: POLLING_INTERVAL_SECONDS +} + +tracker = Mixpanel::Tracker.new(PROJECT_TOKEN, local_flags_config: local_config) + +tracker.local_flags.start_polling_for_definitions! + +variant_value = tracker.local_flags.get_variant_value(FLAG_KEY, FLAG_FALLBACK_VARIANT, USER_CONTEXT) +puts "Variant value: #{variant_value}" + +tracker.local_flags.stop_polling_for_definitions! diff --git a/demo/flags/remote_flags.rb b/demo/flags/remote_flags.rb new file mode 100644 index 0000000..ae909b6 --- /dev/null +++ b/demo/flags/remote_flags.rb @@ -0,0 +1,18 @@ +require 'mixpanel-ruby' + +# Configuration +PROJECT_TOKEN = "" +FLAG_KEY = "sample-flag" +FLAG_FALLBACK_VARIANT = "control" +USER_CONTEXT = { "distinct_id" => "ruby-demo-user" } +API_HOST = "api.mixpanel.com" + +remote_config = { + api_host: API_HOST +} + +tracker = Mixpanel::Tracker.new(PROJECT_TOKEN, remote_flags_config: remote_config) + +variant_value = tracker.remote_flags.get_variant_value(FLAG_KEY, FLAG_FALLBACK_VARIANT, USER_CONTEXT) +puts "Variant value: #{variant_value}" + diff --git a/lib/mixpanel-ruby.rb b/lib/mixpanel-ruby.rb index 16951b0..79df69c 100644 --- a/lib/mixpanel-ruby.rb +++ b/lib/mixpanel-ruby.rb @@ -1,3 +1,8 @@ require 'mixpanel-ruby/consumer.rb' require 'mixpanel-ruby/tracker.rb' require 'mixpanel-ruby/version.rb' +require 'mixpanel-ruby/flags/utils.rb' +require 'mixpanel-ruby/flags/types.rb' +require 'mixpanel-ruby/flags/flags_provider.rb' +require 'mixpanel-ruby/flags/local_flags_provider.rb' +require 'mixpanel-ruby/flags/remote_flags_provider.rb' diff --git a/lib/mixpanel-ruby/flags/flags_provider.rb b/lib/mixpanel-ruby/flags/flags_provider.rb new file mode 100644 index 0000000..3984b4c --- /dev/null +++ b/lib/mixpanel-ruby/flags/flags_provider.rb @@ -0,0 +1,111 @@ +require 'net/https' +require 'json' +require 'uri' +require 'mixpanel-ruby/version' +require 'mixpanel-ruby/error' +require 'mixpanel-ruby/flags/utils' +require 'mixpanel-ruby/flags/types' + +module Mixpanel + module Flags + + # Base class for feature flags providers + # Provides common HTTP handling and exposure event tracking + class FlagsProvider + # @param provider_config [Hash] Configuration with :token, :api_host, :request_timeout_in_seconds + # @param endpoint [String] API endpoint path (e.g., '/flags' or '/flags/definitions') + # @param tracker_callback [Proc] Function used to track events (bound tracker.track method) + # @param evaluation_mode [String] The feature flag evaluation mode. This is either 'local' or 'remote' + # @param error_handler [Mixpanel::ErrorHandler] Error handler instance + def initialize(provider_config, endpoint, tracker_callback, evaluation_mode, error_handler) + @provider_config = provider_config + @endpoint = endpoint + @tracker_callback = tracker_callback + @evaluation_mode = evaluation_mode + @error_handler = error_handler + end + + # Make HTTP request to flags API endpoint + # @param additional_params [Hash, nil] Additional query parameters + # @return [Hash] Parsed JSON response + # @raise [Mixpanel::ConnectionError] on network errors + # @raise [Mixpanel::ServerError] on HTTP errors + def call_flags_endpoint(additional_params = nil) + common_params = Utils.prepare_common_query_params( + @provider_config[:token], + Mixpanel::VERSION + ) + + params = common_params.merge(additional_params || {}) + query_string = URI.encode_www_form(params) + + uri = URI::HTTPS.build( + host: @provider_config[:api_host], + path: @endpoint, + query: query_string + ) + + http = Net::HTTP.new(uri.host, uri.port) + + http.use_ssl = true + http.open_timeout = @provider_config[:request_timeout_in_seconds] + http.read_timeout = @provider_config[:request_timeout_in_seconds] + + request = Net::HTTP::Get.new(uri.request_uri) + + request.basic_auth(@provider_config[:token], '') + + request['Content-Type'] = 'application/json' + request['traceparent'] = Utils.generate_traceparent() + + begin + response = http.request(request) + + unless response.code.to_i == 200 + raise ServerError.new("HTTP #{response.code}: #{response.body}") + end + + JSON.parse(response.body) + rescue Net::OpenTimeout, Net::ReadTimeout => e + raise ConnectionError.new("Request timeout: #{e.message}") + rescue JSON::ParserError => e + raise ServerError.new("Invalid JSON response: #{e.message}") + rescue StandardError => e + raise ConnectionError.new("Network error: #{e.message}") + end + end + + # Track exposure event to Mixpanel + # @param flag_key [String] Feature flag key + # @param selected_variant [SelectedVariant] The selected variant + # @param context [Hash] User context (must include 'distinct_id') + # @param latency_ms [Integer, nil] Optional latency in milliseconds + def track_exposure_event(flag_key, selected_variant, context, latency_ms = nil) + distinct_id = context['distinct_id'] || context[:distinct_id] + + unless distinct_id + return + end + + properties = { + 'distinct_id' => distinct_id, + 'Experiment name' => flag_key, + 'Variant name' => selected_variant.variant_key, + '$experiment_type' => 'feature_flag', + 'Flag evaluation mode' => @evaluation_mode + } + + properties['Variant fetch latency (ms)'] = latency_ms if latency_ms + properties['$experiment_id'] = selected_variant.experiment_id if selected_variant.experiment_id + properties['$is_experiment_active'] = selected_variant.is_experiment_active unless selected_variant.is_experiment_active.nil? + properties['$is_qa_tester'] = selected_variant.is_qa_tester unless selected_variant.is_qa_tester.nil? + + begin + @tracker_callback.call(distinct_id, Utils::EXPOSURE_EVENT, properties) + rescue MixpanelError => e + @error_handler.handle(e) + end + end + end + end +end diff --git a/lib/mixpanel-ruby/flags/local_flags_provider.rb b/lib/mixpanel-ruby/flags/local_flags_provider.rb new file mode 100644 index 0000000..e1f1ab2 --- /dev/null +++ b/lib/mixpanel-ruby/flags/local_flags_provider.rb @@ -0,0 +1,303 @@ +require 'thread' +require 'json_logic' +require 'mixpanel-ruby/flags/flags_provider' + +module Mixpanel + module Flags + # Local feature flags provider + # Evaluates flags client-side with cached flag definitions + class LocalFlagsProvider < FlagsProvider + DEFAULT_CONFIG = { + api_host: 'api.mixpanel.com', + request_timeout_in_seconds: 10, + enable_polling: true, + polling_interval_in_seconds: 60 + }.freeze + + # @param token [String] Mixpanel project token + # @param config [Hash] Local flags configuration + # @param tracker_callback [Proc] Callback to track events + # @param error_handler [Mixpanel::ErrorHandler] Error handler + def initialize(token, config, tracker_callback, error_handler) + @config = DEFAULT_CONFIG.merge(config || {}) + + provider_config = { + token: token, + api_host: @config[:api_host], + request_timeout_in_seconds: @config[:request_timeout_in_seconds] + } + + super(provider_config, '/flags/definitions', tracker_callback, 'local', error_handler) + + @flag_definitions = {} + @polling_thread = nil + @stop_polling = false + end + + # Start polling for flag definitions + # Fetches immediately, then at regular intervals if polling enabled + def start_polling_for_definitions! + fetch_flag_definitions + + if @config[:enable_polling] && !@polling_thread + @stop_polling = false + @polling_thread = Thread.new do + loop do + sleep @config[:polling_interval_in_seconds] + break if @stop_polling + + begin + fetch_flag_definitions + rescue StandardError => e + @error_handler.handle(e) if @error_handler + end + end + end + end + rescue StandardError => e + @error_handler.handle(e) if @error_handler + end + + def stop_polling_for_definitions! + @stop_polling = true + @polling_thread&.join + @polling_thread = nil + end + + # Check if flag is enabled (for boolean flags) + # @param flag_key [String] Feature flag key + # @param context [Hash] Evaluation context (must include 'distinct_id') + # @return [Boolean] + def is_enabled?(flag_key, context) + value = get_variant_value(flag_key, false, context) + value == true + end + + # Get variant value for a flag + # @param flag_key [String] Feature flag key + # @param fallback_value [Object] Fallback value if not in rollout + # @param context [Hash] Evaluation context + # @param report_exposure [Boolean] Whether to track exposure event + # @return [Object] The variant value + def get_variant_value(flag_key, fallback_value, context, report_exposure: true) + result = get_variant( + flag_key, + SelectedVariant.new(variant_value: fallback_value), + context, + report_exposure: report_exposure + ) + result.variant_value + end + + # Get complete variant information + # @param flag_key [String] Feature flag key + # @param fallback_variant [SelectedVariant] Fallback variant + # @param context [Hash] Evaluation context + # @param report_exposure [Boolean] Whether to track exposure event + # @return [SelectedVariant] + def get_variant(flag_key, fallback_variant, context, report_exposure: true) + flag = @flag_definitions[flag_key] + + return fallback_variant unless flag + + context_key = flag['context'] + unless context.key?(context_key) || context.key?(context_key.to_sym) + return fallback_variant + end + + context_value = context[context_key] || context[context_key.to_sym] + + selected_variant = nil + + test_variant = get_variant_override_for_test_user(flag, context) + if test_variant + selected_variant = test_variant + else + rollout = get_assigned_rollout(flag, context_value, context) + if rollout + selected_variant = get_assigned_variant(flag, context_value, flag_key, rollout) + end + end + + if selected_variant + track_exposure_event(flag_key, selected_variant, context) if report_exposure + return selected_variant + end + + fallback_variant + end + + # Get all variants for user context + # Exposure events NOT tracked automatically + # @param context [Hash] Evaluation context + # @return [Hash] Map of flag_key => SelectedVariant + def get_all_variants(context) + variants = {} + + @flag_definitions.each_key do |flag_key| + variant = get_variant(flag_key, nil, context, report_exposure: false) + variants[flag_key] = variant if variant + end + + variants + end + + private + + def fetch_flag_definitions + response = call_flags_endpoint + + new_definitions = {} + (response['flags'] || []).each do |flag_data| + new_definitions[flag_data['key']] = flag_data + end + + @flag_definitions = new_definitions + + response + end + + def get_variant_override_for_test_user(flag, context) + test_users = flag.dig('ruleset', 'test', 'users') + return nil unless test_users + + distinct_id = context['distinct_id'] || context[:distinct_id] + return nil unless distinct_id + + variant_key = test_users[distinct_id.to_s] + return nil unless variant_key + + variant = get_matching_variant(variant_key, flag) + if variant + variant.is_qa_tester = true + end + variant + end + + def get_matching_variant(variant_key, flag) + return nil unless flag['ruleset'] && flag['ruleset']['variants'] + + flag['ruleset']['variants'].each do |v| + if variant_key.downcase == v['key'].downcase + return SelectedVariant.new( + variant_key: v['key'], + variant_value: v['value'], + experiment_id: flag['experiment_id'], + is_experiment_active: flag['is_experiment_active'] + ) + end + end + nil + end + + def get_assigned_rollout(flag, context_value, context) + return nil unless flag['ruleset'] && flag['ruleset']['rollout'] + + flag['ruleset']['rollout'].each_with_index do |rollout, index| + salt = if flag['hash_salt'] + "#{flag['key']}#{flag['hash_salt']}#{index}" + else + "#{flag['key']}rollout" + end + + rollout_hash = Utils.normalized_hash(context_value.to_s, salt) + + if rollout_hash < rollout['rollout_percentage'] && + is_runtime_evaluation_satisfied?(rollout, context) + return rollout + end + end + + nil + end + + def get_assigned_variant(flag, context_value, flag_key, rollout) + if rollout['variant_override'] + variant = get_matching_variant(rollout['variant_override']['key'], flag) + if variant + variant.is_qa_tester = false + return variant + end + end + + stored_salt = flag['hash_salt'] || '' + salt = "#{flag_key}#{stored_salt}variant" + variant_hash = Utils.normalized_hash(context_value.to_s, salt) + + variants = flag['ruleset']['variants'].map { |v| v.dup } + if rollout['variant_splits'] + variants.each do |v| + v['split'] = rollout['variant_splits'][v['key']] if rollout['variant_splits'].key?(v['key']) + end + end + + selected = variants.first + cumulative = 0.0 + variants.each do |v| + selected = v + cumulative += (v['split'] || 0.0) + break if variant_hash < cumulative + end + + SelectedVariant.new( + variant_key: selected['key'], + variant_value: selected['value'], + experiment_id: flag['experiment_id'], + is_experiment_active: flag['is_experiment_active'], + is_qa_tester: false + ) + end + + def lowercase_keys_and_values(val) + case val + when String + val.downcase + when Array + val.map { |item| lowercase_keys_and_values(item) } + when Hash + val.transform_keys { |k| k.is_a?(String) ? k.downcase : k } + .transform_values { |v| lowercase_keys_and_values(v) } + else + val + end + end + + def lowercase_only_leaf_nodes(val) + case val + when String + val.downcase + when Array + val.map { |item| lowercase_only_leaf_nodes(item) } + when Hash + val.transform_values { |v| lowercase_only_leaf_nodes(v) } + else + val + end + end + + def get_runtime_parameters(context) + custom_props = context['custom_properties'] || context[:custom_properties] + return nil unless custom_props && custom_props.is_a?(Hash) + + lowercase_keys_and_values(custom_props) + end + + def is_runtime_evaluation_satisfied?(rollout, context) + runtime_rule = rollout['runtime_evaluation_rule'] + return true unless runtime_rule + + parameters = get_runtime_parameters(context) + return false unless parameters + + begin + rule = lowercase_only_leaf_nodes(runtime_rule) + result = JsonLogic.apply(rule, parameters) + !!result + rescue StandardError => e + @error_handler.handle(e) if @error_handler + false + end + end + end + end +end diff --git a/lib/mixpanel-ruby/flags/remote_flags_provider.rb b/lib/mixpanel-ruby/flags/remote_flags_provider.rb new file mode 100644 index 0000000..eaa6471 --- /dev/null +++ b/lib/mixpanel-ruby/flags/remote_flags_provider.rb @@ -0,0 +1,134 @@ +require 'mixpanel-ruby/flags/flags_provider' + +module Mixpanel + module Flags + # Remote feature flags provider + # Evaluates flags on the server-side via HTTP API calls + class RemoteFlagsProvider < FlagsProvider + DEFAULT_CONFIG = { + api_host: 'api.mixpanel.com', + request_timeout_in_seconds: 10 + }.freeze + + # @param token [String] Mixpanel project token + # @param config [Hash] Remote flags configuration + # @param tracker_callback [Proc] Callback to track events + # @param error_handler [Mixpanel::ErrorHandler] Error handler + def initialize(token, config, tracker_callback, error_handler) + merged_config = DEFAULT_CONFIG.merge(config || {}) + + provider_config = { + token: token, + api_host: merged_config[:api_host], + request_timeout_in_seconds: merged_config[:request_timeout_in_seconds] + } + + super(provider_config, '/flags', tracker_callback, 'remote', error_handler) + end + + # Get variant value for a flag + # @param flag_key [String] Feature flag key + # @param fallback_value [Object] Fallback value + # @param context [Hash] Evaluation context + # @param report_exposure [Boolean] Whether to track exposure + # @return [Object] Variant value + def get_variant_value(flag_key, fallback_value, context, report_exposure: true) + selected_variant = get_variant( + flag_key, + SelectedVariant.new(variant_value: fallback_value), + context, + report_exposure: report_exposure + ) + selected_variant.variant_value + rescue MixpanelError => e + @error_handler.handle(e) + fallback_value + end + + # Get complete variant information + # @param flag_key [String] Feature flag key + # @param fallback_variant [SelectedVariant] Fallback variant + # @param context [Hash] Evaluation context + # @param report_exposure [Boolean] Whether to track exposure + # @return [SelectedVariant] + def get_variant(flag_key, fallback_variant, context, report_exposure: true) + start_time = Time.now + response = fetch_flags(context, flag_key) + latency_ms = ((Time.now - start_time) * 1000).to_i + + flags = response['flags'] || {} + selected_variant_data = flags[flag_key] + + return fallback_variant unless selected_variant_data + + selected_variant = SelectedVariant.new( + variant_key: selected_variant_data['variant_key'], + variant_value: selected_variant_data['variant_value'], + experiment_id: selected_variant_data['experiment_id'], + is_experiment_active: selected_variant_data['is_experiment_active'] + ) + + track_exposure_event(flag_key, selected_variant, context, latency_ms) if report_exposure + + return selected_variant + rescue MixpanelError => e + @error_handler.handle(e) + return fallback_variant + end + + # Check if flag is enabled (for boolean flags) + # This method is intended only for flags defined as Mixpanel Feature Gates (boolean flags) + # This checks that the variant value of a selected variant is concretely the boolean 'true' + # It does not coerce other truthy values. + # @param flag_key [String] Feature flag key + # @param context [Hash] Evaluation context + # @return [Boolean] + def is_enabled?(flag_key, context) + value = get_variant_value(flag_key, false, context) + value == true + rescue MixpanelError => e + @error_handler.handle(e) + false + end + + # Get all variants for user context + # Exposure events NOT tracked automatically + # @param context [Hash] Evaluation context + # @return [Hash, nil] Map of flag_key => SelectedVariant, or nil on error + def get_all_variants(context) + response = fetch_flags(context) + + variants = {} + (response['flags'] || {}).each do |flag_key, variant_data| + variants[flag_key] = SelectedVariant.new( + variant_key: variant_data['variant_key'], + variant_value: variant_data['variant_value'], + experiment_id: variant_data['experiment_id'], + is_experiment_active: variant_data['is_experiment_active'] + ) + end + + variants + rescue MixpanelError => e + @error_handler.handle(e) + nil + end + + private + + # Fetch flags from remote API + # @param context [Hash] Evaluation context + # @param flag_key [String, nil] Optional specific flag key + # @return [Hash] API response + def fetch_flags(context, flag_key = nil) + additional_params = { + 'context' => JSON.generate(context) + } + + additional_params['flag_key'] = flag_key if flag_key + + call_flags_endpoint(additional_params) + end + end + end +end diff --git a/lib/mixpanel-ruby/flags/types.rb b/lib/mixpanel-ruby/flags/types.rb new file mode 100644 index 0000000..f996804 --- /dev/null +++ b/lib/mixpanel-ruby/flags/types.rb @@ -0,0 +1,35 @@ +module Mixpanel + module Flags + # Selected variant returned from flag evaluation + class SelectedVariant + attr_accessor :variant_key, :variant_value, :experiment_id, + :is_experiment_active, :is_qa_tester + + # @param variant_key [String, nil] The variant key + # @param variant_value [Object] The variant value (any type) + # @param experiment_id [String, nil] Associated experiment ID + # @param is_experiment_active [Boolean, nil] Whether experiment is active + # @param is_qa_tester [Boolean, nil] Whether user is a QA tester + def initialize(variant_key: nil, variant_value: nil, experiment_id: nil, + is_experiment_active: nil, is_qa_tester: nil) + @variant_key = variant_key + @variant_value = variant_value + @experiment_id = experiment_id + @is_experiment_active = is_experiment_active + @is_qa_tester = is_qa_tester + end + + # Convert to hash representation + # @return [Hash] + def to_h + { + variant_key: @variant_key, + variant_value: @variant_value, + experiment_id: @experiment_id, + is_experiment_active: @is_experiment_active, + is_qa_tester: @is_qa_tester + }.compact + end + end + end +end diff --git a/lib/mixpanel-ruby/flags/utils.rb b/lib/mixpanel-ruby/flags/utils.rb new file mode 100644 index 0000000..b038ea2 --- /dev/null +++ b/lib/mixpanel-ruby/flags/utils.rb @@ -0,0 +1,65 @@ +require 'securerandom' + +module Mixpanel + module Flags + module Utils + EXPOSURE_EVENT = '$experiment_started'.freeze + + # FNV-1a 64-bit hash implementation + # Used for consistent variant assignment + # + # @param data [String] Data to hash + # @return [Integer] 64-bit hash value + def self.fnv1a_64(data) + fnv_prime = 0x100000001b3 + hash_value = 0xcbf29ce484222325 + + data.bytes.each do |byte| + hash_value ^= byte + hash_value *= fnv_prime + hash_value &= 0xffffffffffffffff # Keep 64-bit + end + + hash_value + end + + # Normalized hash for variant assignment + # Returns a float in the range [0.0, 1.0) for rollout percentage matching + # + # @param key [String] Key to hash (typically distinct_id) + # @param salt [String] Salt value (flag-specific) + # @return [Float] Value between 0.0 and 1.0 (non-inclusive upper bound) + def self.normalized_hash(key, salt) + combined = key.to_s + salt.to_s + hash_value = fnv1a_64(combined) + (hash_value % 100) / 100.0 + end + + # Prepare common query parameters for flags API + # + # @param token [String] Mixpanel project token + # @param $lib_version [String] SDK version + # @return [Hash] Query parameters + def self.prepare_common_query_params(token, lib_version) + { + 'mp_lib' => 'ruby', + '$lib_version' => lib_version, + 'token' => token + } + end + + # Generate W3C traceparent header for distributed tracing + # Format: 00-{trace-id}-{parent-id}-{trace-flags} + # + # @return [String] traceparent header value + def self.generate_traceparent + version = '00' + trace_id = SecureRandom.hex(16) + parent_id = SecureRandom.hex(8) + trace_flags = '01' # sampled + + "#{version}-#{trace_id}-#{parent_id}-#{trace_flags}" + end + end + end +end diff --git a/lib/mixpanel-ruby/tracker.rb b/lib/mixpanel-ruby/tracker.rb index 123fa8b..667c672 100644 --- a/lib/mixpanel-ruby/tracker.rb +++ b/lib/mixpanel-ruby/tracker.rb @@ -1,6 +1,8 @@ require 'mixpanel-ruby/events.rb' require 'mixpanel-ruby/people.rb' require 'mixpanel-ruby/groups.rb' +require 'mixpanel-ruby/flags/local_flags_provider.rb' +require 'mixpanel-ruby/flags/remote_flags_provider.rb' module Mixpanel # Use Mixpanel::Tracker to track events and profile updates in your application. @@ -33,6 +35,14 @@ class Tracker < Events # An instance of Mixpanel::Groups. Use this to send groups updates attr_reader :groups + # An instance of Mixpanel::Flags::LocalFlagsProvider. Use this for + # client-side feature flag evaluation + attr_reader :local_flags + + # An instance of Mixpanel::Flags::RemoteFlagsProvider. Use this for + # server-side feature flag evaluation + attr_reader :remote_flags + # Takes your Mixpanel project token, as a string. # # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) @@ -52,11 +62,31 @@ class Tracker < Events # If a block is provided, it is passed a type (one of :event or :profile_update) # and a string message. This same format is accepted by Mixpanel::Consumer#send! # and Mixpanel::BufferedConsumer#send! - def initialize(token, error_handler=nil, &block) + def initialize(token, error_handler=nil, local_flags_config: nil, remote_flags_config: nil, &block) super(token, error_handler, &block) @token = token @people = People.new(token, error_handler, &block) @groups = Groups.new(token, error_handler, &block) + + # Initialize local flags if config provided + if local_flags_config + @local_flags = Flags::LocalFlagsProvider.new( + token, + local_flags_config, + method(:track), # Pass bound method as callback + error_handler || ErrorHandler.new + ) + end + + # Initialize remote flags if config provided + if remote_flags_config + @remote_flags = Flags::RemoteFlagsProvider.new( + token, + remote_flags_config, + method(:track), # Pass bound method as callback + error_handler || ErrorHandler.new + ) + end end # A call to #track is a report that an event has occurred. #track diff --git a/lib/mixpanel-ruby/version.rb b/lib/mixpanel-ruby/version.rb index f170da4..1a673ea 100644 --- a/lib/mixpanel-ruby/version.rb +++ b/lib/mixpanel-ruby/version.rb @@ -1,3 +1,3 @@ module Mixpanel - VERSION = '2.3.1' + VERSION = '3.0.0' end diff --git a/mixpanel-ruby.gemspec b/mixpanel-ruby.gemspec index 0bc5c16..a04e617 100644 --- a/mixpanel-ruby.gemspec +++ b/mixpanel-ruby.gemspec @@ -12,12 +12,17 @@ spec = Gem::Specification.new do |spec| spec.homepage = 'https://mixpanel.com/help/reference/ruby' spec.license = 'Apache License 2.0' - spec.required_ruby_version = '>= 2.3.0' + spec.required_ruby_version = '>= 3.0.0' spec.add_runtime_dependency 'mutex_m' spec.add_runtime_dependency "base64" + spec.add_runtime_dependency 'json-logic-rb', '~> 0.1.5' spec.add_development_dependency 'activesupport', '~> 4.0' spec.add_development_dependency 'rake', '~> 13' spec.add_development_dependency 'rspec', '~> 3.0' - spec.add_development_dependency 'webmock', '~> 1.18' + spec.add_development_dependency 'webmock', '~> 3.16.2' + spec.add_development_dependency 'debug' + spec.add_development_dependency 'ruby-lsp-rspec' + spec.add_development_dependency 'simplecov' + spec.add_development_dependency 'simplecov-cobertura' end diff --git a/spec/mixpanel-ruby/flags/local_flags_spec.rb b/spec/mixpanel-ruby/flags/local_flags_spec.rb new file mode 100644 index 0000000..6969521 --- /dev/null +++ b/spec/mixpanel-ruby/flags/local_flags_spec.rb @@ -0,0 +1,759 @@ +require 'json' +require 'mixpanel-ruby/flags/local_flags_provider' +require 'mixpanel-ruby/flags/types' +require 'webmock/rspec' + +describe Mixpanel::Flags::LocalFlagsProvider do + let(:test_token) { 'test-token' } + let(:test_context) { { 'distinct_id' => 'user123' } } + let(:endpoint_url_regex) { %r{https://api\.mixpanel\.com/flags/definitions} } + let(:mock_tracker) { double('tracker').as_null_object } + let(:mock_error_handler) { double('error_handler', handle: nil) } + let(:config) { { enable_polling: false } } + + let(:provider) do + Mixpanel::Flags::LocalFlagsProvider.new( + test_token, + config, + mock_tracker, + mock_error_handler + ) + end + + before(:each) do + WebMock.reset! + WebMock.disable_net_connect!(allow_localhost: false) + end + + after(:each) do + provider.stop_polling_for_definitions! + end + + def create_test_flag(options = {}) + flag_key = options[:flag_key] || 'test_flag' + context = options[:context] || 'distinct_id' + variants = options[:variants] || [ + { 'key' => 'control', 'value' => 'control', 'is_control' => true, 'split' => 50.0 }, + { 'key' => 'treatment', 'value' => 'treatment', 'is_control' => false, 'split' => 50.0 } + ] + variant_override = options[:variant_override] + rollout_percentage = options[:rollout_percentage] || 100.0 + runtime_evaluation_rule = options[:runtime_evaluation_rule] + test_users = options[:test_users] + experiment_id = options[:experiment_id] + is_experiment_active = options[:is_experiment_active] + variant_splits = options[:variant_splits] + hash_salt = options[:hash_salt] + + rollout = [ + { + 'rollout_percentage' => rollout_percentage, + 'runtime_evaluation_rule' => runtime_evaluation_rule, + 'variant_override' => variant_override, + 'variant_splits' => variant_splits + }.compact + ] + + test_config = test_users ? { 'users' => test_users } : nil + + { + 'id' => 'test-id', + 'name' => 'Test Flag', + 'key' => flag_key, + 'status' => 'active', + 'project_id' => 123, + 'context' => context, + 'experiment_id' => experiment_id, + 'is_experiment_active' => is_experiment_active, + 'hash_salt' => hash_salt, + 'ruleset' => { + 'variants' => variants, + 'rollout' => rollout, + 'test' => test_config + }.compact + }.compact + end + + def stub_flag_definitions(flags) + response = { + code: 200, + flags: flags + } + + stub_request(:get, endpoint_url_regex) + .to_return( + status: 200, + body: response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + def stub_flag_definitions_failure(status_code) + stub_request(:get, endpoint_url_regex) + .to_return(status: status_code) + end + + def user_context_with_properties(properties) + { + 'distinct_id' => 'user123', + 'custom_properties' => properties + } + end + + describe '#get_variant_value' do + it 'returns fallback when no flag definitions' do + stub_flag_definitions([]) + provider.start_polling_for_definitions! + + result = provider.get_variant_value('nonexistent_flag', 'control', test_context) + expect(result).to eq('control') + expect(mock_tracker).not_to have_received(:call) + end + + it 'returns fallback if flag definition call fails' do + stub_flag_definitions_failure(500) + provider.start_polling_for_definitions! + + result = provider.get_variant_value('nonexistent_flag', 'control', test_context) + expect(result).to eq('control') + end + + it 'returns fallback when flag does not exist' do + other_flag = create_test_flag(flag_key: 'other_flag') + stub_flag_definitions([other_flag]) + provider.start_polling_for_definitions! + + result = provider.get_variant_value('nonexistent_flag', 'control', test_context) + expect(result).to eq('control') + end + + it 'returns fallback when no context' do + flag = create_test_flag(context: 'distinct_id') + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + result = provider.get_variant_value('test_flag', 'fallback', {}) + expect(result).to eq('fallback') + end + + it 'returns fallback when wrong context key' do + flag = create_test_flag(context: 'user_id') + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + result = provider.get_variant_value('test_flag', 'fallback', { 'distinct_id' => 'user123' }) + expect(result).to eq('fallback') + end + + it 'returns test user variant when configured' do + variants = [ + { 'key' => 'control', 'value' => 'false', 'is_control' => true, 'split' => 50.0 }, + { 'key' => 'treatment', 'value' => 'true', 'is_control' => false, 'split' => 50.0 } + ] + flag = create_test_flag( + variants: variants, + test_users: { 'test_user' => 'treatment' } + ) + + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + result = provider.get_variant_value('test_flag', 'control', { 'distinct_id' => 'test_user' }) + expect(result).to eq('true') + end + + it 'returns correct variant when test user variant not configured' do + variants = [ + { 'key' => 'control', 'value' => 'false', 'is_control' => true, 'split' => 50.0 }, + { 'key' => 'treatment', 'value' => 'true', 'is_control' => false, 'split' => 50.0 } + ] + flag = create_test_flag( + variants: variants, + test_users: { 'test_user' => 'nonexistent_variant' } + ) + + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + result = provider.get_variant_value('test_flag', 'fallback', { 'distinct_id' => 'test_user' }) + expect(['false', 'true']).to include(result) + end + + it 'returns fallback when rollout percentage zero' do + flag = create_test_flag(rollout_percentage: 0.0) + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + result = provider.get_variant_value('test_flag', 'fallback', test_context) + expect(result).to eq('fallback') + end + + it 'returns variant when rollout percentage hundred' do + flag = create_test_flag(rollout_percentage: 100.0) + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + result = provider.get_variant_value('test_flag', 'fallback', test_context) + expect(result).not_to eq('fallback') + expect(['control', 'treatment']).to include(result) + end + + it 'respects runtime evaluation rule with equality operator when satisfied' do + runtime_eval = { + '==' => [{'var' => 'plan'}, 'premium'] + } + flag = create_test_flag(runtime_evaluation_rule: runtime_eval) + + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + context = user_context_with_properties({'plan' => 'premium'}) + result = provider.get_variant_value('test_flag', 'fallback', context) + + expect(result).not_to eq('fallback') + expect(['control', 'treatment']).to include(result) + end + + it 'respects runtime evaluation rule with equality operator when not satisfied' do + runtime_eval = { + '==' => [{'var' => 'plan'}, 'premium'] + } + flag = create_test_flag(runtime_evaluation_rule: runtime_eval) + + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + context = user_context_with_properties({'plan' => 'basic'}) + result = provider.get_variant_value('test_flag', 'fallback', context) + + expect(result).to eq('fallback') + end + + it 'returns fallback when runtime rule is invalid' do + runtime_eval = { + '=oops=' => [{'var' => 'plan'}, 'premium'] + } + flag = create_test_flag(runtime_evaluation_rule: runtime_eval) + + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + context = user_context_with_properties({'plan' => 'premium'}) + result = provider.get_variant_value('test_flag', 'fallback', context) + + expect(result).to eq('fallback') + end + + it 'returns fallback when runtime evaluation rule used but no custom properties provided' do + runtime_eval = { + '==' => [{'var' => 'plan'}, 'premium'] + } + flag = create_test_flag(runtime_evaluation_rule: runtime_eval) + + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + context = {'distinct_id' => 'user123', 'custom_properties' => {}} + result = provider.get_variant_value('test_flag', 'fallback', context) + + expect(result).to eq('fallback') + end + + it 'respects runtime evaluation rule case-insensitive param value when satisfied' do + runtime_eval = { + '==' => [{'var' => 'plan'}, 'premium'] + } + flag = create_test_flag(runtime_evaluation_rule: runtime_eval) + + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + context = user_context_with_properties({'plan' => 'PremIum'}) + result = provider.get_variant_value('test_flag', 'fallback', context) + + expect(result).not_to eq('fallback') + expect(['control', 'treatment']).to include(result) + end + + it 'respects runtime evaluation rule case-insensitive var names when satisfied' do + runtime_eval = { + '==' => [{'var' => 'Plan'}, 'premium'] + } + flag = create_test_flag(runtime_evaluation_rule: runtime_eval) + + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + context = user_context_with_properties({'plan' => 'premium'}) + result = provider.get_variant_value('test_flag', 'fallback', context) + + expect(result).not_to eq('fallback') + expect(['control', 'treatment']).to include(result) + end + + it 'respects runtime evaluation rule case-insensitive rule value when satisfied' do + runtime_eval = { + '==' => [{'var' => 'plan'}, 'pREMIUm'] + } + flag = create_test_flag(runtime_evaluation_rule: runtime_eval) + + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + context = user_context_with_properties({'plan' => 'premium'}) + result = provider.get_variant_value('test_flag', 'fallback', context) + + expect(result).not_to eq('fallback') + expect(['control', 'treatment']).to include(result) + end + + it 'respects runtime evaluation rule with contains operator when satisfied' do + runtime_eval = { + 'in' => ['Springfield', {'var' => 'url'}] + } + flag = create_test_flag(runtime_evaluation_rule: runtime_eval) + + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + context = user_context_with_properties({'url' => 'https://helloworld.com/Springfield/all-about-it'}) + result = provider.get_variant_value('test_flag', 'fallback', context) + + expect(result).not_to eq('fallback') + expect(['control', 'treatment']).to include(result) + end + + it 'respects runtime evaluation rule with contains operator when not satisfied' do + runtime_eval = { + 'in' => ['Springfield', {'var' => 'url'}] + } + flag = create_test_flag(runtime_evaluation_rule: runtime_eval) + + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + context = user_context_with_properties({'url' => 'https://helloworld.com/Boston/all-about-it'}) + result = provider.get_variant_value('test_flag', 'fallback', context) + + expect(result).to eq('fallback') + end + + it 'respects runtime evaluation rule with multi-value in operator when satisfied' do + runtime_eval = { + 'in' => [ + {'var' => 'name'}, + ['a', 'b', 'c', 'all-from-the-ui'] + ] + } + flag = create_test_flag(runtime_evaluation_rule: runtime_eval) + + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + context = user_context_with_properties({'name' => 'b'}) + result = provider.get_variant_value('test_flag', 'fallback', context) + + expect(result).not_to eq('fallback') + expect(['control', 'treatment']).to include(result) + end + + it 'respects runtime evaluation rule with multi-value in operator when not satisfied' do + runtime_eval = { + 'in' => [ + {'var' => 'name'}, + ['a', 'b', 'c', 'all-from-the-ui'] + ] + } + flag = create_test_flag(runtime_evaluation_rule: runtime_eval) + + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + context = user_context_with_properties({'name' => 'd'}) + result = provider.get_variant_value('test_flag', 'fallback', context) + + expect(result).to eq('fallback') + end + + it 'respects runtime evaluation rule with AND operator when satisfied' do + runtime_eval = { + 'and' => [ + {'==' => [{'var' => 'name'}, 'Johannes']}, + {'==' => [{'var' => 'country'}, 'Deutschland']} + ] + } + flag = create_test_flag(runtime_evaluation_rule: runtime_eval) + + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + context = user_context_with_properties({ + 'name' => 'Johannes', + 'country' => 'Deutschland' + }) + result = provider.get_variant_value('test_flag', 'fallback', context) + + expect(result).not_to eq('fallback') + expect(['control', 'treatment']).to include(result) + end + + it 'respects runtime evaluation rule with AND operator when not satisfied' do + runtime_eval = { + 'and' => [ + {'==' => [{'var' => 'name'}, 'Johannes']}, + {'==' => [{'var' => 'country'}, 'Deutschland']} + ] + } + flag = create_test_flag(runtime_evaluation_rule: runtime_eval) + + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + context = user_context_with_properties({ + 'name' => 'Johannes', + 'country' => 'France' + }) + result = provider.get_variant_value('test_flag', 'fallback', context) + + expect(result).to eq('fallback') + end + + it 'respects runtime evaluation rule with comparison operator when satisfied' do + runtime_eval = { + '>' => [ + {'var' => 'queries_ran'}, + 25 + ] + } + flag = create_test_flag(runtime_evaluation_rule: runtime_eval) + + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + context = user_context_with_properties({'queries_ran' => 30}) + result = provider.get_variant_value('test_flag', 'fallback', context) + + expect(result).not_to eq('fallback') + expect(['control', 'treatment']).to include(result) + end + + it 'respects runtime evaluation rule with comparison operator when not satisfied' do + runtime_eval = { + '>' => [ + {'var' => 'queries_ran'}, + 25 + ] + } + flag = create_test_flag(runtime_evaluation_rule: runtime_eval) + + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + context = user_context_with_properties({'queries_ran' => 20}) + result = provider.get_variant_value('test_flag', 'fallback', context) + + expect(result).to eq('fallback') + end + + it 'picks correct variant with hundred percent split' do + variants = [ + { 'key' => 'A', 'value' => 'variant_a', 'is_control' => false, 'split' => 100.0 }, + { 'key' => 'B', 'value' => 'variant_b', 'is_control' => false, 'split' => 0.0 }, + { 'key' => 'C', 'value' => 'variant_c', 'is_control' => false, 'split' => 0.0 } + ] + flag = create_test_flag( + variants: variants, + rollout_percentage: 100.0 + ) + + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + result = provider.get_variant_value('test_flag', 'fallback', test_context) + expect(result).to eq('variant_a') + end + + it 'picks correct variant with half migrated group splits' do + variants = [ + { 'key' => 'A', 'value' => 'variant_a', 'is_control' => false, 'split' => 100.0 }, + { 'key' => 'B', 'value' => 'variant_b', 'is_control' => false, 'split' => 0.0 }, + { 'key' => 'C', 'value' => 'variant_c', 'is_control' => false, 'split' => 0.0 } + ] + variant_splits = { 'A' => 0.0, 'B' => 100.0, 'C' => 0.0 } + flag = create_test_flag( + variants: variants, + rollout_percentage: 100.0, + variant_splits: variant_splits + ) + + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + result = provider.get_variant_value('test_flag', 'fallback', test_context) + expect(result).to eq('variant_b') + end + + it 'picks correct variant with full migrated group splits' do + variants = [ + { 'key' => 'A', 'value' => 'variant_a', 'is_control' => false }, + { 'key' => 'B', 'value' => 'variant_b', 'is_control' => false }, + { 'key' => 'C', 'value' => 'variant_c', 'is_control' => false } + ] + variant_splits = { 'A' => 0.0, 'B' => 0.0, 'C' => 100.0 } + flag = create_test_flag( + variants: variants, + rollout_percentage: 100.0, + variant_splits: variant_splits + ) + + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + result = provider.get_variant_value('test_flag', 'fallback', test_context) + expect(result).to eq('variant_c') + end + + it 'picks overridden variant' do + variants = [ + { 'key' => 'A', 'value' => 'variant_a', 'is_control' => false, 'split' => 100.0 }, + { 'key' => 'B', 'value' => 'variant_b', 'is_control' => false, 'split' => 0.0 } + ] + flag = create_test_flag( + variants: variants, + variant_override: { 'key' => 'B' } + ) + + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + result = provider.get_variant_value('test_flag', 'control', test_context) + expect(result).to eq('variant_b') + end + + it 'tracks exposure when variant selected' do + flag = create_test_flag + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + expect(mock_tracker).to receive(:call).once + + provider.get_variant_value('test_flag', 'fallback', test_context) + end + + it 'tracks exposure with correct properties' do + flag = create_test_flag( + experiment_id: 'exp-123', + is_experiment_active: true, + test_users: { 'qa_user' => 'treatment' } + ) + + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + expect(mock_tracker).to receive(:call) do |distinct_id, event_name, properties| + expect(distinct_id).to eq('qa_user') + expect(event_name).to eq('$experiment_started') + expect(properties['$experiment_id']).to eq('exp-123') + expect(properties['$is_experiment_active']).to eq(true) + expect(properties['$is_qa_tester']).to eq(true) + end + + provider.get_variant_value('test_flag', 'fallback', { 'distinct_id' => 'qa_user' }) + end + + it 'does not track exposure on fallback' do + stub_flag_definitions([]) + provider.start_polling_for_definitions! + + expect(mock_tracker).not_to receive(:call) + + provider.get_variant_value('nonexistent_flag', 'fallback', test_context) + end + + it 'does not track exposure without distinct_id' do + flag = create_test_flag(context: 'company') + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + expect(mock_tracker).not_to receive(:call) + + provider.get_variant_value('test_flag', 'fallback', { 'company_id' => 'company123' }) + end + end + + describe '#get_variant' do + it 'returns fallback variant when no flag definitions' do + stub_flag_definitions([]) + provider.start_polling_for_definitions! + + fallback = Mixpanel::Flags::SelectedVariant.new(variant_value: 'control') + result = provider.get_variant('nonexistent_flag', fallback, test_context) + + expect(result.variant_value).to eq('control') + expect(mock_tracker).not_to have_received(:call) + end + + it 'returns variant with correct properties' do + flag = create_test_flag(rollout_percentage: 100.0) + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + fallback = Mixpanel::Flags::SelectedVariant.new(variant_value: 'fallback') + result = provider.get_variant('test_flag', fallback, test_context, report_exposure: false) + + expect(['control', 'treatment']).to include(result.variant_key) + expect(['control', 'treatment']).to include(result.variant_value) + end + end + + describe '#get_all_variants' do + it 'returns empty hash when no flag definitions' do + stub_flag_definitions([]) + provider.start_polling_for_definitions! + + result = provider.get_all_variants(test_context) + + expect(result).to eq({}) + end + + it 'returns all variants when two flags have 100% rollout' do + flag1 = create_test_flag(flag_key: 'flag1', rollout_percentage: 100.0) + flag2 = create_test_flag(flag_key: 'flag2', rollout_percentage: 100.0) + + stub_flag_definitions([flag1, flag2]) + provider.start_polling_for_definitions! + + result = provider.get_all_variants(test_context) + + expect(result.keys).to contain_exactly('flag1', 'flag2') + end + + it 'returns partial results when one flag has 0% rollout' do + flag1 = create_test_flag(flag_key: 'flag1', rollout_percentage: 100.0) + flag2 = create_test_flag(flag_key: 'flag2', rollout_percentage: 0.0) + + stub_flag_definitions([flag1, flag2]) + provider.start_polling_for_definitions! + + result = provider.get_all_variants(test_context) + + expect(result.keys).to include('flag1') + expect(result.keys).not_to include('flag2') + end + + it 'does not track exposure events' do + flag1 = create_test_flag(flag_key: 'flag1', rollout_percentage: 100.0) + flag2 = create_test_flag(flag_key: 'flag2', rollout_percentage: 100.0) + + stub_flag_definitions([flag1, flag2]) + provider.start_polling_for_definitions! + + expect(mock_tracker).not_to receive(:call) + + provider.get_all_variants(test_context) + end + end + + describe '#is_enabled' do + it 'returns false for nonexistent flag' do + stub_flag_definitions([]) + provider.start_polling_for_definitions! + + result = provider.is_enabled?('nonexistent_flag', test_context) + expect(result).to eq(false) + end + + it 'returns true for true variant value' do + variants = [ + { 'key' => 'treatment', 'value' => true, 'is_control' => false, 'split' => 100.0 } + ] + flag = create_test_flag(variants: variants, rollout_percentage: 100.0) + + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + result = provider.is_enabled?('test_flag', test_context) + expect(result).to eq(true) + end + + it 'returns false for false variant value' do + variants = [ + { 'key' => 'control', 'value' => false, 'is_control' => true, 'split' => 100.0 } + ] + flag = create_test_flag(variants: variants, rollout_percentage: 100.0) + + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + result = provider.is_enabled?('test_flag', test_context) + expect(result).to eq(false) + end + + it 'returns false for truthy non-boolean values' do + variants = [ + { 'key' => 'treatment', 'value' => 'true', 'is_control' => false, 'split' => 100.0 } + ] + flag = create_test_flag(variants: variants, rollout_percentage: 100.0) + + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + result = provider.is_enabled?('test_flag', test_context) + expect(result).to eq(false) + end + end + + describe '#track_exposure_event' do + it 'successfully tracks' do + flag = create_test_flag + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + variant = Mixpanel::Flags::SelectedVariant.new( + variant_key: 'treatment', + variant_value: 'treatment' + ) + + expect(mock_tracker).to receive(:call).once + + provider.send(:track_exposure_event, 'test_flag', variant, test_context) + end + end + + describe 'polling' do + it 'uses most recent polled flag definitions' do + flag_v1 = create_test_flag(rollout_percentage: 0.0) + flag_v2 = create_test_flag(rollout_percentage: 100.0) + + call_count = 0 + stub_request(:get, endpoint_url_regex) + .to_return do |request| + call_count += 1 + flag = call_count == 1 ? flag_v1 : flag_v2 + { + status: 200, + body: { code: 200, flags: [flag] }.to_json, + headers: { 'Content-Type' => 'application/json' } + } + end + + polling_provider = Mixpanel::Flags::LocalFlagsProvider.new( + test_token, + { enable_polling: true, polling_interval_in_seconds: 0.1 }, + mock_tracker, + mock_error_handler + ) + + begin + polling_provider.start_polling_for_definitions! + + sleep 0.3 + + result = polling_provider.get_variant_value('test_flag', 'fallback', test_context, report_exposure: false) + expect(result).not_to eq('fallback') + ensure + polling_provider.stop_polling_for_definitions! + end + end + end +end diff --git a/spec/mixpanel-ruby/flags/remote_flags_spec.rb b/spec/mixpanel-ruby/flags/remote_flags_spec.rb new file mode 100644 index 0000000..8e7308b --- /dev/null +++ b/spec/mixpanel-ruby/flags/remote_flags_spec.rb @@ -0,0 +1,441 @@ +require 'json' +require 'mixpanel-ruby/flags/remote_flags_provider' +require 'mixpanel-ruby/flags/types' +require 'webmock/rspec' + +describe Mixpanel::Flags::RemoteFlagsProvider do + let(:test_token) { 'test-token' } + let(:test_context) { { 'distinct_id' => 'user123' } } + let(:endpoint_url_regex) { %r{https://api\.mixpanel\.com/flags} } + let(:mock_tracker) { double('tracker').as_null_object } + let(:mock_error_handler) { double('error_handler', handle: nil) } + let(:config) { {} } + + let(:provider) do + Mixpanel::Flags::RemoteFlagsProvider.new( + test_token, + config, + mock_tracker, + mock_error_handler + ) + end + + before(:each) do + WebMock.reset! + WebMock.disable_net_connect!(allow_localhost: false) + end + + def create_success_response(flags_with_selected_variant) + { + code: 200, + flags: flags_with_selected_variant + } + end + + def stub_flags_request(response_body) + stub_request(:get, endpoint_url_regex) + .to_return( + status: 200, + body: response_body.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + end + + def stub_flags_request_failure(status_code) + stub_request(:get, endpoint_url_regex) + .with(basic_auth: [test_token, '']) + .to_return(status: status_code) + end + + def stub_flags_request_error(error) + stub_request(:get, endpoint_url_regex) + .with(basic_auth: [test_token, '']) + .to_raise(error) + end + + describe '#get_variant_value' do + it 'returns fallback value if call fails' do + stub_flags_request_error(Errno::ECONNREFUSED) + + result = provider.get_variant_value('test_flag', 'control', test_context) + expect(result).to eq('control') + end + + it 'returns fallback value if bad response format' do + stub_request(:get, %r{api\.mixpanel\.com/flags}) + .to_return( + status: 200, + body: 'invalid json', + headers: { 'Content-Type' => 'text/plain' } + ) + + result = provider.get_variant_value('test_flag', 'control', test_context) + expect(result).to eq('control') + end + + it 'returns fallback value if success but no flag found' do + stub_flags_request(create_success_response({})) + + result = provider.get_variant_value('test_flag', 'control', test_context) + expect(result).to eq('control') + end + + it 'returns expected variant from API' do + response = create_success_response({ + 'test_flag' => { + 'variant_key' => 'treatment', + 'variant_value' => 'treatment' + } + }) + stub_flags_request(response) + + result = provider.get_variant_value('test_flag', 'control', test_context) + expect(result).to eq('treatment') + end + + it 'tracks exposure event if variant selected' do + response = create_success_response({ + 'test_flag' => { + 'variant_key' => 'treatment', + 'variant_value' => 'treatment' + } + }) + stub_flags_request(response) + + expect(mock_tracker).to receive(:call).once + + provider.get_variant_value('test_flag', 'control', test_context) + end + + it 'does not track exposure event if fallback' do + stub_flags_request_error(Errno::ECONNREFUSED) + + expect(mock_tracker).not_to receive(:call) + + provider.get_variant_value('test_flag', 'control', test_context) + end + + it 'does not track exposure event when report_exposure is false' do + response = create_success_response({ + 'test_flag' => { + 'variant_key' => 'treatment', + 'variant_value' => 'treatment' + } + }) + stub_flags_request(response) + + expect(mock_tracker).not_to receive(:call) + + provider.get_variant_value('test_flag', 'control', test_context, report_exposure: false) + end + + it 'handles different variant value types' do + # Test string + response = create_success_response({ + 'string_flag' => { + 'variant_key' => 'treatment', + 'variant_value' => 'text-value' + } + }) + stub_flags_request(response) + result = provider.get_variant_value('string_flag', 'default', test_context, report_exposure: false) + expect(result).to eq('text-value') + + # Test number + WebMock.reset! + response = create_success_response({ + 'number_flag' => { + 'variant_key' => 'treatment', + 'variant_value' => 42 + } + }) + stub_flags_request(response) + result = provider.get_variant_value('number_flag', 0, test_context, report_exposure: false) + expect(result).to eq(42) + + # Test object + WebMock.reset! + response = create_success_response({ + 'object_flag' => { + 'variant_key' => 'treatment', + 'variant_value' => { 'key' => 'value' } + } + }) + stub_flags_request(response) + result = provider.get_variant_value('object_flag', {}, test_context, report_exposure: false) + expect(result).to eq({ 'key' => 'value' }) + end + end + + describe '#get_variant' do + it 'returns variant when served' do + response = create_success_response({ + 'new-feature' => { + 'variant_key' => 'on', + 'variant_value' => true + } + }) + stub_flags_request(response) + + fallback_variant = Mixpanel::Flags::SelectedVariant.new(variant_value: false) + result = provider.get_variant('new-feature', fallback_variant, test_context, report_exposure: false) + + expect(result.variant_key).to eq('on') + expect(result.variant_value).to eq(true) + end + + it 'selects fallback variant when no flags are served' do + stub_flags_request(create_success_response({})) + + fallback_variant = Mixpanel::Flags::SelectedVariant.new( + variant_key: 'control', + variant_value: false + ) + result = provider.get_variant('any-flag', fallback_variant, test_context) + + expect(result.variant_key).to eq('control') + expect(result.variant_value).to eq(false) + expect(mock_tracker).not_to have_received(:call) + end + + it 'selects fallback variant if flag does not exist in served flags' do + response = create_success_response({ + 'different-flag' => { + 'variant_key' => 'on', + 'variant_value' => true + } + }) + stub_flags_request(response) + + fallback_variant = Mixpanel::Flags::SelectedVariant.new( + variant_key: 'control', + variant_value: false + ) + result = provider.get_variant('missing-flag', fallback_variant, test_context) + + expect(result.variant_key).to eq('control') + expect(result.variant_value).to eq(false) + expect(mock_tracker).not_to have_received(:call) + end + + it 'tracks exposure event when variant is selected' do + response = create_success_response({ + 'test-flag' => { + 'variant_key' => 'treatment', + 'variant_value' => true + } + }) + stub_flags_request(response) + + fallback_variant = Mixpanel::Flags::SelectedVariant.new( + variant_key: 'control', + variant_value: false + ) + + expect(mock_tracker).to receive(:call) do |distinct_id, event_name, properties| + expect(distinct_id).to eq('user123') + expect(event_name).to eq('$experiment_started') + expect(properties['Experiment name']).to eq('test-flag') + expect(properties['Variant name']).to eq('treatment') + expect(properties['$experiment_type']).to eq('feature_flag') + expect(properties['Flag evaluation mode']).to eq('remote') + end + + provider.get_variant('test-flag', fallback_variant, test_context) + end + + it 'does not track exposure event when fallback variant is selected' do + stub_flags_request(create_success_response({})) + + fallback_variant = Mixpanel::Flags::SelectedVariant.new( + variant_key: 'control', + variant_value: false + ) + + expect(mock_tracker).not_to receive(:call) + + provider.get_variant('any-flag', fallback_variant, test_context) + end + end + + describe '#is_enabled' do + it 'returns true when variant value is boolean true' do + response = create_success_response({ + 'test_flag' => { + 'variant_key' => 'on', + 'variant_value' => true + } + }) + stub_flags_request(response) + + result = provider.is_enabled?('test_flag', test_context) + expect(result).to eq(true) + end + + it 'returns false when variant value is boolean false' do + response = create_success_response({ + 'test_flag' => { + 'variant_key' => 'off', + 'variant_value' => false + } + }) + stub_flags_request(response) + + result = provider.is_enabled?('test_flag', test_context) + expect(result).to eq(false) + end + + it 'returns false for truthy non-boolean values' do + # Test string "true" + response = create_success_response({ + 'string_flag' => { + 'variant_key' => 'treatment', + 'variant_value' => 'true' + } + }) + stub_flags_request(response) + + expect(mock_tracker).to receive(:call).once + result = provider.is_enabled?('string_flag', test_context) + expect(result).to eq(false) + + # Test number 1 + WebMock.reset! + response = create_success_response({ + 'number_flag' => { + 'variant_key' => 'treatment', + 'variant_value' => 1 + } + }) + stub_flags_request(response) + + expect(mock_tracker).to receive(:call).once + result = provider.is_enabled?('number_flag', test_context) + expect(result).to eq(false) + end + + it 'returns false when flag does not exist' do + response = create_success_response({ + 'different-flag' => { + 'variant_key' => 'on', + 'variant_value' => true + } + }) + stub_flags_request(response) + + result = provider.is_enabled?('missing-flag', test_context) + expect(result).to eq(false) + end + + it 'tracks exposure event' do + response = create_success_response({ + 'test_flag' => { + 'variant_key' => 'on', + 'variant_value' => true + } + }) + stub_flags_request(response) + + expect(mock_tracker).to receive(:call) do |distinct_id, event_name, properties| + expect(event_name).to eq('$experiment_started') + expect(properties['Experiment name']).to eq('test_flag') + expect(properties['Variant name']).to eq('on') + end + + provider.is_enabled?('test_flag', test_context) + end + + it 'returns false on network error' do + stub_flags_request_error(Errno::ECONNREFUSED) + + result = provider.is_enabled?('test_flag', test_context) + expect(result).to eq(false) + end + end + + describe '#get_all_variants' do + it 'returns all variants from API' do + response = create_success_response({ + 'flag-1' => { + 'variant_key' => 'treatment', + 'variant_value' => true + }, + 'flag-2' => { + 'variant_key' => 'control', + 'variant_value' => false + }, + 'flag-3' => { + 'variant_key' => 'blue', + 'variant_value' => 'blue-theme' + } + }) + stub_flags_request(response) + + result = provider.get_all_variants(test_context) + + expect(result.keys).to contain_exactly('flag-1', 'flag-2', 'flag-3') + expect(result['flag-1'].variant_key).to eq('treatment') + expect(result['flag-1'].variant_value).to eq(true) + expect(result['flag-2'].variant_key).to eq('control') + expect(result['flag-2'].variant_value).to eq(false) + expect(result['flag-3'].variant_key).to eq('blue') + expect(result['flag-3'].variant_value).to eq('blue-theme') + end + + it 'returns empty hash when no flags served' do + stub_flags_request(create_success_response({})) + + result = provider.get_all_variants(test_context) + + expect(result).to eq({}) + end + + it 'does not track any exposure events' do + response = create_success_response({ + 'flag-1' => { + 'variant_key' => 'treatment', + 'variant_value' => true + }, + 'flag-2' => { + 'variant_key' => 'control', + 'variant_value' => false + } + }) + stub_flags_request(response) + + expect(mock_tracker).not_to receive(:call) + + provider.get_all_variants(test_context) + end + + it 'returns nil on network error' do + stub_flags_request_error(Errno::ECONNREFUSED) + + result = provider.get_all_variants(test_context) + + expect(result).to be_nil + end + + it 'handles empty response' do + stub_flags_request(create_success_response({})) + + result = provider.get_all_variants(test_context) + + expect(result).to eq({}) + end + end + + describe '#track_exposure_event' do + it 'successfully tracks' do + variant = Mixpanel::Flags::SelectedVariant.new( + variant_key: 'treatment', + variant_value: 'treatment' + ) + + expect(mock_tracker).to receive(:call).once + + provider.send(:track_exposure_event, 'test_flag', variant, test_context) + end + end +end diff --git a/spec/mixpanel-ruby/flags/utils_spec.rb b/spec/mixpanel-ruby/flags/utils_spec.rb new file mode 100644 index 0000000..16c23f7 --- /dev/null +++ b/spec/mixpanel-ruby/flags/utils_spec.rb @@ -0,0 +1,110 @@ +require 'rspec' +require 'mixpanel-ruby/flags/utils' + +describe Mixpanel::Flags::Utils do + describe '.generate_traceparent' do + it 'should generate traceparent in W3C format' do + traceparent = Mixpanel::Flags::Utils.generate_traceparent + + # W3C traceparent format: 00-{32 hex chars}-{16 hex chars}-01 + # https://www.w3.org/TR/trace-context/#traceparent-header + pattern = /^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/ + + expect(traceparent).to match(pattern) + end + end + + describe '.normalized_hash' do + def expect_valid_hash(hash) + expect(hash).to be_a(Float) + expect(hash).to be >= 0.0 + expect(hash).to be < 1.0 + end + + it 'should match known test vectors' do + hash1 = Mixpanel::Flags::Utils.normalized_hash('abc', 'variant') + expect(hash1).to eq(0.72) + + hash2 = Mixpanel::Flags::Utils.normalized_hash('def', 'variant') + expect(hash2).to eq(0.21) + end + + it 'should produce consistent results' do + hash1 = Mixpanel::Flags::Utils.normalized_hash('test_key', 'salt') + hash2 = Mixpanel::Flags::Utils.normalized_hash('test_key', 'salt') + hash3 = Mixpanel::Flags::Utils.normalized_hash('test_key', 'salt') + + expect(hash1).to eq(hash2) + expect(hash2).to eq(hash3) + end + + it 'should produce different hashes when salt is changed' do + hash1 = Mixpanel::Flags::Utils.normalized_hash('same_key', 'salt1') + hash2 = Mixpanel::Flags::Utils.normalized_hash('same_key', 'salt2') + hash3 = Mixpanel::Flags::Utils.normalized_hash('same_key', 'different_salt') + + expect(hash1).not_to eq(hash2) + expect(hash1).not_to eq(hash3) + expect(hash2).not_to eq(hash3) + end + + it 'should produce different hashes when order is changed' do + hash1 = Mixpanel::Flags::Utils.normalized_hash('abc', 'salt') + hash2 = Mixpanel::Flags::Utils.normalized_hash('bac', 'salt') + hash3 = Mixpanel::Flags::Utils.normalized_hash('cba', 'salt') + + expect(hash1).not_to eq(hash2) + expect(hash1).not_to eq(hash3) + expect(hash2).not_to eq(hash3) + end + + describe 'edge cases with empty strings' do + it 'should return valid hash for empty key' do + hash = Mixpanel::Flags::Utils.normalized_hash('', 'salt') + expect_valid_hash(hash) + end + + it 'should return valid hash for empty salt' do + hash = Mixpanel::Flags::Utils.normalized_hash('key', '') + expect_valid_hash(hash) + end + + it 'should return valid hash for both empty' do + hash = Mixpanel::Flags::Utils.normalized_hash('', '') + expect_valid_hash(hash) + end + + it 'empty strings in different positions should produce different results' do + hash1 = Mixpanel::Flags::Utils.normalized_hash('', 'salt') + hash2 = Mixpanel::Flags::Utils.normalized_hash('key', '') + expect(hash1).not_to eq(hash2) + end + end + + describe 'special characters' do + test_cases = [ + { key: '🎉', description: 'emoji' }, + { key: 'beyoncé', description: 'accented characters' }, + { key: 'key@#$%^&*()', description: 'special symbols' }, + { key: 'key with spaces', description: 'spaces' } + ] + + test_cases.each do |test_case| + it "should return valid hash for #{test_case[:description]}" do + hash = Mixpanel::Flags::Utils.normalized_hash(test_case[:key], 'salt') + expect_valid_hash(hash) + end + end + + it 'produces different results for different special characters' do + hashes = test_cases.map { |tc| Mixpanel::Flags::Utils.normalized_hash(tc[:key], 'salt') } + + hashes.each_with_index do |hash1, i| + hashes.each_with_index do |hash2, j| + expect(hash1).not_to eq(hash2) if i != j + end + end + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 37b3f0b..8177a80 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,3 +1,17 @@ +require 'simplecov' +require 'simplecov-cobertura' + +SimpleCov.start do + add_filter '/spec/' + add_filter '/demo/' + + # Generate both HTML and Cobertura XML formats since CodeCov typically requires XML + formatter SimpleCov::Formatter::MultiFormatter.new([ + SimpleCov::Formatter::HTMLFormatter, + SimpleCov::Formatter::CoberturaFormatter + ]) +end + require 'json' require 'webmock/rspec'