diff --git a/optimizely/event/entity/conversion_event.py b/optimizely/event/entity/conversion_event.py index e6cd746d..c131cc84 100644 --- a/optimizely/event/entity/conversion_event.py +++ b/optimizely/event/entity/conversion_event.py @@ -17,6 +17,7 @@ class ConversionEvent(UserEvent): """ Class representing Conversion Event. """ def __init__(self, event_context, event, user_id, visitor_attributes, event_tags, bot_filtering=None): + super(ConversionEvent, self).__init__(event_context) self.event_context = event_context self.event = event self.user_id = user_id diff --git a/optimizely/event/entity/decision.py b/optimizely/event/entity/decision.py index 60b965dc..d36abe7d 100644 --- a/optimizely/event/entity/decision.py +++ b/optimizely/event/entity/decision.py @@ -13,7 +13,7 @@ class Decision(object): - def __init__(self, compaign_id, experiment_id, variation_id): - self.campaign_id = compaign_id + def __init__(self, campaign_id, experiment_id, variation_id): + self.campaign_id = campaign_id self.experiment_id = experiment_id self.variation_id = variation_id diff --git a/optimizely/event/entity/event_context.py b/optimizely/event/entity/event_context.py index 5d7efcc5..f6b953e3 100644 --- a/optimizely/event/entity/event_context.py +++ b/optimizely/event/entity/event_context.py @@ -10,7 +10,7 @@ # 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. -from .. import version +from ... import version SDK_VERSION = 'python-sdk' diff --git a/optimizely/event/entity/impression_event.py b/optimizely/event/entity/impression_event.py index 044fb163..83c6f9a9 100644 --- a/optimizely/event/entity/impression_event.py +++ b/optimizely/event/entity/impression_event.py @@ -17,6 +17,7 @@ class ImpressionEvent(UserEvent): """ Class representing Impression Event. """ def __init__(self, event_context, user_id, experiment, visitor_attributes, variation, bot_filtering=None): + super(ImpressionEvent, self).__init__(event_context) self.event_context = event_context self.user_id = user_id self.experiment = experiment diff --git a/optimizely/event/event_factory.py b/optimizely/event/event_factory.py new file mode 100644 index 00000000..9206ea2e --- /dev/null +++ b/optimizely/event/event_factory.py @@ -0,0 +1,169 @@ +# Copyright 2019 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 json +from more_itertools.more import always_iterable + +from .entity.conversion_event import ConversionEvent +from .entity.decision import Decision +from .entity.event_batch import EventBatch +from .entity.impression_event import ImpressionEvent +from .entity.snapshot import Snapshot +from .entity.snapshot_event import SnapshotEvent +from .entity.visitor import Visitor +from .log_event import LogEvent +from ..helpers import event_tag_utils +from ..helpers import enums +from ..helpers import validator + +CUSTOM_ATTRIBUTE_FEATURE_TYPE = 'custom' + + +class EventFactory(object): + """ EventFactory builds LogEvent object from a given UserEvent. + This class serves to separate concerns between events in the SDK and the API used + to record the events via the Optimizely Events API ("https://developers.optimizely.com/x/events/api/index.html") + """ + + EVENT_ENDPOINT = 'https://logx.optimizely.com/v1/events' + HTTP_VERB = 'POST' + HTTP_HEADERS = {'Content-Type': 'application/json'} + ACTIVATE_EVENT_KEY = 'campaign_activated' + + @classmethod + def create_log_event(cls, user_events, logger): + """ Create LogEvent instance. + + Args: + user_events: An array of UserEvent instances. + logger: Provides a logger instance. + + Returns: + LogEvent instance. + """ + + visitors = [] + + for user_event in always_iterable(user_events): + visitors.append(cls._create_visitor(user_event, logger)) + user_context = user_event.event_context + + event_batch = EventBatch( + user_context.account_id, + user_context.project_id, + user_context.revision, + user_context.client_name, + user_context.client_version, + user_context.anonymize_ip, + True + ) + + if len([x for x in visitors if x is not None]) == 0: + return None + + event_batch.visitors = visitors + + event_batch_json = json.dumps(event_batch.__dict__, default=lambda o: o.__dict__) + + return LogEvent(cls.EVENT_ENDPOINT, event_batch_json, cls.HTTP_VERB, cls.HTTP_HEADERS) + + @classmethod + def _create_visitor(cls, user_event, logger): + if not user_event: + return None + + if isinstance(user_event, ImpressionEvent): + decision = Decision( + user_event.experiment.layerId if hasattr(user_event, 'experiment') else None, + user_event.experiment.id if hasattr(user_event, 'experiment') else None, + user_event.variation.id if hasattr(user_event, 'variation') else None + ) + + snapshot_event = SnapshotEvent( + user_event.experiment.layerId if hasattr(user_event, 'experiment') else None, + user_event.uuid, + cls.ACTIVATE_EVENT_KEY, + user_event.timestamp + ) + + snapshot = Snapshot([snapshot_event], [decision]) + + visitor = Visitor([snapshot], user_event.visitor_attributes, user_event.user_id) + + return visitor + + elif isinstance(user_event, ConversionEvent): + revenue = event_tag_utils.get_revenue_value(user_event.event_tags) + value = event_tag_utils.get_numeric_value(user_event.event_tags, logger) + + snapshot_event = SnapshotEvent( + user_event.event.id if hasattr(user_event, 'event') else None, + user_event.uuid, + user_event.event.key if hasattr(user_event, 'event') else None, + user_event.timestamp, + revenue, + value, + user_event.event_tags + ) + + snapshot = Snapshot([snapshot_event]) + + visitor = Visitor([snapshot], user_event.visitor_attributes, user_event.user_id) + + return visitor + + else: + # include log message for invalid event type + return + + @staticmethod + def build_attribute_list(attributes, project_config): + """ Create Vistor Attribute List. + + Args: + attributes: Dict representing user attributes and values which need to be recorded. + project_config: Instance of ProjectConfig. + + Returns: + List consisting of valid attributes for the user. Empty otherwise. + """ + + if project_config is None: + return None + + attributes_list = [] + + if isinstance(attributes, dict): + for attribute_key in attributes.keys(): + attribute_value = attributes.get(attribute_key) + # Omit attribute values that are not supported by the log endpoint. + if validator.is_attribute_valid(attribute_key, attribute_value): + attribute_id = project_config.get_attribute_id(attribute_key) + if attribute_id: + attributes_list.append({ + 'entity_id': attribute_id, + 'key': attribute_key, + 'type': CUSTOM_ATTRIBUTE_FEATURE_TYPE, + 'value': attribute_value + }) + + # Append Bot Filtering Attribute + bot_filtering_value = project_config.get_bot_filtering_value() + if isinstance(bot_filtering_value, bool): + attributes_list.append({ + 'entity_id': enums.ControlAttributes.BOT_FILTERING, + 'key': enums.ControlAttributes.BOT_FILTERING, + 'type': CUSTOM_ATTRIBUTE_FEATURE_TYPE, + 'value': bot_filtering_value + }) + + return attributes_list diff --git a/optimizely/event/log_event.py b/optimizely/event/log_event.py new file mode 100644 index 00000000..ea34b17e --- /dev/null +++ b/optimizely/event/log_event.py @@ -0,0 +1,22 @@ +# Copyright 2019 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. + + +class LogEvent(object): + """ Representation of an event which can be sent to the Optimizely logging endpoint. """ + + def __init__(self, url, params, http_verb=None, headers=None): + self.url = url + self.params = params + self.http_verb = http_verb or 'GET' + self.headers = headers diff --git a/optimizely/event/user_event_factory.py b/optimizely/event/user_event_factory.py new file mode 100644 index 00000000..8680699a --- /dev/null +++ b/optimizely/event/user_event_factory.py @@ -0,0 +1,85 @@ +# Copyright 2019 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. +from .entity.impression_event import ImpressionEvent +from .entity.conversion_event import ConversionEvent +from .entity.event_context import EventContext +from .event_factory import EventFactory + + +class UserEventFactory(object): + """ UserEventFactory builds impression and conversion events from a given UserEvent. """ + + @classmethod + def create_impression_event(cls, project_config, activated_experiment, variation_id, user_id, user_attributes): + """ Create impression Event to be sent to the logging endpoint. + + Args: + project_config: Instance of ProjectConfig. + experiment: Experiment for which impression needs to be recorded. + variation_id: ID for variation which would be presented to user. + user_id: ID for user. + attributes: Dict representing user attributes and values which need to be recorded. + + Returns: + Event object encapsulating the impression event. + """ + + experiment_key = activated_experiment.key if activated_experiment else None + variation = project_config.get_variation_from_id(experiment_key, variation_id) + + event_context = EventContext( + project_config.account_id, + project_config.project_id, + project_config.revision, + project_config.anonymize_ip + ) + + return ImpressionEvent( + event_context, + user_id, + activated_experiment, + EventFactory.build_attribute_list(user_attributes, project_config), + variation, + project_config.get_bot_filtering_value() + ) + + @classmethod + def create_conversion_event(cls, project_config, event_key, user_id, user_attributes, event_tags): + """ Create conversion Event to be sent to the logging endpoint. + + Args: + project_config: Instance of ProjectConfig. + 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. + + Returns: + Event object encapsulating the conversion event. + """ + + event_context = EventContext( + project_config.account_id, + project_config.project_id, + project_config.revision, + project_config.anonymize_ip + ) + + return ConversionEvent( + event_context, + project_config.get_event(event_key), + user_id, + EventFactory.build_attribute_list(user_attributes, project_config), + event_tags, + project_config.get_bot_filtering_value() + ) diff --git a/tests/test_event_factory.py b/tests/test_event_factory.py new file mode 100644 index 00000000..72810a9e --- /dev/null +++ b/tests/test_event_factory.py @@ -0,0 +1,821 @@ +# Copyright 2019, 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 json +import mock +from operator import itemgetter +import time +import unittest +import uuid + +from . import base +from optimizely import logger +from optimizely import version +from optimizely.event.event_factory import EventFactory +from optimizely.event.log_event import LogEvent +from optimizely.event.user_event_factory import UserEventFactory + + +class LogEventTest(unittest.TestCase): + + def test_init(self): + url = 'event.optimizely.com' + params = { + 'a': '111001', + 'n': 'test_event', + 'g': '111028', + 'u': 'oeutest_user' + } + http_verb = 'POST' + headers = {'Content-Type': 'application/json'} + event_obj = LogEvent(url, params, http_verb=http_verb, headers=headers) + self.assertEqual(url, event_obj.url) + self.assertEqual(params, event_obj.params) + self.assertEqual(http_verb, event_obj.http_verb) + self.assertEqual(headers, event_obj.headers) + + +class EventFactoryTest(base.BaseTest): + + def setUp(self, *args, **kwargs): + base.BaseTest.setUp(self, 'config_dict_with_multiple_experiments') + self.logger = logger.NoOpLogger() + self.uuid = str(uuid.uuid4()) + self.timestamp = int(round(time.time() * 1000)) + + def _dict_clean(self, obj): + """ Helper method to remove keys from dictionary with None values. """ + + result = {} + for k, v in obj: + if v is None and k in ['revenue', 'value', 'tags', 'decisions']: + continue + else: + result[k] = v + return result + + def _validate_event_object(self, event_obj, expected_url, expected_params, expected_verb, expected_headers): + """ Helper method to validate properties of the event object. """ + + self.assertEqual(expected_url, event_obj.url) + + event_obj.params = json.loads(event_obj.params, object_pairs_hook=self._dict_clean) + + expected_params['visitors'][0]['attributes'] = \ + sorted(expected_params['visitors'][0]['attributes'], key=itemgetter('key')) + event_obj.params['visitors'][0]['attributes'] = \ + sorted(event_obj.params['visitors'][0]['attributes'], key=itemgetter('key')) + self.assertEqual(expected_params, event_obj.params) + + self.assertEqual(expected_verb, event_obj.http_verb) + self.assertEqual(expected_headers, event_obj.headers) + + def test_create_impression_event(self): + """ Test that create_impression_event creates LogEvent object with right params. """ + + expected_params = { + 'account_id': '12001', + 'project_id': '111001', + 'visitors': [{ + 'visitor_id': 'test_user', + 'attributes': [], + 'snapshots': [{ + 'decisions': [{ + 'variation_id': '111129', + 'experiment_id': '111127', + 'campaign_id': '111182' + }], + 'events': [{ + 'timestamp': 42123, + 'entity_id': '111182', + 'uuid': 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c', + 'key': 'campaign_activated' + }] + }] + }], + 'client_name': 'python-sdk', + 'client_version': version.__version__, + 'enrich_decisions': True, + 'anonymize_ip': False, + 'revision': '42' + } + + with mock.patch('time.time', return_value=42.123), \ + mock.patch('uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c'): + event_obj = UserEventFactory.create_impression_event( + self.project_config, self.project_config.get_experiment_from_key('test_experiment'), + '111129', 'test_user', None + ) + + log_event = EventFactory.create_log_event(event_obj, self.logger) + + self._validate_event_object(log_event, + EventFactory.EVENT_ENDPOINT, + expected_params, + EventFactory.HTTP_VERB, + EventFactory.HTTP_HEADERS) + + def test_create_impression_event__with_attributes(self): + """ Test that create_impression_event creates Event object + with right params when attributes are provided. """ + + expected_params = { + 'account_id': '12001', + 'project_id': '111001', + 'visitors': [{ + 'visitor_id': 'test_user', + 'attributes': [{ + 'type': 'custom', + 'value': 'test_value', + 'entity_id': '111094', + 'key': 'test_attribute' + }], + 'snapshots': [{ + 'decisions': [{ + 'variation_id': '111129', + 'experiment_id': '111127', + 'campaign_id': '111182' + }], + 'events': [{ + 'timestamp': 42123, + 'entity_id': '111182', + 'uuid': 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c', + 'key': 'campaign_activated' + }] + }] + }], + 'client_name': 'python-sdk', + 'client_version': version.__version__, + 'enrich_decisions': True, + 'anonymize_ip': False, + 'revision': '42' + } + + with mock.patch('time.time', return_value=42.123), \ + mock.patch('uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c'): + event_obj = UserEventFactory.create_impression_event( + self.project_config, self.project_config.get_experiment_from_key('test_experiment'), + '111129', 'test_user', {'test_attribute': 'test_value'} + ) + + log_event = EventFactory.create_log_event(event_obj, self.logger) + + self._validate_event_object(log_event, + EventFactory.EVENT_ENDPOINT, + expected_params, + EventFactory.HTTP_VERB, + EventFactory.HTTP_HEADERS) + + def test_create_impression_event_when_attribute_is_not_in_datafile(self): + """ Test that create_impression_event creates Event object + with right params when attribute is not in the datafile. """ + + expected_params = { + 'account_id': '12001', + 'project_id': '111001', + 'visitors': [{ + 'visitor_id': 'test_user', + 'attributes': [], + 'snapshots': [{ + 'decisions': [{ + 'variation_id': '111129', + 'experiment_id': '111127', + 'campaign_id': '111182' + }], + 'events': [{ + 'timestamp': 42123, + 'entity_id': '111182', + 'uuid': 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c', + 'key': 'campaign_activated' + }] + }] + }], + 'client_name': 'python-sdk', + 'client_version': version.__version__, + 'enrich_decisions': True, + 'anonymize_ip': False, + 'revision': '42' + } + + with mock.patch('time.time', return_value=42.123), \ + mock.patch('uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c'): + event_obj = UserEventFactory.create_impression_event( + self.project_config, self.project_config.get_experiment_from_key('test_experiment'), + '111129', 'test_user', {'do_you_know_me': 'test_value'} + ) + + log_event = EventFactory.create_log_event(event_obj, self.logger) + + self._validate_event_object(log_event, + EventFactory.EVENT_ENDPOINT, + expected_params, + EventFactory.HTTP_VERB, + EventFactory.HTTP_HEADERS) + + def test_create_impression_event_calls_is_attribute_valid(self): + """ Test that create_impression_event calls is_attribute_valid and + creates Event object with only those attributes for which is_attribute_valid is True.""" + + expected_params = { + 'account_id': '12001', + 'project_id': '111001', + 'visitors': [{ + 'visitor_id': 'test_user', + 'attributes': [{ + 'type': 'custom', + 'value': 5.5, + 'entity_id': '111198', + 'key': 'double_key' + }, { + 'type': 'custom', + 'value': True, + 'entity_id': '111196', + 'key': 'boolean_key' + }], + 'snapshots': [{ + 'decisions': [{ + 'variation_id': '111129', + 'experiment_id': '111127', + 'campaign_id': '111182' + }], + 'events': [{ + 'timestamp': 42123, + 'entity_id': '111182', + 'uuid': 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c', + 'key': 'campaign_activated' + }] + }] + }], + 'client_name': 'python-sdk', + 'client_version': version.__version__, + 'enrich_decisions': True, + 'anonymize_ip': False, + 'revision': '42' + } + + def side_effect(*args, **kwargs): + attribute_key = args[0] + if attribute_key == 'boolean_key' or attribute_key == 'double_key': + return True + + return False + + attributes = { + 'test_attribute': 'test_value', + 'boolean_key': True, + 'integer_key': 0, + 'double_key': 5.5 + } + + with mock.patch('time.time', return_value=42.123), \ + mock.patch('uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c'),\ + mock.patch('optimizely.helpers.validator.is_attribute_valid', side_effect=side_effect): + + event_obj = UserEventFactory.create_impression_event( + self.project_config, self.project_config.get_experiment_from_key('test_experiment'), + '111129', 'test_user', attributes + ) + + log_event = EventFactory.create_log_event(event_obj, self.logger) + + self._validate_event_object(log_event, + EventFactory.EVENT_ENDPOINT, + expected_params, + EventFactory.HTTP_VERB, + EventFactory.HTTP_HEADERS) + + def test_create_impression_event__with_user_agent_when_bot_filtering_is_enabled(self): + """ Test that create_impression_event creates Event object + with right params when user agent attribute is provided and + bot filtering is enabled """ + + expected_params = { + 'account_id': '12001', + 'project_id': '111001', + 'visitors': [{ + 'visitor_id': 'test_user', + 'attributes': [{ + 'type': 'custom', + 'value': 'Edge', + 'entity_id': '$opt_user_agent', + 'key': '$opt_user_agent' + }, { + 'type': 'custom', + 'value': True, + 'entity_id': '$opt_bot_filtering', + 'key': '$opt_bot_filtering' + }], + 'snapshots': [{ + 'decisions': [{ + 'variation_id': '111129', + 'experiment_id': '111127', + 'campaign_id': '111182' + }], + 'events': [{ + 'timestamp': 42123, + 'entity_id': '111182', + 'uuid': 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c', + 'key': 'campaign_activated' + }] + }] + }], + 'client_name': 'python-sdk', + 'client_version': version.__version__, + 'enrich_decisions': True, + 'anonymize_ip': False, + 'revision': '42' + } + + with mock.patch('time.time', return_value=42.123), \ + mock.patch('uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c'),\ + mock.patch('optimizely.project_config.ProjectConfig.get_bot_filtering_value', return_value=True): + event_obj = UserEventFactory.create_impression_event( + self.project_config, + self.project_config.get_experiment_from_key('test_experiment'), + '111129', 'test_user', {'$opt_user_agent': 'Edge'} + ) + + log_event = EventFactory.create_log_event(event_obj, self.logger) + + self._validate_event_object(log_event, + EventFactory.EVENT_ENDPOINT, + expected_params, + EventFactory.HTTP_VERB, + EventFactory.HTTP_HEADERS) + + def test_create_impression_event__with_empty_attributes_when_bot_filtering_is_enabled(self): + """ Test that create_impression_event creates Event object + with right params when empty attributes are provided and + bot filtering is enabled """ + + expected_params = { + 'account_id': '12001', + 'project_id': '111001', + 'visitors': [{ + 'visitor_id': 'test_user', + 'attributes': [{ + 'type': 'custom', + 'value': True, + 'entity_id': '$opt_bot_filtering', + 'key': '$opt_bot_filtering' + }], + 'snapshots': [{ + 'decisions': [{ + 'variation_id': '111129', + 'experiment_id': '111127', + 'campaign_id': '111182' + }], + 'events': [{ + 'timestamp': 42123, + 'entity_id': '111182', + 'uuid': 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c', + 'key': 'campaign_activated' + }] + }] + }], + 'client_name': 'python-sdk', + 'client_version': version.__version__, + 'enrich_decisions': True, + 'anonymize_ip': False, + 'revision': '42' + } + + with mock.patch('time.time', return_value=42.123), \ + mock.patch('uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c'),\ + mock.patch('optimizely.project_config.ProjectConfig.get_bot_filtering_value', return_value=True): + event_obj = UserEventFactory.create_impression_event( + self.project_config, + self.project_config.get_experiment_from_key('test_experiment'), + '111129', 'test_user', None + ) + + log_event = EventFactory.create_log_event(event_obj, self.logger) + + self._validate_event_object(log_event, + EventFactory.EVENT_ENDPOINT, + expected_params, + EventFactory.HTTP_VERB, + EventFactory.HTTP_HEADERS) + + def test_create_impression_event__with_user_agent_when_bot_filtering_is_disabled(self): + """ Test that create_impression_event creates Event object + with right params when user agent attribute is provided and + bot filtering is disabled """ + + expected_params = { + 'account_id': '12001', + 'project_id': '111001', + 'visitors': [{ + 'visitor_id': 'test_user', + 'attributes': [{ + 'type': 'custom', + 'value': 'Chrome', + 'entity_id': '$opt_user_agent', + 'key': '$opt_user_agent' + }, { + 'type': 'custom', + 'value': False, + 'entity_id': '$opt_bot_filtering', + 'key': '$opt_bot_filtering' + }], + 'snapshots': [{ + 'decisions': [{ + 'variation_id': '111129', + 'experiment_id': '111127', + 'campaign_id': '111182' + }], + 'events': [{ + 'timestamp': 42123, + 'entity_id': '111182', + 'uuid': 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c', + 'key': 'campaign_activated' + }] + }] + }], + 'client_name': 'python-sdk', + 'client_version': version.__version__, + 'enrich_decisions': True, + 'anonymize_ip': False, + 'revision': '42' + } + + with mock.patch('time.time', return_value=42.123), \ + mock.patch('uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c'),\ + mock.patch('optimizely.project_config.ProjectConfig.get_bot_filtering_value', return_value=False): + event_obj = UserEventFactory.create_impression_event( + self.project_config, + self.project_config.get_experiment_from_key('test_experiment'), + '111129', 'test_user', {'$opt_user_agent': 'Chrome'} + ) + + log_event = EventFactory.create_log_event(event_obj, self.logger) + + self._validate_event_object(log_event, + EventFactory.EVENT_ENDPOINT, + expected_params, + EventFactory.HTTP_VERB, + EventFactory.HTTP_HEADERS) + + def test_create_conversion_event(self): + """ Test that create_conversion_event creates Event object + with right params when no attributes are provided. """ + + expected_params = { + 'account_id': '12001', + 'project_id': '111001', + 'visitors': [{ + 'visitor_id': 'test_user', + 'attributes': [], + 'snapshots': [{ + 'events': [{ + 'timestamp': 42123, + 'entity_id': '111095', + 'uuid': 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c', + 'key': 'test_event' + }] + }] + }], + 'client_name': 'python-sdk', + 'client_version': version.__version__, + 'enrich_decisions': True, + 'anonymize_ip': False, + 'revision': '42' + } + + with mock.patch('time.time', return_value=42.123), \ + mock.patch('uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c'): + event_obj = UserEventFactory.create_conversion_event( + self.project_config, 'test_event', 'test_user', None, None + ) + + log_event = EventFactory.create_log_event(event_obj, self.logger) + + self._validate_event_object(log_event, + EventFactory.EVENT_ENDPOINT, + expected_params, + EventFactory.HTTP_VERB, + EventFactory.HTTP_HEADERS) + + def test_create_conversion_event__with_attributes(self): + """ Test that create_conversion_event creates Event object + with right params when attributes are provided. """ + + expected_params = { + 'account_id': '12001', + 'project_id': '111001', + 'visitors': [{ + 'visitor_id': 'test_user', + 'attributes': [{ + 'type': 'custom', + 'value': 'test_value', + 'entity_id': '111094', + 'key': 'test_attribute' + }], + 'snapshots': [{ + 'events': [{ + 'timestamp': 42123, + 'entity_id': '111095', + 'uuid': 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c', + 'key': 'test_event' + }] + }] + }], + 'client_name': 'python-sdk', + 'client_version': version.__version__, + 'enrich_decisions': True, + 'anonymize_ip': False, + 'revision': '42' + } + + with mock.patch('time.time', return_value=42.123), \ + mock.patch('uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c'): + event_obj = UserEventFactory.create_conversion_event( + self.project_config, 'test_event', 'test_user', {'test_attribute': 'test_value'}, None + ) + + log_event = EventFactory.create_log_event(event_obj, self.logger) + + self._validate_event_object(log_event, + EventFactory.EVENT_ENDPOINT, + expected_params, + EventFactory.HTTP_VERB, + EventFactory.HTTP_HEADERS) + + def test_create_conversion_event__with_user_agent_when_bot_filtering_is_enabled(self): + """ Test that create_conversion_event creates Event object + with right params when user agent attribute is provided and + bot filtering is enabled """ + + expected_params = { + 'account_id': '12001', + 'project_id': '111001', + 'visitors': [{ + 'visitor_id': 'test_user', + 'attributes': [{ + 'type': 'custom', + 'value': 'Edge', + 'entity_id': '$opt_user_agent', + 'key': '$opt_user_agent' + }, { + 'type': 'custom', + 'value': True, + 'entity_id': '$opt_bot_filtering', + 'key': '$opt_bot_filtering' + }], + 'snapshots': [{ + 'events': [{ + 'timestamp': 42123, + 'entity_id': '111095', + 'uuid': 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c', + 'key': 'test_event' + }] + }] + }], + 'client_name': 'python-sdk', + 'client_version': version.__version__, + 'enrich_decisions': True, + 'anonymize_ip': False, + 'revision': '42' + } + + with mock.patch('time.time', return_value=42.123), \ + mock.patch('uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c'), \ + mock.patch('optimizely.project_config.ProjectConfig.get_bot_filtering_value', return_value=True): + event_obj = UserEventFactory.create_conversion_event( + self.project_config, 'test_event', 'test_user', {'$opt_user_agent': 'Edge'}, None + ) + + log_event = EventFactory.create_log_event(event_obj, self.logger) + + self._validate_event_object(log_event, + EventFactory.EVENT_ENDPOINT, + expected_params, + EventFactory.HTTP_VERB, + EventFactory.HTTP_HEADERS) + + def test_create_conversion_event__with_user_agent_when_bot_filtering_is_disabled(self): + """ Test that create_conversion_event creates Event object + with right params when user agent attribute is provided and + bot filtering is disabled """ + + expected_params = { + 'account_id': '12001', + 'project_id': '111001', + 'visitors': [{ + 'visitor_id': 'test_user', + 'attributes': [{ + 'type': 'custom', + 'value': 'Chrome', + 'entity_id': '$opt_user_agent', + 'key': '$opt_user_agent' + }, { + 'type': 'custom', + 'value': False, + 'entity_id': '$opt_bot_filtering', + 'key': '$opt_bot_filtering' + }], + 'snapshots': [{ + 'events': [{ + 'timestamp': 42123, + 'entity_id': '111095', + 'uuid': 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c', + 'key': 'test_event' + }] + }] + }], + 'client_name': 'python-sdk', + 'client_version': version.__version__, + 'enrich_decisions': True, + 'anonymize_ip': False, + 'revision': '42' + } + + with mock.patch('time.time', return_value=42.123), \ + mock.patch('uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c'), \ + mock.patch('optimizely.project_config.ProjectConfig.get_bot_filtering_value', return_value=False): + event_obj = UserEventFactory.create_conversion_event( + self.project_config, 'test_event', 'test_user', {'$opt_user_agent': 'Chrome'}, None + ) + + log_event = EventFactory.create_log_event(event_obj, self.logger) + + self._validate_event_object(log_event, + EventFactory.EVENT_ENDPOINT, + expected_params, + EventFactory.HTTP_VERB, + EventFactory.HTTP_HEADERS) + + def test_create_conversion_event__with_event_tags(self): + """ Test that create_conversion_event creates Event object + with right params when event tags are provided. """ + + expected_params = { + 'client_version': version.__version__, + 'project_id': '111001', + 'visitors': [{ + 'attributes': [{ + 'entity_id': '111094', + 'type': 'custom', + 'value': 'test_value', + 'key': 'test_attribute' + }], + 'visitor_id': 'test_user', + 'snapshots': [{ + 'events': [{ + 'uuid': 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c', + 'tags': { + 'non-revenue': 'abc', + 'revenue': 4200, + 'value': 1.234 + }, + 'timestamp': 42123, + 'revenue': 4200, + 'value': 1.234, + 'key': 'test_event', + 'entity_id': '111095' + }] + }] + }], + 'account_id': '12001', + 'client_name': 'python-sdk', + 'enrich_decisions': True, + 'anonymize_ip': False, + 'revision': '42' + } + + with mock.patch('time.time', return_value=42.123), \ + mock.patch('uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c'): + event_obj = UserEventFactory.create_conversion_event( + self.project_config, + 'test_event', + 'test_user', + {'test_attribute': 'test_value'}, + {'revenue': 4200, 'value': 1.234, 'non-revenue': 'abc'} + ) + + log_event = EventFactory.create_log_event(event_obj, self.logger) + + self._validate_event_object(log_event, + EventFactory.EVENT_ENDPOINT, + expected_params, + EventFactory.HTTP_VERB, + EventFactory.HTTP_HEADERS) + + def test_create_conversion_event__with_invalid_event_tags(self): + """ Test that create_conversion_event creates Event object + with right params when event tags are provided. """ + + expected_params = { + 'client_version': version.__version__, + 'project_id': '111001', + 'visitors': [{ + 'attributes': [{ + 'entity_id': '111094', + 'type': 'custom', + 'value': 'test_value', + 'key': 'test_attribute' + }], + 'visitor_id': 'test_user', + 'snapshots': [{ + 'events': [{ + 'timestamp': 42123, + 'entity_id': '111095', + 'uuid': 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c', + 'key': 'test_event', + 'tags': { + 'non-revenue': 'abc', + 'revenue': '4200', + 'value': True + } + }] + }] + }], + 'account_id': '12001', + 'client_name': 'python-sdk', + 'enrich_decisions': True, + 'anonymize_ip': False, + 'revision': '42' + } + + with mock.patch('time.time', return_value=42.123), \ + mock.patch('uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c'): + event_obj = UserEventFactory.create_conversion_event( + self.project_config, + 'test_event', + 'test_user', + {'test_attribute': 'test_value'}, + {'revenue': '4200', 'value': True, 'non-revenue': 'abc'} + ) + + log_event = EventFactory.create_log_event(event_obj, self.logger) + + self._validate_event_object(log_event, + EventFactory.EVENT_ENDPOINT, + expected_params, + EventFactory.HTTP_VERB, + EventFactory.HTTP_HEADERS) + + def test_create_conversion_event__when_event_is_used_in_multiple_experiments(self): + """ Test that create_conversion_event creates Event object with + right params when multiple experiments use the same event. """ + + expected_params = { + 'client_version': version.__version__, + 'project_id': '111001', + 'visitors': [{ + 'attributes': [{ + 'entity_id': '111094', + 'type': 'custom', + 'value': 'test_value', + 'key': 'test_attribute' + }], + 'visitor_id': 'test_user', + 'snapshots': [{ + 'events': [{ + 'uuid': 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c', + 'tags': { + 'non-revenue': 'abc', + 'revenue': 4200, + 'value': 1.234 + }, + 'timestamp': 42123, + 'revenue': 4200, + 'value': 1.234, + 'key': 'test_event', + 'entity_id': '111095' + }] + }] + }], + 'account_id': '12001', + 'client_name': 'python-sdk', + 'enrich_decisions': True, + 'anonymize_ip': False, + 'revision': '42' + } + + with mock.patch('time.time', return_value=42.123), \ + mock.patch('uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c'): + event_obj = UserEventFactory.create_conversion_event( + self.project_config, + 'test_event', + 'test_user', + {'test_attribute': 'test_value'}, + {'revenue': 4200, 'value': 1.234, 'non-revenue': 'abc'} + ) + + log_event = EventFactory.create_log_event(event_obj, self.logger) + + self._validate_event_object(log_event, + EventFactory.EVENT_ENDPOINT, + expected_params, + EventFactory.HTTP_VERB, + EventFactory.HTTP_HEADERS) diff --git a/tests/test_user_event_factory.py b/tests/test_user_event_factory.py new file mode 100644 index 00000000..ada95675 --- /dev/null +++ b/tests/test_user_event_factory.py @@ -0,0 +1,138 @@ +# Copyright 2019, 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. +from . import base +from optimizely import logger +from optimizely.event.event_factory import EventFactory +from optimizely.event.user_event_factory import UserEventFactory + + +class UserEventFactoryTest(base.BaseTest): + def setUp(self): + base.BaseTest.setUp(self, 'config_dict_with_multiple_experiments') + self.logger = logger.NoOpLogger() + + def test_impression_event(self): + project_config = self.project_config + experiment = self.project_config.get_experiment_from_key('test_experiment') + variation = self.project_config.get_variation_from_id(experiment.key, '111128') + user_id = 'test_user' + + impression_event = UserEventFactory.create_impression_event( + project_config, + experiment, + '111128', + user_id, + None + ) + + self.assertEqual(self.project_config.project_id, impression_event.event_context.project_id) + self.assertEqual(self.project_config.revision, impression_event.event_context.revision) + self.assertEqual(self.project_config.account_id, impression_event.event_context.account_id) + self.assertEqual(self.project_config.anonymize_ip, impression_event.event_context.anonymize_ip) + self.assertEqual(self.project_config.bot_filtering, impression_event.bot_filtering) + self.assertEqual(experiment, impression_event.experiment) + self.assertEqual(variation, impression_event.variation) + self.assertEqual(user_id, impression_event.user_id) + + def test_impression_event__with_attributes(self): + project_config = self.project_config + experiment = self.project_config.get_experiment_from_key('test_experiment') + variation = self.project_config.get_variation_from_id(experiment.key, '111128') + user_id = 'test_user' + + user_attributes = { + 'test_attribute': 'test_value', + 'boolean_key': True + } + + impression_event = UserEventFactory.create_impression_event( + project_config, + experiment, + '111128', + user_id, + user_attributes + ) + + expected_attrs = EventFactory.build_attribute_list(user_attributes, project_config) + + self.assertEqual(self.project_config.project_id, impression_event.event_context.project_id) + self.assertEqual(self.project_config.revision, impression_event.event_context.revision) + self.assertEqual(self.project_config.account_id, impression_event.event_context.account_id) + self.assertEqual(self.project_config.anonymize_ip, impression_event.event_context.anonymize_ip) + self.assertEqual(self.project_config.bot_filtering, impression_event.bot_filtering) + self.assertEqual(experiment, impression_event.experiment) + self.assertEqual(variation, impression_event.variation) + self.assertEqual(user_id, impression_event.user_id) + self.assertEqual(expected_attrs, impression_event.visitor_attributes) + + def test_conversion_event(self): + project_config = self.project_config + user_id = 'test_user' + event_key = 'test_event' + user_attributes = { + 'test_attribute': 'test_value', + 'boolean_key': True + } + + conversion_event = UserEventFactory.create_conversion_event( + project_config, + event_key, + user_id, + user_attributes, + None + ) + + expected_attrs = EventFactory.build_attribute_list(user_attributes, project_config) + + self.assertEqual(self.project_config.project_id, conversion_event.event_context.project_id) + self.assertEqual(self.project_config.revision, conversion_event.event_context.revision) + self.assertEqual(self.project_config.account_id, conversion_event.event_context.account_id) + self.assertEqual(self.project_config.anonymize_ip, conversion_event.event_context.anonymize_ip) + self.assertEqual(self.project_config.bot_filtering, conversion_event.bot_filtering) + self.assertEqual(self.project_config.get_event(event_key), conversion_event.event) + self.assertEqual(user_id, conversion_event.user_id) + self.assertEqual(expected_attrs, conversion_event.visitor_attributes) + + def test_conversion_event__with_event_tags(self): + project_config = self.project_config + user_id = 'test_user' + event_key = 'test_event' + user_attributes = { + 'test_attribute': 'test_value', + 'boolean_key': True + } + event_tags = { + "revenue": 4200, + "value": 1.234, + "non_revenue": "abc" + } + + conversion_event = UserEventFactory.create_conversion_event( + project_config, + event_key, + user_id, + user_attributes, + event_tags + ) + + expected_attrs = EventFactory.build_attribute_list(user_attributes, project_config) + + self.assertEqual(self.project_config.project_id, conversion_event.event_context.project_id) + self.assertEqual(self.project_config.revision, conversion_event.event_context.revision) + self.assertEqual(self.project_config.account_id, conversion_event.event_context.account_id) + self.assertEqual(self.project_config.anonymize_ip, conversion_event.event_context.anonymize_ip) + self.assertEqual(self.project_config.bot_filtering, conversion_event.bot_filtering) + self.assertEqual(self.project_config.get_event(event_key), conversion_event.event) + self.assertEqual(user_id, conversion_event.user_id) + self.assertEqual(expected_attrs, conversion_event.visitor_attributes) + self.assertEqual(event_tags, conversion_event.event_tags)