Skip to content

Commit 2dcc5ea

Browse files
Introduce decision service (#54)
1 parent e65eb04 commit 2dcc5ea

File tree

11 files changed

+669
-171
lines changed

11 files changed

+669
-171
lines changed

optimizely/bucketer.py

Lines changed: 1 addition & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2016, Optimizely
1+
# Copyright 2016-2017, Optimizely
22
# Licensed under the Apache License, Version 2.0 (the "License");
33
# you may not use this file except in compliance with the License.
44
# You may obtain a copy of the License at
@@ -133,25 +133,3 @@ def bucket(self, experiment, user_id):
133133

134134
self.config.logger.log(enums.LogLevels.INFO, 'User "%s" is in no variation.' % user_id)
135135
return None
136-
137-
def get_forced_variation(self, experiment, user_id):
138-
""" Determine if a user is forced into a variation for the given experiment and return that variation.
139-
140-
Args:
141-
experiment: Object representing the experiment for which user is to be bucketed.
142-
user_id: ID for the user.
143-
144-
Returns:
145-
Variation in which the user with ID user_id is forced into. None if no variation.
146-
"""
147-
148-
forced_variations = experiment.forcedVariations
149-
if forced_variations and user_id in forced_variations:
150-
variation_key = forced_variations.get(user_id)
151-
variation = self.config.get_variation_from_key(experiment.key, variation_key)
152-
if variation:
153-
self.config.logger.log(enums.LogLevels.INFO,
154-
'User "%s" is forced in variation "%s".' % (user_id, variation_key))
155-
return variation
156-
157-
return None

optimizely/decision_service.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
# Copyright 2017, Optimizely
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
13+
14+
import sys
15+
16+
from . import bucketer
17+
from .helpers import audience as audience_helper
18+
from .helpers import enums
19+
from .helpers import experiment as experiment_helper
20+
from .helpers import validator
21+
from .user_profile import UserProfile
22+
23+
24+
class DecisionService(object):
25+
""" Class encapsulating all decision related capabilities. """
26+
27+
def __init__(self, config, user_profile_service):
28+
self.bucketer = bucketer.Bucketer(config)
29+
self.user_profile_service = user_profile_service
30+
self.config = config
31+
self.logger = config.logger
32+
33+
def get_forced_variation(self, experiment, user_id):
34+
""" Determine if a user is forced into a variation for the given experiment and return that variation.
35+
36+
Args:
37+
experiment: Object representing the experiment for which user is to be bucketed.
38+
user_id: ID for the user.
39+
40+
Returns:
41+
Variation in which the user with ID user_id is forced into. None if no variation.
42+
"""
43+
44+
forced_variations = experiment.forcedVariations
45+
if forced_variations and user_id in forced_variations:
46+
variation_key = forced_variations.get(user_id)
47+
variation = self.config.get_variation_from_key(experiment.key, variation_key)
48+
if variation:
49+
self.config.logger.log(enums.LogLevels.INFO,
50+
'User "%s" is forced in variation "%s".' % (user_id, variation_key))
51+
return variation
52+
53+
return None
54+
55+
def get_stored_variation(self, experiment, user_profile):
56+
""" Determine if the user has a stored variation available for the given experiment and return that.
57+
58+
Args:
59+
experiment: Object representing the experiment for which user is to be bucketed.
60+
user_profile: UserProfile object representing the user's profile.
61+
62+
Returns:
63+
Variation if available. None otherwise.
64+
"""
65+
66+
user_id = user_profile.user_id
67+
variation_id = user_profile.get_variation_for_experiment(experiment.id)
68+
69+
if variation_id:
70+
variation = self.config.get_variation_from_id(experiment.key, variation_id)
71+
if variation:
72+
self.config.logger.log(enums.LogLevels.INFO,
73+
'Found a stored decision. User "%s" is in variation "%s" of experiment "%s".' %
74+
(user_id, variation.key, experiment.key))
75+
return variation
76+
77+
return None
78+
79+
def get_variation(self, experiment, user_id, attributes):
80+
""" Top-level function to help determine variation user should be put in.
81+
82+
First, check if experiment is running.
83+
Second, check if user is forced in a variation.
84+
Third, check if there is a stored decision for the user and return the corresponding variation.
85+
Fourth, figure out if user is in the experiment by evaluating audience conditions if any.
86+
Fifth, bucket the user and return the variation.
87+
88+
Args:
89+
experiment_key: Experiment for which user variation needs to be determined.
90+
user_id: ID for user.
91+
attributes: Dict representing user attributes.
92+
93+
Returns:
94+
Variation user should see. None if user is not in experiment or experiment is not running.
95+
"""
96+
97+
# Check if experiment is running
98+
if not experiment_helper.is_experiment_running(experiment):
99+
self.logger.log(enums.LogLevels.INFO, 'Experiment "%s" is not running.' % experiment.key)
100+
return None
101+
102+
# Check to see if user is white-listed for a certain variation
103+
variation = self.get_forced_variation(experiment, user_id)
104+
if variation:
105+
return variation
106+
107+
# Check to see if user has a decision available for the given experiment
108+
user_profile = UserProfile(user_id)
109+
if self.user_profile_service:
110+
try:
111+
retrieved_profile = self.user_profile_service.lookup(user_id)
112+
except:
113+
error = sys.exc_info()[1]
114+
self.logger.log(
115+
enums.LogLevels.ERROR,
116+
'Unable to retrieve user profile for user "%s" as lookup failed. Error: %s' % (user_id, str(error))
117+
)
118+
retrieved_profile = None
119+
120+
if validator.is_user_profile_valid(retrieved_profile):
121+
user_profile = UserProfile(**retrieved_profile)
122+
variation = self.get_stored_variation(experiment, user_profile)
123+
if variation:
124+
return variation
125+
else:
126+
self.logger.log(enums.LogLevels.WARNING, 'User profile has invalid format.')
127+
128+
# Bucket user and store the new decision
129+
if not audience_helper.is_user_in_experiment(self.config, experiment, attributes):
130+
self.logger.log(
131+
enums.LogLevels.INFO,
132+
'User "%s" does not meet conditions to be in experiment "%s".' % (user_id, experiment.key)
133+
)
134+
return None
135+
136+
variation = self.bucketer.bucket(experiment, user_id)
137+
138+
if variation:
139+
# Store this new decision and return the variation for the user
140+
if self.user_profile_service:
141+
try:
142+
user_profile.save_variation_for_experiment(experiment.id, variation.id)
143+
self.user_profile_service.save(user_profile.__dict__)
144+
except:
145+
error = sys.exc_info()[1]
146+
self.logger.log(enums.LogLevels.ERROR,
147+
'Unable to save user profile for user "%s". Error: %s' % (user_id, str(error)))
148+
return variation
149+
150+
return None

optimizely/event_builder.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,8 @@ def __init__(self, url, params, http_verb=None, headers=None):
3232
class BaseEventBuilder(object):
3333
""" Base class which encapsulates methods to build events for tracking impressions and conversions. """
3434

35-
def __init__(self, config, bucketer):
35+
def __init__(self, config):
3636
self.config = config
37-
self.bucketer = bucketer
3837
self.params = {}
3938

4039
@abstractproperty
@@ -183,14 +182,13 @@ def _add_required_params_for_impression(self, experiment, variation_id):
183182
self.EventParams.IS_LAYER_HOLDBACK: False
184183
}
185184

186-
def _add_required_params_for_conversion(self, event_key, user_id, event_tags, valid_experiments):
185+
def _add_required_params_for_conversion(self, event_key, event_tags, decisions):
187186
""" Add parameters that are required for the conversion event to register.
188187
189188
Args:
190189
event_key: Key representing the event which needs to be recorded.
191-
user_id: ID for user.
192190
event_tags: Dict representing metadata associated with the event.
193-
valid_experiments: List of tuples representing valid experiments IDs and variation IDs.
191+
decisions: List of tuples representing valid experiments IDs and variation IDs.
194192
"""
195193

196194
self.params[self.EventParams.IS_GLOBAL_HOLDBACK] = False
@@ -219,7 +217,7 @@ def _add_required_params_for_conversion(self, event_key, user_id, event_tags, va
219217
self.params[self.EventParams.EVENT_FEATURES].append(event_feature)
220218

221219
self.params[self.EventParams.LAYER_STATES] = []
222-
for experiment_id, variation_id in valid_experiments:
220+
for experiment_id, variation_id in decisions:
223221
experiment = self.config.get_experiment_from_id(experiment_id)
224222
self.params[self.EventParams.LAYER_STATES].append({
225223
self.EventParams.LAYER_ID: experiment.layerId,
@@ -256,23 +254,23 @@ def create_impression_event(self, experiment, variation_id, user_id, attributes)
256254
http_verb=self.HTTP_VERB,
257255
headers=self.HTTP_HEADERS)
258256

259-
def create_conversion_event(self, event_key, user_id, attributes, event_tags, valid_experiments):
257+
def create_conversion_event(self, event_key, user_id, attributes, event_tags, decisions):
260258
""" Create conversion Event to be sent to the logging endpoint.
261259
262260
Args:
263261
event_key: Key representing the event which needs to be recorded.
264262
user_id: ID for user.
265263
attributes: Dict representing user attributes and values.
266264
event_tags: Dict representing metadata associated with the event.
267-
valid_experiments: List of tuples representing experiments IDs and variation IDs.
265+
decisions: List of tuples representing experiments IDs and variation IDs.
268266
269267
Returns:
270268
Event object encapsulating the conversion event.
271269
"""
272270

273271
self.params = {}
274272
self._add_common_params(user_id, attributes)
275-
self._add_required_params_for_conversion(event_key, user_id, event_tags, valid_experiments)
273+
self._add_required_params_for_conversion(event_key, event_tags, decisions)
276274
return Event(self.CONVERSION_ENDPOINT,
277275
self.params,
278276
http_verb=self.HTTP_VERB,

optimizely/helpers/validator.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import json
1515
import jsonschema
1616

17+
from optimizely.user_profile import UserProfile
1718
from . import constants
1819

1920

@@ -117,3 +118,36 @@ def are_event_tags_valid(event_tags):
117118
"""
118119

119120
return type(event_tags) is dict
121+
122+
123+
def is_user_profile_valid(user_profile):
124+
""" Determine if provided user profile is valid or not.
125+
126+
Args:
127+
user_profile: User's profile which needs to be validated.
128+
129+
Returns:
130+
Boolean depending upon whether profile is valid or not.
131+
"""
132+
133+
if not user_profile:
134+
return False
135+
136+
if not type(user_profile) is dict:
137+
return False
138+
139+
if not UserProfile.USER_ID_KEY in user_profile:
140+
return False
141+
142+
if not UserProfile.EXPERIMENT_BUCKET_MAP_KEY in user_profile:
143+
return False
144+
145+
experiment_bucket_map = user_profile.get(UserProfile.EXPERIMENT_BUCKET_MAP_KEY)
146+
if not type(experiment_bucket_map) is dict:
147+
return False
148+
149+
for decision in experiment_bucket_map.values():
150+
if type(decision) is not dict or UserProfile.VARIATION_ID_KEY not in decision:
151+
return False
152+
153+
return True

0 commit comments

Comments
 (0)