Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions lib/amplitude-experiment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
1 change: 1 addition & 0 deletions lib/experiment/local/assignment/assignment.rb
Original file line number Diff line number Diff line change
@@ -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

Expand Down
1 change: 1 addition & 0 deletions lib/experiment/local/assignment/assignment_config.rb
Original file line number Diff line number Diff line change
@@ -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

Expand Down
1 change: 1 addition & 0 deletions lib/experiment/local/assignment/assignment_filter.rb
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
1 change: 1 addition & 0 deletions lib/experiment/local/assignment/assignment_service.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down
10 changes: 9 additions & 1 deletion lib/experiment/local/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ 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_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
@flag_config_fetcher = LocalEvaluationFetcher.new(@api_key, @logger, @config.server_url)
Expand Down Expand Up @@ -51,15 +55,17 @@ 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
# variant object if the flag was evaluated but the user was not assigned (i.e. off).
#
# @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)
flags = @flag_config_storage.flag_configs
return {} if flags.nil?

Expand All @@ -72,6 +78,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
Expand Down
10 changes: 9 additions & 1 deletion lib/experiment/local/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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?
Expand All @@ -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
end
end
end
10 changes: 10 additions & 0 deletions lib/experiment/local/evaluate_options.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
module AmplitudeExperiment
# Options for evaluating variants for a user.
class EvaluateOptions
attr_accessor :tracks_exposure

def initialize(tracks_exposure: nil)
@tracks_exposure = tracks_exposure
end
end
end
22 changes: 22 additions & 0 deletions lib/experiment/local/exposure/exposure.rb
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions lib/experiment/local/exposure/exposure_config.rb
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions lib/experiment/local/exposure/exposure_filter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
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
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
71 changes: 71 additions & 0 deletions lib/experiment/local/exposure/exposure_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
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|
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 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 = {}
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(
'[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
50 changes: 50 additions & 0 deletions spec/experiment/local/client_spec.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
require 'set'
module AmplitudeExperiment
describe LocalEvaluationClient do
let(:api_key) { 'client-DvWljIjiiuqLbyjqdvBaLFfEBrAvGuA3' }
Expand Down Expand Up @@ -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, LocalEvaluationConfig.new(exposure_config: ExposureConfig.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_user2, [], 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_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)
# 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
Loading