Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
34 changes: 18 additions & 16 deletions lib/optimizely/bucketer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ module Optimizely
class Bucketer
# Optimizely bucketing algorithm that evenly distributes visitors.

BUCKETING_ID_TEMPLATE = '%{user_id}%{entity_id}'
BUCKETING_ID_TEMPLATE = '%{bucketing_id}%{entity_id}'
HASH_SEED = 1
MAX_HASH_VALUE = 2**32
MAX_TRAFFIC_VALUE = 10_000
Expand All @@ -35,13 +35,15 @@ def initialize(config)
@config = config
end

def bucket(experiment, user_id)
def bucket(experiment, bucketing_id, user_id)
# Determines ID of variation to be shown for a given experiment key and user ID.
#
# experiment - Experiment for which visitor is to be bucketed.
# bucketing_id - String A customer-assigned value used to generate bucketing key
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the bucketing key

# user_id - String ID for user.
#
# Returns variation in which visitor with ID user_id has been placed. Nil if no variation.
return nil if experiment.nil?

# check if experiment is in a group; if so, check if user is bucketed into specified experiment
experiment_id = experiment['id']
Expand All @@ -51,7 +53,7 @@ def bucket(experiment, user_id)
group = @config.group_key_map.fetch(group_id)
if Helpers::Group.random_policy?(group)
traffic_allocations = group.fetch('trafficAllocation')
bucketed_experiment_id = find_bucket(user_id, group_id, traffic_allocations)
bucketed_experiment_id = find_bucket(bucketing_id, user_id, group_id, traffic_allocations)
# return if the user is not bucketed into any experiment
unless bucketed_experiment_id
@config.logger.log(Logger::INFO, "User '#{user_id}' is in no experiment.")
Expand All @@ -76,7 +78,7 @@ def bucket(experiment, user_id)
end

traffic_allocations = experiment['trafficAllocation']
variation_id = find_bucket(user_id, experiment_id, traffic_allocations)
variation_id = find_bucket(bucketing_id, user_id, experiment_id, traffic_allocations)
if variation_id && variation_id != ''
variation = @config.get_variation_from_id(experiment_key, variation_id)
variation_key = variation ? variation['key'] : nil
Expand All @@ -96,18 +98,18 @@ def bucket(experiment, user_id)
nil
end

def find_bucket(user_id, parent_id, traffic_allocations)
def find_bucket(bucketing_id, user_id, parent_id, traffic_allocations)
# Helper function to find the matching entity ID for a given bucketing value in a list of traffic allocations.
#
# bucketing_id - String A customer-assigned value user to generate bucketing key
# user_id - String ID for user
# parent_id - String entity ID to use for bucketing ID
# traffic_allocations - Array of traffic allocations
#
# Returns entity ID corresponding to the provided bucket value or nil if no match is found.

bucketing_id = sprintf(BUCKETING_ID_TEMPLATE, user_id: user_id, entity_id: parent_id)
bucket_value = generate_bucket_value(bucketing_id)
@config.logger.log(Logger::DEBUG, "Assigned bucket #{bucket_value} to user '#{user_id}'.")
bucketing_key = sprintf(BUCKETING_ID_TEMPLATE, bucketing_id: bucketing_id, entity_id: parent_id)
bucket_value = generate_bucket_value(bucketing_key)
@config.logger.log(Logger::DEBUG, "Assigned bucket #{bucket_value} to user '#{user_id}' with bucketing ID: '#{bucketing_id}'.")

traffic_allocations.each do |traffic_allocation|
current_end_of_range = traffic_allocation['endOfRange']
Expand All @@ -122,25 +124,25 @@ def find_bucket(user_id, parent_id, traffic_allocations)

private

def generate_bucket_value(bucketing_id)
def generate_bucket_value(bucketing_key)
# Helper function to generate bucket value in half-closed interval [0, MAX_TRAFFIC_VALUE).
#
# bucketing_id - String ID for bucketing.
# bucketing_key - String - Value used to generate bucket value
#
# Returns bucket value corresponding to the provided bucketing ID.
# Returns bucket value corresponding to the provided bucketing key.

ratio = (generate_unsigned_hash_code_32_bit(bucketing_id)).to_f / MAX_HASH_VALUE
ratio = (generate_unsigned_hash_code_32_bit(bucketing_key)).to_f / MAX_HASH_VALUE
(ratio * MAX_TRAFFIC_VALUE).to_i
end

def generate_unsigned_hash_code_32_bit(bucketing_id)
def generate_unsigned_hash_code_32_bit(bucketing_key)
# Helper function to retreive hash code
#
# bucketing_id - String ID for bucketing.
# bucketing_key - String - Value used for the key of the murmur hash
#
# Returns hash code which is a 32 bit unsigned integer.

MurmurHash3::V32.str_hash(bucketing_id, @bucket_seed) & UNSIGNED_MAX_32_BIT_VALUE
MurmurHash3::V32.str_hash(bucketing_key, @bucket_seed) & UNSIGNED_MAX_32_BIT_VALUE
end
end
end
16 changes: 15 additions & 1 deletion lib/optimizely/decision_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
require_relative './bucketer'

module Optimizely

RESERVED_ATTRIBUTE_KEY_BUCKETING_ID = "\$opt_bucketing_id".freeze

class DecisionService
# Optimizely's decision service that determines into which variation of an experiment a user will be allocated.
#
Expand Down Expand Up @@ -47,6 +50,17 @@ def get_variation(experiment_key, user_id, attributes = nil)
#
# Returns variation ID where visitor will be bucketed (nil if experiment is inactive or user does not meet audience conditions)

# By default, the bucketing ID should be the user ID
bucketing_id = user_id;

# If the bucketing ID key is defined in attributes, then use that in place of the userID
if attributes and attributes[RESERVED_ATTRIBUTE_KEY_BUCKETING_ID].is_a? String
unless attributes[RESERVED_ATTRIBUTE_KEY_BUCKETING_ID].empty?
bucketing_id = attributes[RESERVED_ATTRIBUTE_KEY_BUCKETING_ID]
@config.logger.log(Logger::DEBUG, "Setting the bucketing ID '#{bucketing_id}'")
end
end

# Check to make sure experiment is active
experiment = @config.get_experiment_from_key(experiment_key)
if experiment.nil?
Expand Down Expand Up @@ -88,7 +102,7 @@ def get_variation(experiment_key, user_id, attributes = nil)
end

# Bucket normally
variation = @bucketer.bucket(experiment, user_id)
variation = @bucketer.bucket(experiment, bucketing_id, user_id)
variation_id = variation ? variation['id'] : nil

# Persist bucketing decision
Expand Down
42 changes: 28 additions & 14 deletions lib/optimizely/event_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
require 'securerandom'

module Optimizely

RESERVED_ATTRIBUTE_KEY_BUCKETING_ID_EVENT_PARAM_KEY = "optimizely_bucketing_id".freeze

class Event
# Representation of an event which can be sent to the Optimizely logging endpoint.

Expand Down Expand Up @@ -69,22 +72,33 @@ def get_common_params(user_id, attributes)
attribute_value = attributes[attribute_key]
next if attribute_value.nil?

# Skip attributes not in the datafile
attribute_id = @config.get_attribute_id(attribute_key)
next unless attribute_id

feature = {
entity_id: attribute_id,
key: attribute_key,
type: CUSTOM_ATTRIBUTE_FEATURE_TYPE,
value: attribute_value
}
if attribute_key.eql? RESERVED_ATTRIBUTE_KEY_BUCKETING_ID
# TODO (Copied from PHP-SDK) (Alda): the type for bucketing ID attribute may change so
# that custom attributes are not overloaded
feature = {
entity_id: RESERVED_ATTRIBUTE_KEY_BUCKETING_ID,
key: RESERVED_ATTRIBUTE_KEY_BUCKETING_ID_EVENT_PARAM_KEY,
type: CUSTOM_ATTRIBUTE_FEATURE_TYPE,
value: attribute_value
}
else
# Skip attributes not in the datafile
attribute_id = @config.get_attribute_id(attribute_key)
next unless attribute_id

feature = {
entity_id: attribute_id,
key: attribute_key,
type: CUSTOM_ATTRIBUTE_FEATURE_TYPE,
value: attribute_value
}

visitor_attributes.push(feature)
end
end
visitor_attributes.push(feature)
end
end

common_params = {
common_params = {
account_id: @config.account_id,
project_id: @config.project_id,
visitors: [
Expand All @@ -98,7 +112,7 @@ def get_common_params(user_id, attributes)
revision: @config.revision,
client_name: CLIENT_ENGINE,
client_version: VERSION
}
}

common_params
end
Expand Down
Loading