class Optimizely::Bucketer

Constants

BUCKETING_ID_TEMPLATE

Optimizely bucketing algorithm that evenly distributes visitors.

HASH_SEED
MAX_HASH_VALUE
MAX_TRAFFIC_VALUE
UNSIGNED_MAX_32_BIT_VALUE

Public Class Methods

new(logger) click to toggle source
# File lib/optimizely/bucketer.rb, line 31
def initialize(logger)
  # Bucketer init method to set bucketing seed and logger.
  # logger - Optional component which provides a log method to log messages.
  @logger = logger
  @bucket_seed = HASH_SEED
end

Public Instance Methods

bucket(project_config, experiment, bucketing_id, user_id) click to toggle source
# File lib/optimizely/bucketer.rb, line 38
def bucket(project_config, experiment, bucketing_id, user_id)
  # Determines ID of variation to be shown for a given experiment key and user ID.
  #
  # project_config - Instance of ProjectConfig
  # experiment - Experiment or Rollout rule for which visitor is to be bucketed.
  # bucketing_id - String A customer-assigned value used to generate 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?

  decide_reasons = []

  # check if experiment is in a group; if so, check if user is bucketed into specified experiment
  # this will not affect evaluation of rollout rules.
  experiment_id = experiment['id']
  experiment_key = experiment['key']
  group_id = experiment['groupId']
  if group_id
    group = project_config.group_id_map.fetch(group_id)
    if Helpers::Group.random_policy?(group)
      traffic_allocations = group.fetch('trafficAllocation')
      bucketed_experiment_id, find_bucket_reasons = find_bucket(bucketing_id, user_id, group_id, traffic_allocations)
      decide_reasons.push(*find_bucket_reasons)

      # return if the user is not bucketed into any experiment
      unless bucketed_experiment_id
        message = "User '#{user_id}' is in no experiment."
        @logger.log(Logger::INFO, message)
        decide_reasons.push(message)
        return nil, decide_reasons
      end

      # return if the user is bucketed into a different experiment than the one specified
      if bucketed_experiment_id != experiment_id
        message = "User '#{user_id}' is not in experiment '#{experiment_key}' of group #{group_id}."
        @logger.log(Logger::INFO, message)
        decide_reasons.push(message)
        return nil, decide_reasons
      end

      # continue bucketing if the user is bucketed into the experiment specified
      message = "User '#{user_id}' is in experiment '#{experiment_key}' of group #{group_id}."
      @logger.log(Logger::INFO, message)
      decide_reasons.push(message)
    end
  end

  traffic_allocations = experiment['trafficAllocation']
  variation_id, find_bucket_reasons = find_bucket(bucketing_id, user_id, experiment_id, traffic_allocations)
  decide_reasons.push(*find_bucket_reasons)

  if variation_id && variation_id != ''
    variation = project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id)
    return variation, decide_reasons
  end

  # Handle the case when the traffic range is empty due to sticky bucketing
  if variation_id == ''
    message = 'Bucketed into an empty traffic range. Returning nil.'
    @logger.log(Logger::DEBUG, message)
    decide_reasons.push(message)
  end

  [nil, decide_reasons]
end
find_bucket(bucketing_id, user_id, parent_id, traffic_allocations) click to toggle source
# File lib/optimizely/bucketer.rb, line 105
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 and array of two values where first value is the entity ID corresponding to the provided bucket value
  # or nil if no match is found. The second value contains the array of reasons stating how the deicision was taken
  decide_reasons = []
  bucketing_key = format(BUCKETING_ID_TEMPLATE, bucketing_id: bucketing_id, entity_id: parent_id)
  bucket_value = generate_bucket_value(bucketing_key)

  message = "Assigned bucket #{bucket_value} to user '#{user_id}' with bucketing ID: '#{bucketing_id}'."
  @logger.log(Logger::DEBUG, message)

  traffic_allocations.each do |traffic_allocation|
    current_end_of_range = traffic_allocation['endOfRange']
    if bucket_value < current_end_of_range
      entity_id = traffic_allocation['entityId']
      return entity_id, decide_reasons
    end
  end

  [nil, decide_reasons]
end

Private Instance Methods

generate_bucket_value(bucketing_key) click to toggle source
# File lib/optimizely/bucketer.rb, line 135
def generate_bucket_value(bucketing_key)
  # Helper function to generate bucket value in half-closed interval [0, MAX_TRAFFIC_VALUE).
  #
  # bucketing_key - String - Value used to generate bucket value
  #
  # Returns bucket value corresponding to the provided bucketing key.

  ratio = generate_unsigned_hash_code_32_bit(bucketing_key).to_f / MAX_HASH_VALUE
  (ratio * MAX_TRAFFIC_VALUE).to_i
end
generate_unsigned_hash_code_32_bit(bucketing_key) click to toggle source
# File lib/optimizely/bucketer.rb, line 146
def generate_unsigned_hash_code_32_bit(bucketing_key)
  # Helper function to retreive hash code
  #
  # 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_key, @bucket_seed) & UNSIGNED_MAX_32_BIT_VALUE
end