From 67942604e1b6d8489360b53c1c1903595dd1286b Mon Sep 17 00:00:00 2001 From: msohailhussain Date: Fri, 20 Sep 2019 11:05:52 -0700 Subject: [PATCH] feat(ep-factory): Implemented Event Factory and User Event Factory (#194) --- optimizely/event/event_factory.py | 179 ++++++ optimizely/event/log_event.py | 22 + optimizely/event/payload.py | 11 +- optimizely/event/user_event_factory.py | 88 +++ tests/test_event_factory.py | 808 +++++++++++++++++++++++++ tests/test_user_event_factory.py | 139 +++++ 6 files changed, 1245 insertions(+), 2 deletions(-) create mode 100644 optimizely/event/event_factory.py create mode 100644 optimizely/event/log_event.py create mode 100644 optimizely/event/user_event_factory.py create mode 100644 tests/test_event_factory.py create mode 100644 tests/test_user_event_factory.py diff --git a/optimizely/event/event_factory.py b/optimizely/event/event_factory.py new file mode 100644 index 00000000..355c3a25 --- /dev/null +++ b/optimizely/event/event_factory.py @@ -0,0 +1,179 @@ +# 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 optimizely.helpers import enums +from optimizely.helpers import event_tag_utils +from optimizely.helpers import validator +from . import log_event +from . import payload +from . import user_event + +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: A single UserEvent instance or a list of UserEvent instances. + logger: Provides a logger instance. + + Returns: + LogEvent instance. + """ + + if not isinstance(user_events, list): + user_events = [user_events] + + visitors = [] + + for event in user_events: + visitor = cls._create_visitor(event, logger) + + if visitor: + visitors.append(visitor) + + user_context = event.event_context + + event_batch = payload.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(visitors) == 0: + return None + + event_batch.visitors = visitors + + event_params = event_batch.get_event_params() + + return log_event.LogEvent(cls.EVENT_ENDPOINT, event_params, cls.HTTP_VERB, cls.HTTP_HEADERS) + + @classmethod + def _create_visitor(cls, event, logger): + """ Helper method to create Visitor instance for event_batch. + + Args: + event: Instance of UserEvent. + logger: Provides a logger instance. + + Returns: + Instance of Visitor. None if: + - event is invalid. + """ + + if isinstance(event, user_event.ImpressionEvent): + decision = payload.Decision( + event.experiment.layerId, + event.experiment.id, + event.variation.id, + ) + + snapshot_event = payload.SnapshotEvent( + event.experiment.layerId, + event.uuid, + cls.ACTIVATE_EVENT_KEY, + event.timestamp + ) + + snapshot = payload.Snapshot([snapshot_event], [decision]) + + visitor = payload.Visitor([snapshot], event.visitor_attributes, event.user_id) + + return visitor + + elif isinstance(event, user_event.ConversionEvent): + revenue = event_tag_utils.get_revenue_value(event.event_tags) + value = event_tag_utils.get_numeric_value(event.event_tags, logger) + + snapshot_event = payload.SnapshotEvent( + event.event.id, + event.uuid, + event.event.key, + event.timestamp, + revenue, + value, + event.event_tags + ) + + snapshot = payload.Snapshot([snapshot_event]) + + visitor = payload.Visitor([snapshot], event.visitor_attributes, event.user_id) + + return visitor + + else: + logger.error('Invalid user event.') + return None + + @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 or None. + project_config: Instance of ProjectConfig. + + Returns: + List consisting of valid attributes for the user. Empty otherwise. + """ + + attributes_list = [] + + if project_config is None: + return 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( + payload.VisitorAttribute( + attribute_id, + attribute_key, + CUSTOM_ATTRIBUTE_FEATURE_TYPE, + attribute_value) + ) + + # Append Bot Filtering Attribute + bot_filtering_value = project_config.get_bot_filtering_value() + if isinstance(bot_filtering_value, bool): + attributes_list.append( + payload.VisitorAttribute( + enums.ControlAttributes.BOT_FILTERING, + enums.ControlAttributes.BOT_FILTERING, + CUSTOM_ATTRIBUTE_FEATURE_TYPE, + 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..cf7d2b3d --- /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 Optimizely events API. """ + + def __init__(self, url, params, http_verb=None, headers=None): + self.url = url + self.params = params + self.http_verb = http_verb or 'POST' + self.headers = headers diff --git a/optimizely/event/payload.py b/optimizely/event/payload.py index e3dc8b6b..0a1c34d4 100644 --- a/optimizely/event/payload.py +++ b/optimizely/event/payload.py @@ -29,8 +29,7 @@ def __init__(self, account_id, project_id, revision, client_name, client_version self.visitors = visitors or [] def __eq__(self, other): - batch_obj = json.loads(json.dumps(self.__dict__, default=lambda o: o.__dict__), - object_pairs_hook=self._dict_clean) + batch_obj = self.get_event_params() return batch_obj == other def _dict_clean(self, obj): @@ -44,6 +43,14 @@ def _dict_clean(self, obj): result[k] = v return result + def get_event_params(self): + """ Method to return valid params for LogEvent payload. """ + + return json.loads( + json.dumps(self.__dict__, default=lambda o: o.__dict__), + object_pairs_hook=self._dict_clean + ) + class Decision(object): """ Class respresenting Decision. """ diff --git a/optimizely/event/user_event_factory.py b/optimizely/event/user_event_factory.py new file mode 100644 index 00000000..9699c570 --- /dev/null +++ b/optimizely/event/user_event_factory.py @@ -0,0 +1,88 @@ +# 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 event_factory +from . import user_event + + +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. None if: + - activated_experiment is None. + """ + + if not activated_experiment: + return None + + experiment_key = activated_experiment.key + variation = project_config.get_variation_from_id(experiment_key, variation_id) + + event_context = user_event.EventContext( + project_config.account_id, + project_config.project_id, + project_config.revision, + project_config.anonymize_ip + ) + + return user_event.ImpressionEvent( + event_context, + user_id, + activated_experiment, + event_factory.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 = user_event.EventContext( + project_config.account_id, + project_config.project_id, + project_config.revision, + project_config.anonymize_ip + ) + + return user_event.ConversionEvent( + event_context, + project_config.get_event(event_key), + user_id, + event_factory.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..bc89fa21 --- /dev/null +++ b/tests/test_event_factory.py @@ -0,0 +1,808 @@ +# 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 mock +import time +import unittest +import uuid +from operator import itemgetter + +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 +from . import base + + +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 _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) + + 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..3c949979 --- /dev/null +++ b/tests/test_user_event_factory.py @@ -0,0 +1,139 @@ +# 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([x.__dict__ for x in expected_attrs], [x.__dict__ for x in 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([x.__dict__ for x in expected_attrs], [x.__dict__ for x in 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([x.__dict__ for x in expected_attrs], [x.__dict__ for x in conversion_event.visitor_attributes]) + self.assertEqual(event_tags, conversion_event.event_tags)