From 9bd62230f4f4e0d73579fed004420a701d6ec64e Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Wed, 12 Nov 2025 16:03:41 -0800 Subject: [PATCH 1/8] feat: add exposure service --- lib/amplitude-experiment.rb | 5 + lib/experiment/local/assignment/assignment.rb | 1 + .../local/assignment/assignment_config.rb | 1 + .../local/assignment/assignment_filter.rb | 1 + .../local/assignment/assignment_service.rb | 1 + lib/experiment/local/client.rb | 18 ++- lib/experiment/local/config.rb | 10 +- lib/experiment/local/evaluate_options.rb | 11 ++ lib/experiment/local/exposure/exposure.rb | 22 +++ .../local/exposure/exposure_config.rb | 12 ++ .../local/exposure/exposure_filter.rb | 18 +++ .../local/exposure/exposure_service.rb | 76 +++++++++ .../local/exposure/exposure_filter_spec.rb | 91 +++++++++++ .../local/exposure/exposure_service_spec.rb | 152 ++++++++++++++++++ 14 files changed, 416 insertions(+), 3 deletions(-) create mode 100644 lib/experiment/local/evaluate_options.rb create mode 100644 lib/experiment/local/exposure/exposure.rb create mode 100644 lib/experiment/local/exposure/exposure_config.rb create mode 100644 lib/experiment/local/exposure/exposure_filter.rb create mode 100644 lib/experiment/local/exposure/exposure_service.rb create mode 100644 spec/experiment/local/exposure/exposure_filter_spec.rb create mode 100644 spec/experiment/local/exposure/exposure_service_spec.rb diff --git a/lib/amplitude-experiment.rb b/lib/amplitude-experiment.rb index 60db186..59acd8c 100644 --- a/lib/amplitude-experiment.rb +++ b/lib/amplitude-experiment.rb @@ -8,10 +8,15 @@ require 'experiment/remote/client' require 'experiment/local/client' require 'experiment/local/config' +require 'experiment/local/evaluate_options' require 'experiment/local/assignment/assignment' require 'experiment/local/assignment/assignment_filter' require 'experiment/local/assignment/assignment_service' require 'experiment/local/assignment/assignment_config' +require 'experiment/local/exposure/exposure' +require 'experiment/local/exposure/exposure_filter' +require 'experiment/local/exposure/exposure_service' +require 'experiment/local/exposure/exposure_config' require 'experiment/util/lru_cache' require 'experiment/util/hash' require 'experiment/util/user' diff --git a/lib/experiment/local/assignment/assignment.rb b/lib/experiment/local/assignment/assignment.rb index f2d5fd4..fa012c0 100644 --- a/lib/experiment/local/assignment/assignment.rb +++ b/lib/experiment/local/assignment/assignment.rb @@ -1,6 +1,7 @@ module AmplitudeExperiment DAY_MILLIS = 86_400_000 # Assignment + # @deprecated Assignment tracking is deprecated. Use Exposure with ExposureService instead. class Assignment attr_accessor :user, :results, :timestamp diff --git a/lib/experiment/local/assignment/assignment_config.rb b/lib/experiment/local/assignment/assignment_config.rb index 061b921..fad51f5 100644 --- a/lib/experiment/local/assignment/assignment_config.rb +++ b/lib/experiment/local/assignment/assignment_config.rb @@ -1,5 +1,6 @@ module AmplitudeExperiment # AssignmentConfig + # @deprecated Assignment tracking is deprecated. Use ExposureConfig with ExposureService instead. class AssignmentConfig < AmplitudeAnalytics::Config attr_accessor :api_key, :cache_capacity diff --git a/lib/experiment/local/assignment/assignment_filter.rb b/lib/experiment/local/assignment/assignment_filter.rb index d09dfca..5746df1 100644 --- a/lib/experiment/local/assignment/assignment_filter.rb +++ b/lib/experiment/local/assignment/assignment_filter.rb @@ -1,5 +1,6 @@ module AmplitudeExperiment # AssignmentFilter + # @deprecated Assignment tracking is deprecated. Use ExposureFilter with ExposureService instead. class AssignmentFilter def initialize(size, ttl_millis = DAY_MILLIS) @cache = LRUCache.new(size, ttl_millis) diff --git a/lib/experiment/local/assignment/assignment_service.rb b/lib/experiment/local/assignment/assignment_service.rb index 8d40da1..0677d41 100644 --- a/lib/experiment/local/assignment/assignment_service.rb +++ b/lib/experiment/local/assignment/assignment_service.rb @@ -1,6 +1,7 @@ require_relative '../../../amplitude' module AmplitudeExperiment # AssignmentService + # @deprecated Assignment tracking is deprecated. Use ExposureService with Exposure tracking instead. class AssignmentService def initialize(amplitude, assignment_filter) @amplitude = amplitude diff --git a/lib/experiment/local/client.rb b/lib/experiment/local/client.rb index 2865888..b99f619 100644 --- a/lib/experiment/local/client.rb +++ b/lib/experiment/local/client.rb @@ -24,6 +24,11 @@ def initialize(api_key, config = nil) @assignment_service = nil @assignment_service = AssignmentService.new(AmplitudeAnalytics::Amplitude.new(config.assignment_config.api_key, configuration: config.assignment_config), AssignmentFilter.new(config.assignment_config.cache_capacity)) if config&.assignment_config + # Exposure service is always instantiated, using deployment key if no api key provided + exposure_config = @config.exposure_config + exposure_config.api_key ||= @api_key + @exposure_service = ExposureService.new(AmplitudeAnalytics::Amplitude.new(exposure_config.api_key, configuration: exposure_config), ExposureFilter.new(exposure_config.cache_capacity)) + @cohort_storage = InMemoryCohortStorage.new @flag_config_storage = InMemoryFlagConfigStorage.new @flag_config_fetcher = LocalEvaluationFetcher.new(@api_key, @logger, @config.server_url) @@ -51,6 +56,7 @@ def evaluate(user, flag_keys = []) AmplitudeExperiment.filter_default_variants(variants) end + # TODO: ruby backwards compatibility for evaluate_v2 to be looked at again # Locally evaluates flag variants for a user. # This function will only evaluate flags for the keys specified in the flag_keys argument. If flag_keys is # missing or None, all flags are evaluated. This function differs from evaluate as it will return a default @@ -58,12 +64,18 @@ def evaluate(user, flag_keys = []) # # @param [User] user The user to evaluate # @param [String[]] flag_keys The flags to evaluate with the user, if empty all flags are evaluated + # @param [EvaluateOptions] options Optional evaluation options # @return [Hash[String, Variant]] The evaluated variants - def evaluate_v2(user, flag_keys = []) + def evaluate_v2(user, flag_keys = [], options = nil) + # Handle backwards compatibility: if options is nil, create default + options = EvaluateOptions.new(flag_keys: flag_keys) if options.nil? + # Use flag_keys from options if provided, otherwise fall back to parameter + flag_keys_to_use = options.flag_keys || flag_keys + flags = @flag_config_storage.flag_configs return {} if flags.nil? - sorted_flags = TopologicalSort.sort(flags, flag_keys) + sorted_flags = TopologicalSort.sort(flags, flag_keys_to_use) required_cohorts_in_storage(sorted_flags) user = enrich_user_with_cohorts(user, flags) if @config.cohort_sync_config context = AmplitudeExperiment.user_to_evaluation_context(user) @@ -72,6 +84,8 @@ def evaluate_v2(user, flag_keys = []) result = @engine.evaluate(context, sorted_flags) @logger.debug("[Experiment] evaluate - result: #{result}") if @config.debug variants = AmplitudeExperiment.evaluation_variants_json_to_variants(result) + @exposure_service.track(Exposure.new(user, variants)) if options.tracks_exposure == true + # @deprecated Assignment tracking is deprecated. Use ExposureService with Exposure tracking instead. @assignment_service&.track(Assignment.new(user, variants)) variants end diff --git a/lib/experiment/local/config.rb b/lib/experiment/local/config.rb index ce1ee02..bcbcabd 100644 --- a/lib/experiment/local/config.rb +++ b/lib/experiment/local/config.rb @@ -35,9 +35,14 @@ class LocalEvaluationConfig attr_accessor :flag_config_polling_interval_millis # Configuration for automatically tracking assignment events after an evaluation. + # @deprecated use exposure_config instead # @return [AssignmentConfig] the config instance attr_accessor :assignment_config + # Configuration for automatically tracking exposure events after an evaluation. + # @return [ExposureConfig] the config instance + attr_accessor :exposure_config + # Configuration for downloading cohorts required for flag evaluation # @return [CohortSyncConfig] the config instance attr_accessor :cohort_sync_config @@ -48,7 +53,8 @@ class LocalEvaluationConfig # @param [String] server_zone Location of the Amplitude data center to get flags and cohorts from, US or EU # @param [Hash] bootstrap The value of bootstrap. # @param [long] flag_config_polling_interval_millis The value of flag config polling interval in million seconds. - # @param [AssignmentConfig] assignment_config Configuration for automatically tracking assignment events after an evaluation. + # @param [AssignmentConfig] assignment_config Configuration for automatically tracking assignment events after an evaluation. @deprecated use exposure_config instead + # @param [ExposureConfig] exposure_config Configuration for automatically tracking exposure events after an evaluation. # @param [CohortSyncConfig] cohort_sync_config Configuration for downloading cohorts required for flag evaluation def initialize(server_url: DEFAULT_SERVER_URL, server_zone: ServerZone::US, @@ -57,6 +63,7 @@ def initialize(server_url: DEFAULT_SERVER_URL, debug: false, logger: nil, assignment_config: nil, + exposure_config: nil, cohort_sync_config: nil) @logger = logger if logger.nil? @@ -73,6 +80,7 @@ def initialize(server_url: DEFAULT_SERVER_URL, @bootstrap = bootstrap @flag_config_polling_interval_millis = flag_config_polling_interval_millis @assignment_config = assignment_config + @exposure_config = exposure_config || ExposureConfig.new end end end diff --git a/lib/experiment/local/evaluate_options.rb b/lib/experiment/local/evaluate_options.rb new file mode 100644 index 0000000..ea21a7d --- /dev/null +++ b/lib/experiment/local/evaluate_options.rb @@ -0,0 +1,11 @@ +module AmplitudeExperiment + # Options for evaluating variants for a user. + class EvaluateOptions + attr_accessor :flag_keys, :tracks_exposure + + def initialize(flag_keys: nil, tracks_exposure: nil) + @flag_keys = flag_keys + @tracks_exposure = tracks_exposure + end + end +end diff --git a/lib/experiment/local/exposure/exposure.rb b/lib/experiment/local/exposure/exposure.rb new file mode 100644 index 0000000..9907ae8 --- /dev/null +++ b/lib/experiment/local/exposure/exposure.rb @@ -0,0 +1,22 @@ +module AmplitudeExperiment + # Exposure is a class that represents a user's exposure to a set of flags. + class Exposure + attr_accessor :user, :results, :timestamp + + def initialize(user, results) + @user = user + @results = results + @timestamp = (Time.now.to_f * 1000).to_i + end + + def canonicalize + sb = "#{@user&.user_id&.strip} #{@user&.device_id&.strip} " + results.sort.to_h.each do |key, value| + next unless value.key + + sb += "#{key.strip} #{value.key&.strip} " + end + sb + end + end +end diff --git a/lib/experiment/local/exposure/exposure_config.rb b/lib/experiment/local/exposure/exposure_config.rb new file mode 100644 index 0000000..388151d --- /dev/null +++ b/lib/experiment/local/exposure/exposure_config.rb @@ -0,0 +1,12 @@ +module AmplitudeExperiment + # ExposureConfig + class ExposureConfig < AmplitudeAnalytics::Config + attr_accessor :api_key, :cache_capacity + + def initialize(api_key = nil, cache_capacity = 65_536, **kwargs) + super(**kwargs) + @api_key = api_key + @cache_capacity = cache_capacity + end + end +end diff --git a/lib/experiment/local/exposure/exposure_filter.rb b/lib/experiment/local/exposure/exposure_filter.rb new file mode 100644 index 0000000..e28b0c6 --- /dev/null +++ b/lib/experiment/local/exposure/exposure_filter.rb @@ -0,0 +1,18 @@ +module AmplitudeExperiment + # ExposureFilter + class ExposureFilter + def initialize(size, ttl_millis = DAY_MILLIS) + @cache = LRUCache.new(size, ttl_millis) + @ttl_millis = ttl_millis + end + + def should_track(exposure) + return false if exposure.results.empty? + + canonical_exposure = exposure.canonicalize + track = @cache.get(canonical_exposure).nil? + @cache.put(canonical_exposure, 0) if track + track + end + end +end diff --git a/lib/experiment/local/exposure/exposure_service.rb b/lib/experiment/local/exposure/exposure_service.rb new file mode 100644 index 0000000..899eb5e --- /dev/null +++ b/lib/experiment/local/exposure/exposure_service.rb @@ -0,0 +1,76 @@ +require_relative '../../../amplitude' +module AmplitudeExperiment + # ExposureService + class ExposureService + def initialize(amplitude, exposure_filter) + @amplitude = amplitude + @exposure_filter = exposure_filter + end + + def track(exposure) + return unless @exposure_filter.should_track(exposure) + + events = ExposureService.to_exposure_events(exposure, @exposure_filter.ttl_millis) + events.each do |event| + @amplitude.track(event) + end + end + + def self.to_exposure_events(exposure, ttl_millis) + events = [] + canonicalized = exposure.canonicalize + exposure.results.each do |flag_key, variant| + # TODO: We don't seem to use trackExposure metadata. + track_exposure = variant.metadata ? variant.metadata.fetch('trackExposure', true) : true + next unless track_exposure + + # Skip default variant exposures + is_default = variant.metadata ? variant.metadata.fetch('default', false) : false + next if is_default + + # Determine user properties to set and unset. + set_props = {} + unset_props = {} + flag_type = variant.metadata['flagType'] if variant.metadata + if flag_type != 'mutual-exclusion-group' + if is_default + unset_props["[Experiment] #{flag_key}"] = '-' + elsif variant.key + set_props["[Experiment] #{flag_key}"] = variant.key + elsif variant.value + set_props["[Experiment] #{flag_key}"] = variant.value + end + end + + # Build event properties. + event_properties = {} + unless is_default + event_properties['[Experiment] Flag Key'] = flag_key + if variant.key + event_properties['[Experiment] Variant'] = variant.key + elsif variant.value + event_properties['[Experiment] Variant'] = variant.value + end + event_properties['metadata'] = variant.metadata if variant.metadata + end + + # Build event. + event = AmplitudeAnalytics::BaseEvent.new( + '[Experiment] Exposure', + user_id: exposure.user.user_id, + device_id: exposure.user.device_id, + event_properties: event_properties, + user_properties: { + '$set' => set_props, + '$unset' => unset_props + }, + insert_id: "#{exposure.user.user_id} #{exposure.user.device_id} #{AmplitudeExperiment.hash_code("#{flag_key} #{canonicalized}")} #{exposure.timestamp / ttl_millis}" + ) + event.groups = exposure.user.groups if exposure.user.groups + + events << event + end + events + end + end +end diff --git a/spec/experiment/local/exposure/exposure_filter_spec.rb b/spec/experiment/local/exposure/exposure_filter_spec.rb new file mode 100644 index 0000000..d38c0a9 --- /dev/null +++ b/spec/experiment/local/exposure/exposure_filter_spec.rb @@ -0,0 +1,91 @@ +require 'spec_helper' +require 'experiment/local/exposure/exposure_filter' +require 'experiment/local/exposure/exposure' +require 'experiment/user' +require 'experiment/variant' + +describe AmplitudeExperiment::ExposureFilter do + let(:user) { AmplitudeExperiment::User.new(user_id: 'user', device_id: 'device') } + + describe '#should_track' do + it 'returns true for single exposure' do + filter = AmplitudeExperiment::ExposureFilter.new(100) + results = { + 'flag-key-1' => AmplitudeExperiment::Variant.new(key: 'on', value: 'on'), + 'flag-key-2' => AmplitudeExperiment::Variant.new(key: 'control', value: 'control') + } + exposure = AmplitudeExperiment::Exposure.new(user, results) + expect(filter.should_track(exposure)).to be true + end + + it 'returns false for duplicate exposure' do + filter = AmplitudeExperiment::ExposureFilter.new(100) + results = { + 'flag-key-1' => AmplitudeExperiment::Variant.new(key: 'on', value: 'on'), + 'flag-key-2' => AmplitudeExperiment::Variant.new(key: 'control', value: 'control') + } + exposure1 = AmplitudeExperiment::Exposure.new(user, results) + exposure2 = AmplitudeExperiment::Exposure.new(user, results) + expect(filter.should_track(exposure1)).to be true + expect(filter.should_track(exposure2)).to be false + end + + it 'returns true for same user different results' do + filter = AmplitudeExperiment::ExposureFilter.new(100) + results1 = { + 'flag-key-1' => AmplitudeExperiment::Variant.new(key: 'on', value: 'on'), + 'flag-key-2' => AmplitudeExperiment::Variant.new(key: 'control', value: 'control') + } + results2 = { + 'flag-key-1' => AmplitudeExperiment::Variant.new(key: 'control', value: 'control'), + 'flag-key-2' => AmplitudeExperiment::Variant.new(key: 'on', value: 'on') + } + exposure1 = AmplitudeExperiment::Exposure.new(user, results1) + exposure2 = AmplitudeExperiment::Exposure.new(user, results2) + expect(filter.should_track(exposure1)).to be true + expect(filter.should_track(exposure2)).to be true + end + + it 'returns true for same results different users' do + filter = AmplitudeExperiment::ExposureFilter.new(100) + user1 = AmplitudeExperiment::User.new(user_id: 'user', device_id: 'device') + user2 = AmplitudeExperiment::User.new(user_id: 'different user', device_id: 'device') + results = { + 'flag-key-1' => AmplitudeExperiment::Variant.new(key: 'on', value: 'on'), + 'flag-key-2' => AmplitudeExperiment::Variant.new(key: 'control', value: 'control') + } + exposure1 = AmplitudeExperiment::Exposure.new(user1, results) + exposure2 = AmplitudeExperiment::Exposure.new(user2, results) + expect(filter.should_track(exposure1)).to be true + expect(filter.should_track(exposure2)).to be true + end + + it 'returns false for empty results' do + filter = AmplitudeExperiment::ExposureFilter.new(100) + user1 = AmplitudeExperiment::User.new(user_id: 'user', device_id: 'device') + user2 = AmplitudeExperiment::User.new(user_id: 'different user', device_id: 'device') + exposure1 = AmplitudeExperiment::Exposure.new(user1, {}) + exposure2 = AmplitudeExperiment::Exposure.new(user1, {}) + exposure3 = AmplitudeExperiment::Exposure.new(user2, {}) + expect(filter.should_track(exposure1)).to be false + expect(filter.should_track(exposure2)).to be false + expect(filter.should_track(exposure3)).to be false + end + + it 'returns false for duplicate exposures with different ordering' do + filter = AmplitudeExperiment::ExposureFilter.new(100) + results1 = { + 'flag-key-1' => AmplitudeExperiment::Variant.new(key: 'on', value: 'on'), + 'flag-key-2' => AmplitudeExperiment::Variant.new(key: 'control', value: 'control') + } + results2 = { + 'flag-key-2' => AmplitudeExperiment::Variant.new(key: 'control', value: 'control'), + 'flag-key-1' => AmplitudeExperiment::Variant.new(key: 'on', value: 'on') + } + exposure1 = AmplitudeExperiment::Exposure.new(user, results1) + exposure2 = AmplitudeExperiment::Exposure.new(user, results2) + expect(filter.should_track(exposure1)).to be true + expect(filter.should_track(exposure2)).to be false + end + end +end diff --git a/spec/experiment/local/exposure/exposure_service_spec.rb b/spec/experiment/local/exposure/exposure_service_spec.rb new file mode 100644 index 0000000..c685670 --- /dev/null +++ b/spec/experiment/local/exposure/exposure_service_spec.rb @@ -0,0 +1,152 @@ +require 'spec_helper' +require 'experiment/local/exposure/exposure_service' +require 'experiment/local/exposure/exposure' +require 'experiment/user' +require 'experiment/variant' + +describe AmplitudeExperiment::ExposureService do + let(:user) { AmplitudeExperiment::User.new(user_id: 'user', device_id: 'device') } + let(:mock_amplitude) { double('Amplitude') } + let(:filter) { double('ExposureFilter', should_track: true, ttl_millis: AmplitudeExperiment::DAY_MILLIS) } + let(:service) { AmplitudeExperiment::ExposureService.new(mock_amplitude, filter) } + + describe '#track' do + it 'calls amplitude track for each event' do + results = { + 'flag-key-1' => AmplitudeExperiment::Variant.new(key: 'on', value: 'on'), + 'flag-key-2' => AmplitudeExperiment::Variant.new(key: 'control', value: 'control') + } + exposure = AmplitudeExperiment::Exposure.new(user, results) + expect(mock_amplitude).to receive(:track).at_least(:once) + service.track(exposure) + end + + it 'does not track if filter returns false' do + filter = double('ExposureFilter', should_track: false, ttl_millis: AmplitudeExperiment::DAY_MILLIS) + service = AmplitudeExperiment::ExposureService.new(mock_amplitude, filter) + results = { + 'flag-key-1' => AmplitudeExperiment::Variant.new(key: 'on', value: 'on') + } + exposure = AmplitudeExperiment::Exposure.new(user, results) + expect(mock_amplitude).not_to receive(:track) + service.track(exposure) + end + end + + describe '.to_exposure_events' do + it 'creates one event per flag with comprehensive variants' do + basic = AmplitudeExperiment::Variant.new( + key: 'control', + value: 'control', + metadata: { + 'segmentName' => 'All Other Users', + 'flagType' => 'experiment', + 'flagVersion' => 10, + 'default' => false + } + ) + different_value = AmplitudeExperiment::Variant.new( + key: 'on', + value: 'control', + metadata: { + 'segmentName' => 'All Other Users', + 'flagType' => 'experiment', + 'flagVersion' => 10, + 'default' => false + } + ) + default = AmplitudeExperiment::Variant.new( + key: 'off', + metadata: { + 'segmentName' => 'All Other Users', + 'flagType' => 'experiment', + 'flagVersion' => 10, + 'default' => true + } + ) + mutex = AmplitudeExperiment::Variant.new( + key: 'slot-1', + value: 'slot-1', + metadata: { + 'segmentName' => 'All Other Users', + 'flagType' => 'mutual-exclusion-group', + 'flagVersion' => 10, + 'default' => false + } + ) + holdout = AmplitudeExperiment::Variant.new( + key: 'holdout', + value: 'holdout', + metadata: { + 'segmentName' => 'All Other Users', + 'flagType' => 'holdout-group', + 'flagVersion' => 10, + 'default' => false + } + ) + partial_metadata = AmplitudeExperiment::Variant.new( + key: 'on', + value: 'on', + metadata: { + 'segmentName' => 'All Other Users', + 'flagType' => 'release' + } + ) + empty_metadata = AmplitudeExperiment::Variant.new( + key: 'on', + value: 'on' + ) + empty_variant = AmplitudeExperiment::Variant.new + results = { + 'basic' => basic, + 'different_value' => different_value, + 'default' => default, + 'mutex' => mutex, + 'holdout' => holdout, + 'partial_metadata' => partial_metadata, + 'empty_metadata' => empty_metadata, + 'empty_variant' => empty_variant + } + exposure = AmplitudeExperiment::Exposure.new(user, results) + events = AmplitudeExperiment::ExposureService.to_exposure_events(exposure, AmplitudeExperiment::DAY_MILLIS) + # Should exclude default (default=true) only + # basic, different_value, mutex, holdout, partial_metadata, empty_metadata, empty_variant = 7 events + expect(events.length).to eq(7) + + events.each do |event| + expect(event.event_type).to eq('[Experiment] Exposure') + expect(event.user_id).to eq(user.user_id) + expect(event.device_id).to eq(user.device_id) + + flag_key = event.event_properties['[Experiment] Flag Key'] + expect(results[flag_key]).to be_truthy + variant = results[flag_key] + + # Validate event properties + if variant.key + expect(event.event_properties['[Experiment] Variant']).to eq(variant.key) + elsif variant.value + expect(event.event_properties['[Experiment] Variant']).to eq(variant.value) + end + expect(event.event_properties['metadata']).to eq(variant.metadata) if variant.metadata + + # Validate user properties + flag_type = variant.metadata ? variant.metadata['flagType'] : nil + if flag_type == 'mutual-exclusion-group' + expect(event.user_properties['$set']).to eq({}) + expect(event.user_properties['$unset']).to eq({}) + elsif variant.metadata && variant.metadata['default'] + expect(event.user_properties['$set']).to eq({}) + expect(event.user_properties['$unset']).to have_key("[Experiment] #{flag_key}") + else + if variant.key + expect(event.user_properties['$set']["[Experiment] #{flag_key}"]).to eq(variant.key) + elsif variant.value + expect(event.user_properties['$set']["[Experiment] #{flag_key}"]).to eq(variant.value) + end + expect(event.user_properties['$unset']).to eq({}) + end + end + end + end +end From acc7762be201fd15384782df05886cd565256887 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Thu, 13 Nov 2025 05:19:17 -0800 Subject: [PATCH 2/8] fix: access, add tests --- .../local/exposure/exposure_filter.rb | 2 + spec/experiment/local/client_spec.rb | 50 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/lib/experiment/local/exposure/exposure_filter.rb b/lib/experiment/local/exposure/exposure_filter.rb index e28b0c6..0b6a8e9 100644 --- a/lib/experiment/local/exposure/exposure_filter.rb +++ b/lib/experiment/local/exposure/exposure_filter.rb @@ -1,6 +1,8 @@ module AmplitudeExperiment # ExposureFilter class ExposureFilter + attr_accessor :ttl_millis + def initialize(size, ttl_millis = DAY_MILLIS) @cache = LRUCache.new(size, ttl_millis) @ttl_millis = ttl_millis diff --git a/spec/experiment/local/client_spec.rb b/spec/experiment/local/client_spec.rb index f454c60..3a80d60 100644 --- a/spec/experiment/local/client_spec.rb +++ b/spec/experiment/local/client_spec.rb @@ -1,3 +1,4 @@ +require 'set' module AmplitudeExperiment describe LocalEvaluationClient do let(:api_key) { 'client-DvWljIjiiuqLbyjqdvBaLFfEBrAvGuA3' } @@ -109,6 +110,55 @@ def setup_stub result = local_evaluation_client.evaluate(test_user2) expect(result['sdk-ci-local-dependencies-test-holdout']).to eq(nil) end + + it 'evaluate_v2 with tracks_exposure tracks non-default variants' do + setup_stub + + local_evaluation_client = LocalEvaluationClient.new(api_key) + local_evaluation_client.start + + # Mock the amplitude client's track method + mock_amplitude = local_evaluation_client.instance_variable_get(:@exposure_service).instance_variable_get(:@amplitude) + tracked_events = [] + allow(mock_amplitude).to receive(:track) do |event| + tracked_events << event + end + + # Perform evaluation with tracks_exposure=true + options = EvaluateOptions.new(tracks_exposure: true) + variants = local_evaluation_client.evaluate_v2(test_user, ['sdk-local-evaluation-ci-test'], options) + + # Verify that track was called + expect(tracked_events.length).to be > 0, 'Amplitude track should be called when tracks_exposure is true' + + # Count non-default variants + non_default_variants = variants.reject do |_flag_key, variant| + (variant.metadata && variant.metadata['default']) + end + + # Verify that we have one event per non-default variant + expect(tracked_events.length).to eq(non_default_variants.length), + "Expected #{non_default_variants.length} exposure events, got #{tracked_events.length}" + + # Verify each event has the correct structure + tracked_flag_keys = Set.new + tracked_events.each do |event| + expect(event.event_type).to eq('[Experiment] Exposure') + expect(event.user_id).to eq(test_user.user_id) + flag_key = event.event_properties['[Experiment] Flag Key'] + expect(flag_key).not_to be_nil, 'Event should have flag key' + tracked_flag_keys.add(flag_key) + # Verify the variant is not default + variant = variants[flag_key] + expect(variant).not_to be_nil, "Variant for #{flag_key} should exist" + expect(variant.metadata && variant.metadata['default']).to be_falsy, + "Variant for #{flag_key} should not be default" + end + + # Verify all non-default variants were tracked + expect(tracked_flag_keys).to eq(Set.new(non_default_variants.keys)), + 'All non-default variants should be tracked' + end end end end From 7dd752935e231be6380b52326500478f0a836a24 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Thu, 13 Nov 2025 05:23:59 -0800 Subject: [PATCH 3/8] fix: check options --- lib/experiment/local/client.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/experiment/local/client.rb b/lib/experiment/local/client.rb index b99f619..513a1f9 100644 --- a/lib/experiment/local/client.rb +++ b/lib/experiment/local/client.rb @@ -84,7 +84,9 @@ def evaluate_v2(user, flag_keys = [], options = nil) result = @engine.evaluate(context, sorted_flags) @logger.debug("[Experiment] evaluate - result: #{result}") if @config.debug variants = AmplitudeExperiment.evaluation_variants_json_to_variants(result) - @exposure_service.track(Exposure.new(user, variants)) if options.tracks_exposure == true + if options.tracks_exposure == true + @exposure_service.track(Exposure.new(user, variants)) + end # @deprecated Assignment tracking is deprecated. Use ExposureService with Exposure tracking instead. @assignment_service&.track(Assignment.new(user, variants)) variants From 509a63dd9cecbe5041fb9efb1a8d46d415fde7cf Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Thu, 13 Nov 2025 05:25:45 -0800 Subject: [PATCH 4/8] fix: rubocop lint --- lib/experiment/local/client.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/experiment/local/client.rb b/lib/experiment/local/client.rb index 513a1f9..b99f619 100644 --- a/lib/experiment/local/client.rb +++ b/lib/experiment/local/client.rb @@ -84,9 +84,7 @@ def evaluate_v2(user, flag_keys = [], options = nil) result = @engine.evaluate(context, sorted_flags) @logger.debug("[Experiment] evaluate - result: #{result}") if @config.debug variants = AmplitudeExperiment.evaluation_variants_json_to_variants(result) - if options.tracks_exposure == true - @exposure_service.track(Exposure.new(user, variants)) - end + @exposure_service.track(Exposure.new(user, variants)) if options.tracks_exposure == true # @deprecated Assignment tracking is deprecated. Use ExposureService with Exposure tracking instead. @assignment_service&.track(Assignment.new(user, variants)) variants From 0f3642d08a8b620b93ff64d339d3c68345eee0cf Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Thu, 13 Nov 2025 10:08:13 -0800 Subject: [PATCH 5/8] fix: remove flags from options --- lib/experiment/local/client.rb | 9 ++------- lib/experiment/local/evaluate_options.rb | 5 ++--- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/lib/experiment/local/client.rb b/lib/experiment/local/client.rb index b99f619..0a5f57d 100644 --- a/lib/experiment/local/client.rb +++ b/lib/experiment/local/client.rb @@ -67,15 +67,10 @@ def evaluate(user, flag_keys = []) # @param [EvaluateOptions] options Optional evaluation options # @return [Hash[String, Variant]] The evaluated variants def evaluate_v2(user, flag_keys = [], options = nil) - # Handle backwards compatibility: if options is nil, create default - options = EvaluateOptions.new(flag_keys: flag_keys) if options.nil? - # Use flag_keys from options if provided, otherwise fall back to parameter - flag_keys_to_use = options.flag_keys || flag_keys - flags = @flag_config_storage.flag_configs return {} if flags.nil? - sorted_flags = TopologicalSort.sort(flags, flag_keys_to_use) + sorted_flags = TopologicalSort.sort(flags, flag_keys) required_cohorts_in_storage(sorted_flags) user = enrich_user_with_cohorts(user, flags) if @config.cohort_sync_config context = AmplitudeExperiment.user_to_evaluation_context(user) @@ -84,7 +79,7 @@ def evaluate_v2(user, flag_keys = [], options = nil) result = @engine.evaluate(context, sorted_flags) @logger.debug("[Experiment] evaluate - result: #{result}") if @config.debug variants = AmplitudeExperiment.evaluation_variants_json_to_variants(result) - @exposure_service.track(Exposure.new(user, variants)) if options.tracks_exposure == true + @exposure_service.track(Exposure.new(user, variants)) if options&.tracks_exposure == true # @deprecated Assignment tracking is deprecated. Use ExposureService with Exposure tracking instead. @assignment_service&.track(Assignment.new(user, variants)) variants diff --git a/lib/experiment/local/evaluate_options.rb b/lib/experiment/local/evaluate_options.rb index ea21a7d..de69b5f 100644 --- a/lib/experiment/local/evaluate_options.rb +++ b/lib/experiment/local/evaluate_options.rb @@ -1,10 +1,9 @@ module AmplitudeExperiment # Options for evaluating variants for a user. class EvaluateOptions - attr_accessor :flag_keys, :tracks_exposure + attr_accessor :tracks_exposure - def initialize(flag_keys: nil, tracks_exposure: nil) - @flag_keys = flag_keys + def initialize(tracks_exposure: nil) @tracks_exposure = tracks_exposure end end From 607a95772e0b2c390901171822fb27527a637f68 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Thu, 13 Nov 2025 15:07:00 -0800 Subject: [PATCH 6/8] fix: redundant code --- .../local/exposure/exposure_service.rb | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/lib/experiment/local/exposure/exposure_service.rb b/lib/experiment/local/exposure/exposure_service.rb index 899eb5e..4b04c66 100644 --- a/lib/experiment/local/exposure/exposure_service.rb +++ b/lib/experiment/local/exposure/exposure_service.rb @@ -33,9 +33,7 @@ def self.to_exposure_events(exposure, ttl_millis) unset_props = {} flag_type = variant.metadata['flagType'] if variant.metadata if flag_type != 'mutual-exclusion-group' - if is_default - unset_props["[Experiment] #{flag_key}"] = '-' - elsif variant.key + if variant.key set_props["[Experiment] #{flag_key}"] = variant.key elsif variant.value set_props["[Experiment] #{flag_key}"] = variant.value @@ -44,15 +42,13 @@ def self.to_exposure_events(exposure, ttl_millis) # Build event properties. event_properties = {} - unless is_default - event_properties['[Experiment] Flag Key'] = flag_key - if variant.key - event_properties['[Experiment] Variant'] = variant.key - elsif variant.value - event_properties['[Experiment] Variant'] = variant.value - end - event_properties['metadata'] = variant.metadata if variant.metadata + event_properties['[Experiment] Flag Key'] = flag_key + if variant.key + event_properties['[Experiment] Variant'] = variant.key + elsif variant.value + event_properties['[Experiment] Variant'] = variant.value end + event_properties['metadata'] = variant.metadata if variant.metadata # Build event. event = AmplitudeAnalytics::BaseEvent.new( From 804993c11baa7a0d01a240ba4b6da1d81a8d9a3e Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Mon, 24 Nov 2025 17:00:41 -0800 Subject: [PATCH 7/8] feat: require api key --- lib/experiment/local/client.rb | 7 +++---- lib/experiment/local/config.rb | 2 +- lib/experiment/local/exposure/exposure_service.rb | 1 - spec/experiment/local/client_spec.rb | 2 +- 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/lib/experiment/local/client.rb b/lib/experiment/local/client.rb index 0a5f57d..a6af428 100644 --- a/lib/experiment/local/client.rb +++ b/lib/experiment/local/client.rb @@ -25,9 +25,8 @@ def initialize(api_key, config = nil) @assignment_service = AssignmentService.new(AmplitudeAnalytics::Amplitude.new(config.assignment_config.api_key, configuration: config.assignment_config), AssignmentFilter.new(config.assignment_config.cache_capacity)) if config&.assignment_config # Exposure service is always instantiated, using deployment key if no api key provided - exposure_config = @config.exposure_config - exposure_config.api_key ||= @api_key - @exposure_service = ExposureService.new(AmplitudeAnalytics::Amplitude.new(exposure_config.api_key, configuration: exposure_config), ExposureFilter.new(exposure_config.cache_capacity)) + @exposure_service = nil + @exposure_service = ExposureService.new(AmplitudeAnalytics::Amplitude.new(config.exposure_config.api_key, configuration: config.exposure_config), ExposureFilter.new(config.exposure_config.cache_capacity)) if config&.exposure_config @cohort_storage = InMemoryCohortStorage.new @flag_config_storage = InMemoryFlagConfigStorage.new @@ -79,7 +78,7 @@ def evaluate_v2(user, flag_keys = [], options = nil) result = @engine.evaluate(context, sorted_flags) @logger.debug("[Experiment] evaluate - result: #{result}") if @config.debug variants = AmplitudeExperiment.evaluation_variants_json_to_variants(result) - @exposure_service.track(Exposure.new(user, variants)) if options&.tracks_exposure == true + @exposure_service&.track(Exposure.new(user, variants)) if options&.tracks_exposure == true # @deprecated Assignment tracking is deprecated. Use ExposureService with Exposure tracking instead. @assignment_service&.track(Assignment.new(user, variants)) variants diff --git a/lib/experiment/local/config.rb b/lib/experiment/local/config.rb index bcbcabd..ee569b5 100644 --- a/lib/experiment/local/config.rb +++ b/lib/experiment/local/config.rb @@ -80,7 +80,7 @@ def initialize(server_url: DEFAULT_SERVER_URL, @bootstrap = bootstrap @flag_config_polling_interval_millis = flag_config_polling_interval_millis @assignment_config = assignment_config - @exposure_config = exposure_config || ExposureConfig.new + @exposure_config = exposure_config end end end diff --git a/lib/experiment/local/exposure/exposure_service.rb b/lib/experiment/local/exposure/exposure_service.rb index 4b04c66..b0b86b2 100644 --- a/lib/experiment/local/exposure/exposure_service.rb +++ b/lib/experiment/local/exposure/exposure_service.rb @@ -20,7 +20,6 @@ def self.to_exposure_events(exposure, ttl_millis) events = [] canonicalized = exposure.canonicalize exposure.results.each do |flag_key, variant| - # TODO: We don't seem to use trackExposure metadata. track_exposure = variant.metadata ? variant.metadata.fetch('trackExposure', true) : true next unless track_exposure diff --git a/spec/experiment/local/client_spec.rb b/spec/experiment/local/client_spec.rb index 3a80d60..09ab0e4 100644 --- a/spec/experiment/local/client_spec.rb +++ b/spec/experiment/local/client_spec.rb @@ -114,7 +114,7 @@ def setup_stub it 'evaluate_v2 with tracks_exposure tracks non-default variants' do setup_stub - local_evaluation_client = LocalEvaluationClient.new(api_key) + local_evaluation_client = LocalEvaluationClient.new(api_key, LocalEvaluationConfig.new(exposure_config: ExposureConfig.new('api_key'))) local_evaluation_client.start # Mock the amplitude client's track method From 832261c2c0982356139b583966201013a213a8b7 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Wed, 10 Dec 2025 02:59:53 -0800 Subject: [PATCH 8/8] test: fix flag keys --- spec/experiment/local/client_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/experiment/local/client_spec.rb b/spec/experiment/local/client_spec.rb index 09ab0e4..05eacf0 100644 --- a/spec/experiment/local/client_spec.rb +++ b/spec/experiment/local/client_spec.rb @@ -126,7 +126,7 @@ def setup_stub # Perform evaluation with tracks_exposure=true options = EvaluateOptions.new(tracks_exposure: true) - variants = local_evaluation_client.evaluate_v2(test_user, ['sdk-local-evaluation-ci-test'], options) + variants = local_evaluation_client.evaluate_v2(test_user2, [], options) # Verify that track was called expect(tracked_events.length).to be > 0, 'Amplitude track should be called when tracks_exposure is true' @@ -144,7 +144,7 @@ def setup_stub tracked_flag_keys = Set.new tracked_events.each do |event| expect(event.event_type).to eq('[Experiment] Exposure') - expect(event.user_id).to eq(test_user.user_id) + expect(event.user_id).to eq(test_user2.user_id) flag_key = event.event_properties['[Experiment] Flag Key'] expect(flag_key).not_to be_nil, 'Event should have flag key' tracked_flag_keys.add(flag_key)