Skip to content

Commit eb3d971

Browse files
Introduce bucketing ID in Python SDK (#92)
1 parent c98a162 commit eb3d971

File tree

5 files changed

+327
-92
lines changed

5 files changed

+327
-92
lines changed

optimizely/bucketer.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
UNSIGNED_MAX_32_BIT_VALUE = 0xFFFFFFFF
2424
MAX_HASH_VALUE = math.pow(2, 32)
2525
HASH_SEED = 1
26-
BUCKETING_ID_TEMPLATE = '{user_id}{parent_id}'
26+
BUCKETING_ID_TEMPLATE = '{bucketing_id}{parent_id}'
2727
GROUP_POLICIES = ['random']
2828

2929

@@ -66,21 +66,23 @@ def _generate_bucket_value(self, bucketing_id):
6666
ratio = float(self._generate_unsigned_hash_code_32_bit(bucketing_id)) / MAX_HASH_VALUE
6767
return math.floor(ratio * MAX_TRAFFIC_VALUE)
6868

69-
def find_bucket(self, user_id, parent_id, traffic_allocations):
69+
def find_bucket(self, bucketing_id, parent_id, traffic_allocations):
7070
""" Determine entity based on bucket value and traffic allocations.
7171
7272
Args:
73-
user_id: ID for user.
73+
bucketing_id: ID to be used for bucketing the user.
7474
parent_id: ID representing group or experiment.
7575
traffic_allocations: Traffic allocations representing traffic allotted to experiments or variations.
7676
7777
Returns:
7878
Entity ID which may represent experiment or variation.
7979
"""
8080

81-
bucketing_id = BUCKETING_ID_TEMPLATE.format(user_id=user_id, parent_id=parent_id)
82-
bucketing_number = self._generate_bucket_value(bucketing_id)
83-
self.config.logger.log(enums.LogLevels.DEBUG, 'Assigned bucket %s to user "%s".' % (bucketing_number, user_id))
81+
bucketing_key = BUCKETING_ID_TEMPLATE.format(bucketing_id=bucketing_id, parent_id=parent_id)
82+
bucketing_number = self._generate_bucket_value(bucketing_key)
83+
self.config.logger.log(enums.LogLevels.DEBUG,
84+
'Assigned bucket %s to user with bucketing ID "%s".' % (bucketing_number,
85+
bucketing_id))
8486

8587
for traffic_allocation in traffic_allocations:
8688
current_end_of_range = traffic_allocation.get('endOfRange')
@@ -89,12 +91,13 @@ def find_bucket(self, user_id, parent_id, traffic_allocations):
8991

9092
return None
9193

92-
def bucket(self, experiment, user_id):
94+
def bucket(self, experiment, user_id, bucketing_id):
9395
""" For a given experiment and bucketing ID determines variation to be shown to user.
9496
9597
Args:
9698
experiment: Object representing the experiment for which user is to be bucketed.
9799
user_id: ID for user.
100+
bucketing_id: ID to be used for bucketing the user.
98101
99102
Returns:
100103
Variation in which user with ID user_id will be put in. None if no variation.
@@ -110,7 +113,7 @@ def bucket(self, experiment, user_id):
110113
if not group:
111114
return None
112115

113-
user_experiment_id = self.find_bucket(user_id, experiment.groupId, group.trafficAllocation)
116+
user_experiment_id = self.find_bucket(bucketing_id, experiment.groupId, group.trafficAllocation)
114117
if not user_experiment_id:
115118
self.config.logger.log(enums.LogLevels.INFO, 'User "%s" is in no experiment.' % user_id)
116119
return None
@@ -124,7 +127,7 @@ def bucket(self, experiment, user_id):
124127
(user_id, experiment.key, experiment.groupId))
125128

126129
# Bucket user if not in white-list and in group (if any)
127-
variation_id = self.find_bucket(user_id, experiment.id, experiment.trafficAllocation)
130+
variation_id = self.find_bucket(bucketing_id, experiment.id, experiment.trafficAllocation)
128131
if variation_id:
129132
variation = self.config.get_variation_from_id(experiment.key, variation_id)
130133
self.config.logger.log(enums.LogLevels.INFO, 'User "%s" is in variation "%s" of experiment %s.' %

optimizely/decision_service.py

Lines changed: 53 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
Decision = namedtuple('Decision', 'experiment variation source')
2626
DECISION_SOURCE_EXPERIMENT = 'experiment'
2727
DECISION_SOURCE_ROLLOUT = 'rollout'
28+
RESERVED_BUCKETING_ID_ATTRIBUTE = '$opt_bucketing_id'
2829

2930

3031
class DecisionService(object):
@@ -36,6 +37,21 @@ def __init__(self, config, user_profile_service):
3637
self.config = config
3738
self.logger = config.logger
3839

40+
@staticmethod
41+
def _get_bucketing_id(user_id, attributes):
42+
""" Helper method to determine bucketing ID for the user.
43+
44+
Args:
45+
user_id: ID for user.
46+
attributes: Dict representing user attributes. May consist of bucketing ID to be used.
47+
48+
Returns:
49+
String representing bucketing ID for the user. Fallback to user's ID if not provided.
50+
"""
51+
52+
attributes = attributes or {}
53+
return attributes.get(RESERVED_BUCKETING_ID_ATTRIBUTE, user_id)
54+
3955
def get_forced_variation(self, experiment, user_id):
4056
""" Determine if a user is forced into a variation for the given experiment and return that variation.
4157
@@ -145,7 +161,9 @@ def get_variation(self, experiment, user_id, attributes, ignore_user_profile=Fal
145161
)
146162
return None
147163

148-
variation = self.bucketer.bucket(experiment, user_id)
164+
# Determine bucketing ID to be used
165+
bucketing_id = self._get_bucketing_id(user_id, attributes)
166+
variation = self.bucketer.bucket(experiment, user_id, bucketing_id)
149167

150168
if variation:
151169
# Store this new decision and return the variation for the user
@@ -188,7 +206,9 @@ def get_variation_for_rollout(self, rollout, user_id, attributes=None):
188206
continue
189207

190208
self.logger.log(enums.LogLevels.DEBUG, 'User "%s" meets conditions for targeting rule %s.' % (user_id, idx + 1))
191-
variation = self.bucketer.bucket(experiment, user_id)
209+
# Determine bucketing ID to be used
210+
bucketing_id = self._get_bucketing_id(user_id, attributes)
211+
variation = self.bucketer.bucket(experiment, user_id, bucketing_id)
192212
if variation:
193213
self.logger.log(enums.LogLevels.DEBUG,
194214
'User "%s" is in variation %s of experiment %s.' % (user_id, variation.key, experiment.key))
@@ -205,14 +225,42 @@ def get_variation_for_rollout(self, rollout, user_id, attributes=None):
205225
if audience_helper.is_user_in_experiment(self.config,
206226
self.config.get_experiment_from_key(rollout.experiments[-1].get('key')),
207227
attributes):
208-
variation = self.bucketer.bucket(everyone_else_experiment, user_id)
228+
# Determine bucketing ID to be used
229+
bucketing_id = self._get_bucketing_id(user_id, attributes)
230+
variation = self.bucketer.bucket(everyone_else_experiment, user_id, bucketing_id)
209231
if variation:
210232
self.logger.log(enums.LogLevels.DEBUG,
211233
'User "%s" meets conditions for targeting rule "Everyone Else".' % user_id)
212234
return Decision(everyone_else_experiment, variation, DECISION_SOURCE_ROLLOUT)
213235

214236
return Decision(None, None, DECISION_SOURCE_ROLLOUT)
215237

238+
def get_experiment_in_group(self, group, bucketing_id):
239+
""" Determine which experiment in the group the user is bucketed into.
240+
241+
Args:
242+
group: The group to bucket the user into.
243+
bucketing_id: ID to be used for bucketing the user.
244+
245+
Returns:
246+
Experiment if the user is bucketed into an experiment in the specified group. None otherwise.
247+
"""
248+
249+
experiment_id = self.bucketer.find_bucket(bucketing_id, group.id, group.trafficAllocation)
250+
if experiment_id:
251+
experiment = self.config.get_experiment_from_id(experiment_id)
252+
if experiment:
253+
self.logger.log(enums.LogLevels.INFO,
254+
'User with bucketing ID "%s" is in experiment %s of group %s.' %
255+
(bucketing_id, experiment.key, group.id))
256+
return experiment
257+
258+
self.logger.log(enums.LogLevels.INFO,
259+
'User with bucketing ID "%s" is not in any experiments of group %s.' %
260+
(bucketing_id, group.id))
261+
262+
return None
263+
216264
def get_variation_for_feature(self, feature, user_id, attributes=None):
217265
""" Returns the experiment/variation the user is bucketed in for the given feature.
218266
@@ -227,12 +275,13 @@ def get_variation_for_feature(self, feature, user_id, attributes=None):
227275

228276
experiment = None
229277
variation = None
278+
bucketing_id = self._get_bucketing_id(user_id, attributes)
230279

231280
# First check if the feature is in a mutex group
232281
if feature.groupId:
233282
group = self.config.get_group(feature.groupId)
234283
if group:
235-
experiment = self.get_experiment_in_group(group, user_id)
284+
experiment = self.get_experiment_in_group(group, bucketing_id)
236285
if experiment and experiment.id in feature.experimentIds:
237286
variation = self.get_variation(experiment, user_id, attributes)
238287

@@ -259,29 +308,3 @@ def get_variation_for_feature(self, feature, user_id, attributes=None):
259308
return self.get_variation_for_rollout(rollout, user_id, attributes)
260309

261310
return Decision(experiment, variation, DECISION_SOURCE_EXPERIMENT)
262-
263-
def get_experiment_in_group(self, group, user_id):
264-
""" Determine which experiment in the group the user is bucketed into.
265-
266-
Args:
267-
group: The group to bucket the user into.
268-
user_id: ID of the user.
269-
270-
Returns:
271-
Experiment if the user is bucketed into an experiment in the specified group. None otherwise.
272-
"""
273-
274-
experiment_id = self.bucketer.find_bucket(user_id, group.id, group.trafficAllocation)
275-
if experiment_id:
276-
experiment = self.config.get_experiment_from_id(experiment_id)
277-
if experiment:
278-
self.logger.log(enums.LogLevels.INFO,
279-
'User "%s" is in experiment %s of group %s.' %
280-
(user_id, experiment.key, group.id))
281-
return experiment
282-
283-
self.logger.log(enums.LogLevels.INFO,
284-
'User "%s" is not in any experiments of group %s.' %
285-
(user_id, group.id))
286-
287-
return None

0 commit comments

Comments
 (0)