Skip to content

Commit

Permalink
Introduce decision service (#54)
Browse files Browse the repository at this point in the history
  • Loading branch information
aliabbasrizvi committed May 23, 2017
1 parent e65eb04 commit 2dcc5ea
Show file tree
Hide file tree
Showing 11 changed files with 669 additions and 171 deletions.
24 changes: 1 addition & 23 deletions optimizely/bucketer.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright 2016, Optimizely
# Copyright 2016-2017, Optimizely
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
Expand Down Expand Up @@ -133,25 +133,3 @@ def bucket(self, experiment, user_id):

self.config.logger.log(enums.LogLevels.INFO, 'User "%s" is in no variation.' % user_id)
return None

def get_forced_variation(self, experiment, user_id):
""" Determine if a user is forced into a variation for the given experiment and return that variation.
Args:
experiment: Object representing the experiment for which user is to be bucketed.
user_id: ID for the user.
Returns:
Variation in which the user with ID user_id is forced into. None if no variation.
"""

forced_variations = experiment.forcedVariations
if forced_variations and user_id in forced_variations:
variation_key = forced_variations.get(user_id)
variation = self.config.get_variation_from_key(experiment.key, variation_key)
if variation:
self.config.logger.log(enums.LogLevels.INFO,
'User "%s" is forced in variation "%s".' % (user_id, variation_key))
return variation

return None
150 changes: 150 additions & 0 deletions optimizely/decision_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# Copyright 2017, Optimizely
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0

# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import sys

from . import bucketer
from .helpers import audience as audience_helper
from .helpers import enums
from .helpers import experiment as experiment_helper
from .helpers import validator
from .user_profile import UserProfile


class DecisionService(object):
""" Class encapsulating all decision related capabilities. """

def __init__(self, config, user_profile_service):
self.bucketer = bucketer.Bucketer(config)
self.user_profile_service = user_profile_service
self.config = config
self.logger = config.logger

def get_forced_variation(self, experiment, user_id):
""" Determine if a user is forced into a variation for the given experiment and return that variation.
Args:
experiment: Object representing the experiment for which user is to be bucketed.
user_id: ID for the user.
Returns:
Variation in which the user with ID user_id is forced into. None if no variation.
"""

forced_variations = experiment.forcedVariations
if forced_variations and user_id in forced_variations:
variation_key = forced_variations.get(user_id)
variation = self.config.get_variation_from_key(experiment.key, variation_key)
if variation:
self.config.logger.log(enums.LogLevels.INFO,
'User "%s" is forced in variation "%s".' % (user_id, variation_key))
return variation

return None

def get_stored_variation(self, experiment, user_profile):
""" Determine if the user has a stored variation available for the given experiment and return that.
Args:
experiment: Object representing the experiment for which user is to be bucketed.
user_profile: UserProfile object representing the user's profile.
Returns:
Variation if available. None otherwise.
"""

user_id = user_profile.user_id
variation_id = user_profile.get_variation_for_experiment(experiment.id)

if variation_id:
variation = self.config.get_variation_from_id(experiment.key, variation_id)
if variation:
self.config.logger.log(enums.LogLevels.INFO,
'Found a stored decision. User "%s" is in variation "%s" of experiment "%s".' %
(user_id, variation.key, experiment.key))
return variation

return None

def get_variation(self, experiment, user_id, attributes):
""" Top-level function to help determine variation user should be put in.
First, check if experiment is running.
Second, check if user is forced in a variation.
Third, check if there is a stored decision for the user and return the corresponding variation.
Fourth, figure out if user is in the experiment by evaluating audience conditions if any.
Fifth, bucket the user and return the variation.
Args:
experiment_key: Experiment for which user variation needs to be determined.
user_id: ID for user.
attributes: Dict representing user attributes.
Returns:
Variation user should see. None if user is not in experiment or experiment is not running.
"""

# Check if experiment is running
if not experiment_helper.is_experiment_running(experiment):
self.logger.log(enums.LogLevels.INFO, 'Experiment "%s" is not running.' % experiment.key)
return None

# Check to see if user is white-listed for a certain variation
variation = self.get_forced_variation(experiment, user_id)
if variation:
return variation

# Check to see if user has a decision available for the given experiment
user_profile = UserProfile(user_id)
if self.user_profile_service:
try:
retrieved_profile = self.user_profile_service.lookup(user_id)
except:
error = sys.exc_info()[1]
self.logger.log(
enums.LogLevels.ERROR,
'Unable to retrieve user profile for user "%s" as lookup failed. Error: %s' % (user_id, str(error))
)
retrieved_profile = None

if validator.is_user_profile_valid(retrieved_profile):
user_profile = UserProfile(**retrieved_profile)
variation = self.get_stored_variation(experiment, user_profile)
if variation:
return variation
else:
self.logger.log(enums.LogLevels.WARNING, 'User profile has invalid format.')

# Bucket user and store the new decision
if not audience_helper.is_user_in_experiment(self.config, experiment, attributes):
self.logger.log(
enums.LogLevels.INFO,
'User "%s" does not meet conditions to be in experiment "%s".' % (user_id, experiment.key)
)
return None

variation = self.bucketer.bucket(experiment, user_id)

if variation:
# Store this new decision and return the variation for the user
if self.user_profile_service:
try:
user_profile.save_variation_for_experiment(experiment.id, variation.id)
self.user_profile_service.save(user_profile.__dict__)
except:
error = sys.exc_info()[1]
self.logger.log(enums.LogLevels.ERROR,
'Unable to save user profile for user "%s". Error: %s' % (user_id, str(error)))
return variation

return None
16 changes: 7 additions & 9 deletions optimizely/event_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,8 @@ def __init__(self, url, params, http_verb=None, headers=None):
class BaseEventBuilder(object):
""" Base class which encapsulates methods to build events for tracking impressions and conversions. """

def __init__(self, config, bucketer):
def __init__(self, config):
self.config = config
self.bucketer = bucketer
self.params = {}

@abstractproperty
Expand Down Expand Up @@ -183,14 +182,13 @@ def _add_required_params_for_impression(self, experiment, variation_id):
self.EventParams.IS_LAYER_HOLDBACK: False
}

def _add_required_params_for_conversion(self, event_key, user_id, event_tags, valid_experiments):
def _add_required_params_for_conversion(self, event_key, event_tags, decisions):
""" Add parameters that are required for the conversion event to register.
Args:
event_key: Key representing the event which needs to be recorded.
user_id: ID for user.
event_tags: Dict representing metadata associated with the event.
valid_experiments: List of tuples representing valid experiments IDs and variation IDs.
decisions: List of tuples representing valid experiments IDs and variation IDs.
"""

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

self.params[self.EventParams.LAYER_STATES] = []
for experiment_id, variation_id in valid_experiments:
for experiment_id, variation_id in decisions:
experiment = self.config.get_experiment_from_id(experiment_id)
self.params[self.EventParams.LAYER_STATES].append({
self.EventParams.LAYER_ID: experiment.layerId,
Expand Down Expand Up @@ -256,23 +254,23 @@ def create_impression_event(self, experiment, variation_id, user_id, attributes)
http_verb=self.HTTP_VERB,
headers=self.HTTP_HEADERS)

def create_conversion_event(self, event_key, user_id, attributes, event_tags, valid_experiments):
def create_conversion_event(self, event_key, user_id, attributes, event_tags, decisions):
""" Create conversion Event to be sent to the logging endpoint.
Args:
event_key: Key representing the event which needs to be recorded.
user_id: ID for user.
attributes: Dict representing user attributes and values.
event_tags: Dict representing metadata associated with the event.
valid_experiments: List of tuples representing experiments IDs and variation IDs.
decisions: List of tuples representing experiments IDs and variation IDs.
Returns:
Event object encapsulating the conversion event.
"""

self.params = {}
self._add_common_params(user_id, attributes)
self._add_required_params_for_conversion(event_key, user_id, event_tags, valid_experiments)
self._add_required_params_for_conversion(event_key, event_tags, decisions)
return Event(self.CONVERSION_ENDPOINT,
self.params,
http_verb=self.HTTP_VERB,
Expand Down
34 changes: 34 additions & 0 deletions optimizely/helpers/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import json
import jsonschema

from optimizely.user_profile import UserProfile
from . import constants


Expand Down Expand Up @@ -117,3 +118,36 @@ def are_event_tags_valid(event_tags):
"""

return type(event_tags) is dict


def is_user_profile_valid(user_profile):
""" Determine if provided user profile is valid or not.
Args:
user_profile: User's profile which needs to be validated.
Returns:
Boolean depending upon whether profile is valid or not.
"""

if not user_profile:
return False

if not type(user_profile) is dict:
return False

if not UserProfile.USER_ID_KEY in user_profile:
return False

if not UserProfile.EXPERIMENT_BUCKET_MAP_KEY in user_profile:
return False

experiment_bucket_map = user_profile.get(UserProfile.EXPERIMENT_BUCKET_MAP_KEY)
if not type(experiment_bucket_map) is dict:
return False

for decision in experiment_bucket_map.values():
if type(decision) is not dict or UserProfile.VARIATION_ID_KEY not in decision:
return False

return True

0 comments on commit 2dcc5ea

Please sign in to comment.