diff --git a/optimizely/event/event_factory.py b/optimizely/event/event_factory.py index 8a4bb0cf..715b88c5 100644 --- a/optimizely/event/event_factory.py +++ b/optimizely/event/event_factory.py @@ -123,7 +123,9 @@ def _create_visitor(cls, event: Optional[user_event.UserEvent], logger: Logger) experiment_layerId = event.experiment.layerId experiment_id = event.experiment.id - metadata = payload.Metadata(event.flag_key, event.rule_key, event.rule_type, variation_key, event.enabled) + metadata = payload.Metadata(event.flag_key, event.rule_key, + event.rule_type, variation_key, + event.enabled, event.cmab_uuid) decision = payload.Decision(experiment_layerId, experiment_id, variation_id, metadata) snapshot_event = payload.SnapshotEvent( experiment_layerId, event.uuid, cls.ACTIVATE_EVENT_KEY, event.timestamp, diff --git a/optimizely/event/payload.py b/optimizely/event/payload.py index ac6f35e4..e352dd10 100644 --- a/optimizely/event/payload.py +++ b/optimizely/event/payload.py @@ -81,12 +81,15 @@ def __init__(self, campaign_id: str, experiment_id: str, variation_id: str, meta class Metadata: """ Class respresenting Metadata. """ - def __init__(self, flag_key: str, rule_key: str, rule_type: str, variation_key: str, enabled: bool): + def __init__(self, flag_key: str, rule_key: str, rule_type: str, + variation_key: str, enabled: bool, cmab_uuid: Optional[str] = None): self.flag_key = flag_key self.rule_key = rule_key self.rule_type = rule_type self.variation_key = variation_key self.enabled = enabled + if cmab_uuid: + self.cmab_uuid = cmab_uuid class Snapshot: diff --git a/optimizely/event/user_event.py b/optimizely/event/user_event.py index 9cdb623a..68c1ee78 100644 --- a/optimizely/event/user_event.py +++ b/optimizely/event/user_event.py @@ -70,7 +70,8 @@ def __init__( rule_key: str, rule_type: str, enabled: bool, - bot_filtering: Optional[bool] = None + bot_filtering: Optional[bool] = None, + cmab_uuid: Optional[str] = None ): super().__init__(event_context, user_id, visitor_attributes, bot_filtering) self.experiment = experiment @@ -79,6 +80,7 @@ def __init__( self.rule_key = rule_key self.rule_type = rule_type self.enabled = enabled + self.cmab_uuid = cmab_uuid class ConversionEvent(UserEvent): diff --git a/optimizely/event/user_event_factory.py b/optimizely/event/user_event_factory.py index ef07d06b..b41be39a 100644 --- a/optimizely/event/user_event_factory.py +++ b/optimizely/event/user_event_factory.py @@ -40,7 +40,8 @@ def create_impression_event( rule_type: str, enabled: bool, user_id: str, - user_attributes: Optional[UserAttributes] + user_attributes: Optional[UserAttributes], + cmab_uuid: Optional[str] ) -> Optional[user_event.ImpressionEvent]: """ Create impression Event to be sent to the logging endpoint. @@ -90,6 +91,7 @@ def create_impression_event( rule_type, enabled, project_config.get_bot_filtering_value(), + cmab_uuid, ) @classmethod diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index ebbde985..ae433cb1 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -15,6 +15,7 @@ from typing import TYPE_CHECKING, Any, Optional + from . import decision_service from . import entities from . import event_builder @@ -260,7 +261,7 @@ def _validate_user_inputs( def _send_impression_event( self, project_config: project_config.ProjectConfig, experiment: Optional[entities.Experiment], variation: Optional[entities.Variation], flag_key: str, rule_key: str, rule_type: str, - enabled: bool, user_id: str, attributes: Optional[UserAttributes] + enabled: bool, user_id: str, attributes: Optional[UserAttributes], cmab_uuid: Optional[str] = None ) -> None: """ Helper method to send impression event. @@ -280,7 +281,9 @@ def _send_impression_event( variation_id = variation.id if variation is not None else None user_event = user_event_factory.UserEventFactory.create_impression_event( - project_config, experiment, variation_id, flag_key, rule_key, rule_type, enabled, user_id, attributes + project_config, experiment, variation_id, + flag_key, rule_key, rule_type, + enabled, user_id, attributes, cmab_uuid ) if user_event is None: @@ -719,6 +722,8 @@ def is_feature_enabled(self, feature_key: str, user_id: str, attributes: Optiona user_context = OptimizelyUserContext(self, self.logger, user_id, attributes, False) decision = self.decision_service.get_variation_for_feature(project_config, feature, user_context)['decision'] + cmab_uuid = decision.cmab_uuid + is_source_experiment = decision.source == enums.DecisionSources.FEATURE_TEST is_source_rollout = decision.source == enums.DecisionSources.ROLLOUT @@ -729,7 +734,7 @@ def is_feature_enabled(self, feature_key: str, user_id: str, attributes: Optiona if (is_source_rollout or not decision.variation) and project_config.get_send_flag_decisions_value(): self._send_impression_event( project_config, decision.experiment, decision.variation, feature.key, decision.experiment.key if - decision.experiment else '', str(decision.source), feature_enabled, user_id, attributes + decision.experiment else '', str(decision.source), feature_enabled, user_id, attributes, cmab_uuid ) # Send event if Decision came from an experiment. @@ -740,7 +745,7 @@ def is_feature_enabled(self, feature_key: str, user_id: str, attributes: Optiona } self._send_impression_event( project_config, decision.experiment, decision.variation, feature.key, decision.experiment.key, - str(decision.source), feature_enabled, user_id, attributes + str(decision.source), feature_enabled, user_id, attributes, cmab_uuid ) if feature_enabled: @@ -1193,7 +1198,9 @@ def _create_optimizely_decision( flag_decision.variation, flag_key, rule_key or '', str(decision_source), feature_enabled, - user_id, attributes) + user_id, attributes, + flag_decision.cmab_uuid + ) decision_event_dispatched = True diff --git a/tests/test_event_factory.py b/tests/test_event_factory.py index adbebd35..59edd7c3 100644 --- a/tests/test_event_factory.py +++ b/tests/test_event_factory.py @@ -113,6 +113,7 @@ def test_create_impression_event(self): False, 'test_user', None, + None ) log_event = EventFactory.create_log_event(event_obj, self.logger) @@ -177,6 +178,7 @@ def test_create_impression_event__with_attributes(self): True, 'test_user', {'test_attribute': 'test_value'}, + None ) log_event = EventFactory.create_log_event(event_obj, self.logger) @@ -239,6 +241,7 @@ def test_create_impression_event_when_attribute_is_not_in_datafile(self): True, 'test_user', {'do_you_know_me': 'test_value'}, + None ) log_event = EventFactory.create_log_event(event_obj, self.logger) @@ -394,6 +397,7 @@ def test_create_impression_event__with_user_agent_when_bot_filtering_is_enabled( False, 'test_user', {'$opt_user_agent': 'Edge'}, + None ) log_event = EventFactory.create_log_event(event_obj, self.logger) @@ -466,6 +470,7 @@ def test_create_impression_event__with_empty_attributes_when_bot_filtering_is_en False, 'test_user', None, + None ) log_event = EventFactory.create_log_event(event_obj, self.logger) @@ -544,6 +549,7 @@ def test_create_impression_event__with_user_agent_when_bot_filtering_is_disabled True, 'test_user', {'$opt_user_agent': 'Chrome'}, + None ) log_event = EventFactory.create_log_event(event_obj, self.logger) @@ -920,3 +926,136 @@ def test_create_conversion_event__when_event_is_used_in_multiple_experiments(sel self._validate_event_object( log_event, EventFactory.EVENT_ENDPOINT, expected_params, EventFactory.HTTP_VERB, EventFactory.HTTP_HEADERS, ) + + def test_create_impression_event_with_cmab_uuid(self): + """ Test that create_impression_event creates LogEvent object with CMAB UUID in metadata. """ + + 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', + 'metadata': {'flag_key': '', + 'rule_key': 'rule_key', + 'rule_type': 'experiment', + 'variation_key': 'variation', + 'enabled': False, + 'cmab_uuid': 'test-cmab-uuid-123' + } + } + ], + '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', + '', + 'rule_key', + 'experiment', + False, + 'test_user', + None, + 'test-cmab-uuid-123' # cmab_uuid parameter + ) + + 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_without_cmab_uuid(self): + """ Test that create_impression_event creates LogEvent object without CMAB UUID when not provided. """ + + 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', + 'metadata': { + 'flag_key': '', + 'rule_key': 'rule_key', + 'rule_type': 'experiment', + 'variation_key': 'variation', + 'enabled': False + } + } + ], + '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', + '', + 'rule_key', + 'experiment', + False, + 'test_user', + None, + None # No cmab_uuid + ) + + log_event = EventFactory.create_log_event(event_obj, self.logger) + + # Verify no cmab_uuid in metadata + metadata = log_event.params['visitors'][0]['snapshots'][0]['decisions'][0]['metadata'] + self.assertNotIn('cmab_uuid', metadata) + + self._validate_event_object( + log_event, EventFactory.EVENT_ENDPOINT, expected_params, EventFactory.HTTP_VERB, EventFactory.HTTP_HEADERS, + ) diff --git a/tests/test_optimizely.py b/tests/test_optimizely.py index f494a766..c95b4113 100644 --- a/tests/test_optimizely.py +++ b/tests/test_optimizely.py @@ -4890,6 +4890,57 @@ def test_odp_events_not_sent_with_legacy_apis(self): client.close() + def test_get_variation_with_cmab_uuid(self): + """ Test that get_variation works correctly with CMAB UUID. """ + expected_cmab_uuid = "get-variation-cmab-uuid" + variation_result = { + 'variation': self.project_config.get_variation_from_id('test_experiment', '111129'), + 'cmab_uuid': expected_cmab_uuid, + 'reasons': [], + 'error': False + } + + with mock.patch( + 'optimizely.decision_service.DecisionService.get_variation', + return_value=variation_result, + ), mock.patch('optimizely.notification_center.NotificationCenter.send_notifications') as mock_broadcast: + variation = self.optimizely.get_variation('test_experiment', 'test_user') + self.assertEqual('variation', variation) + + # Verify decision notification is sent with correct parameters + mock_broadcast.assert_any_call( + enums.NotificationTypes.DECISION, + 'ab-test', + 'test_user', + {}, + {'experiment_key': 'test_experiment', 'variation_key': 'variation'}, + ) + + def test_get_variation_without_cmab_uuid(self): + """ Test that get_variation works correctly when CMAB UUID is None. """ + variation_result = { + 'variation': self.project_config.get_variation_from_id('test_experiment', '111129'), + 'cmab_uuid': None, + 'reasons': [], + 'error': False + } + + with mock.patch( + 'optimizely.decision_service.DecisionService.get_variation', + return_value=variation_result, + ), mock.patch('optimizely.notification_center.NotificationCenter.send_notifications') as mock_broadcast: + variation = self.optimizely.get_variation('test_experiment', 'test_user') + self.assertEqual('variation', variation) + + # Verify decision notification is sent correctly + mock_broadcast.assert_any_call( + enums.NotificationTypes.DECISION, + 'ab-test', + 'test_user', + {}, + {'experiment_key': 'test_experiment', 'variation_key': 'variation'}, + ) + class OptimizelyWithExceptionTest(base.BaseTest): def setUp(self): diff --git a/tests/test_user_context.py b/tests/test_user_context.py index 41064c42..3ae9be0d 100644 --- a/tests/test_user_context.py +++ b/tests/test_user_context.py @@ -297,7 +297,8 @@ def test_decide__feature_test(self): 'feature-test', expected.enabled, 'test_user', - {'browser': 'chrome'} + {'browser': 'chrome'}, + None ) def test_decide__feature_test__send_flag_decision_false(self): @@ -400,7 +401,8 @@ def test_decide_feature_rollout(self): 'rollout', expected.enabled, 'test_user', - user_attributes + user_attributes, + None ) # assert notification count @@ -563,7 +565,8 @@ def test_decide_feature_null_variation(self): 'rollout', expected.enabled, 'test_user', - {'browser': 'chrome'} + {'browser': 'chrome'}, + None ) def test_decide_feature_null_variation__send_flag_decision_false(self): @@ -840,7 +843,8 @@ def test_decide__option__exclude_variables(self): 'feature-test', expected.enabled, 'test_user', - {'browser': 'chrome'} + {'browser': 'chrome'}, + None ) def test_decide__option__include_reasons__feature_test(self): @@ -952,7 +956,8 @@ def test_decide__option__enabled_flags_only(self): 'rollout', expected.enabled, 'test_user', - user_attributes + user_attributes, + None ) def test_decide__default_options__with__options(self): @@ -1511,7 +1516,8 @@ def test_should_return_valid_decision_after_setting_and_removing_forced_decision 'feature-test', expected.enabled, 'test_user', - {} + {}, + None ) self.assertTrue('User "test_user" is in variation "control" of experiment test_experiment.' diff --git a/tests/test_user_event_factory.py b/tests/test_user_event_factory.py index 009ef05d..77f985d8 100644 --- a/tests/test_user_event_factory.py +++ b/tests/test_user_event_factory.py @@ -29,7 +29,7 @@ def test_impression_event(self): user_id = 'test_user' impression_event = UserEventFactory.create_impression_event(project_config, experiment, '111128', '', - 'rule_key', 'rule_type', True, user_id, None) + 'rule_key', 'rule_type', True, user_id, None, 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) @@ -51,7 +51,7 @@ def test_impression_event__with_attributes(self): user_attributes = {'test_attribute': 'test_value', 'boolean_key': True} impression_event = UserEventFactory.create_impression_event( - project_config, experiment, '111128', '', 'rule_key', 'rule_type', True, user_id, user_attributes + project_config, experiment, '111128', '', 'rule_key', 'rule_type', True, user_id, user_attributes, None ) expected_attrs = EventFactory.build_attribute_list(user_attributes, project_config) @@ -121,3 +121,119 @@ def test_conversion_event__with_event_tags(self): [x.__dict__ for x in expected_attrs], [x.__dict__ for x in conversion_event.visitor_attributes], ) self.assertEqual(event_tags, conversion_event.event_tags) + + def test_create_impression_user_event_with_cmab_uuid(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' + cmab_uuid = '123e4567-e89b-12d3-a456-426614174000' + + impression_event = UserEventFactory.create_impression_event( + project_config, experiment, '111128', '', 'rule_key', 'rule_type', True, user_id, None, cmab_uuid + ) + + # Verify basic impression event properties + 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) + + # Verify CMAB UUID is properly set + self.assertEqual(cmab_uuid, impression_event.cmab_uuid) + + # Test that the CMAB UUID is included in the event payload when creating a log event + from optimizely.event.event_factory import EventFactory + log_event = EventFactory.create_log_event(impression_event, self.logger) + + self.assertIsNotNone(log_event) + event_params = log_event.params + + # Verify the event structure contains the CMAB UUID in metadata + self.assertIn('visitors', event_params) + self.assertEqual(len(event_params['visitors']), 1) + + visitor = event_params['visitors'][0] + self.assertIn('snapshots', visitor) + self.assertEqual(len(visitor['snapshots']), 1) + + snapshot = visitor['snapshots'][0] + self.assertIn('decisions', snapshot) + self.assertEqual(len(snapshot['decisions']), 1) + + decision = snapshot['decisions'][0] + self.assertIn('metadata', decision) + + metadata = decision['metadata'] + self.assertIn('cmab_uuid', metadata) + self.assertEqual(cmab_uuid, metadata['cmab_uuid']) + + # Verify other metadata fields are present + self.assertEqual('rule_key', metadata['rule_key']) + self.assertEqual('rule_type', metadata['rule_type']) + self.assertEqual(True, metadata['enabled']) + self.assertEqual(variation.key, metadata['variation_key']) + + def test_create_impression_user_event_without_cmab_uuid(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', '', 'rule_key', 'rule_type', True, user_id, None, None + ) + + # Verify basic impression event properties + 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) + + # Verify CMAB UUID is None when not provided + self.assertIsNone(impression_event.cmab_uuid) + + # Test that the CMAB UUID is not included in the event payload when creating a log event + from optimizely.event.event_factory import EventFactory + log_event = EventFactory.create_log_event(impression_event, self.logger) + + self.assertIsNotNone(log_event) + event_params = log_event.params + + # Verify the event structure does not contain CMAB UUID in metadata + self.assertIn('visitors', event_params) + self.assertEqual(len(event_params['visitors']), 1) + + visitor = event_params['visitors'][0] + self.assertIn('snapshots', visitor) + self.assertEqual(len(visitor['snapshots']), 1) + + snapshot = visitor['snapshots'][0] + self.assertIn('decisions', snapshot) + self.assertEqual(len(snapshot['decisions']), 1) + + decision = snapshot['decisions'][0] + self.assertIn('metadata', decision) + + metadata = decision['metadata'] + + # Verify CMAB UUID is not present in metadata when not provided + self.assertNotIn('cmab_uuid', metadata) + + # Verify other metadata fields are still present + self.assertEqual('rule_key', metadata['rule_key']) + self.assertEqual('rule_type', metadata['rule_type']) + self.assertEqual(True, metadata['enabled']) + self.assertEqual(variation.key, metadata['variation_key'])