Skip to content

Commit 1e8b9ba

Browse files
[FSSDK-11184] Update: Send CMAB uuid in impression events (#458)
* update: integrate CMAB components into OptimizelyFactory * update: add cmab_service parameter to Optimizely constructor for CMAB support * update: add docstring to DefaultCmabService class for improved documentation * update: implement CMAB support in bucketer and decision service, revert OptimizelyFactory * linting fix * update: add cmab_uuid handling in DecisionService and related tests * - updated function bucket_to_entity_id - test_optimizely.py fixed to expect new Decision objects * update: add None parameter to Decision constructor in user context tests * update: enhance CMAB decision handling and add related tests * update: fix logger message formatting in CMAB experiment tests * mypy fix * update: refine traffic allocation type hints and key naming in bucketer and decision service * update: remove unused import of cast in bucketer.py * update: fix return type for numeric_metric_value in get_numeric_value and ensure key is of bytes type in hash128 * update: specify type hint for numeric_metric_value in get_numeric_value function * update: fix logger reference in DefaultCmabClient initialization and add __init__.py for cmab module * update: enhance error logging for CMAB fetch failures with detailed messages and add a test for handling 500 errors * update: enhance decision result handling by introducing VariationResult and updating get_variation return type to include detailed error information * update: refactor get_variation return structure and change tests accordingly * -Error propagated to optimizely.py -test cases changed to handle return type dicts of DecisionResult and VariationResult * update: add cmab_uuid parameter to impression events * update: add None parameter to impression events in decision tests * update: modify get_variation to return VariationResult and adjust related logic for improved variation handling * update: unit test fixes * update: include CMAB UUID in activation and add corresponding tests * update: add tests for get_variation with and without CMAB UUID * Revert "update: unit test fixes" This reverts commit d2fc631. * Revert "update: modify get_variation to return VariationResult and adjust related logic for improved variation handling" This reverts commit b901c5f. * update: make cmab_uuid parameter optional in _send_impression_event method * chore: trigger CI by turning on python flag * update: new class method to handle optimizely error decisions * fix unit test * fix: update error logging format for CMAB fetch failures * chore: trigger CI * update: enhance decision service to handle error states and improve bucketing logic * update: remove debug print statement from Optimizely class * update: enhance bucketing logic to support CMAB traffic allocations * update: improve error logging for CMAB decision fetch failures * update: improve logging and error handling in bucketer and decision service * update: add test for CMAB UUID handling in decision events. Removed two redundant tests on legacy api. * update: enhance CMAB UUID handling in decide method and verify dispatched events
1 parent 81f5be9 commit 1e8b9ba

File tree

9 files changed

+368
-17
lines changed

9 files changed

+368
-17
lines changed

optimizely/event/event_factory.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,9 @@ def _create_visitor(cls, event: Optional[user_event.UserEvent], logger: Logger)
123123
experiment_layerId = event.experiment.layerId
124124
experiment_id = event.experiment.id
125125

126-
metadata = payload.Metadata(event.flag_key, event.rule_key, event.rule_type, variation_key, event.enabled)
126+
metadata = payload.Metadata(event.flag_key, event.rule_key,
127+
event.rule_type, variation_key,
128+
event.enabled, event.cmab_uuid)
127129
decision = payload.Decision(experiment_layerId, experiment_id, variation_id, metadata)
128130
snapshot_event = payload.SnapshotEvent(
129131
experiment_layerId, event.uuid, cls.ACTIVATE_EVENT_KEY, event.timestamp,

optimizely/event/payload.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,15 @@ def __init__(self, campaign_id: str, experiment_id: str, variation_id: str, meta
8181
class Metadata:
8282
""" Class respresenting Metadata. """
8383

84-
def __init__(self, flag_key: str, rule_key: str, rule_type: str, variation_key: str, enabled: bool):
84+
def __init__(self, flag_key: str, rule_key: str, rule_type: str,
85+
variation_key: str, enabled: bool, cmab_uuid: Optional[str] = None):
8586
self.flag_key = flag_key
8687
self.rule_key = rule_key
8788
self.rule_type = rule_type
8889
self.variation_key = variation_key
8990
self.enabled = enabled
91+
if cmab_uuid:
92+
self.cmab_uuid = cmab_uuid
9093

9194

9295
class Snapshot:

optimizely/event/user_event.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,8 @@ def __init__(
7070
rule_key: str,
7171
rule_type: str,
7272
enabled: bool,
73-
bot_filtering: Optional[bool] = None
73+
bot_filtering: Optional[bool] = None,
74+
cmab_uuid: Optional[str] = None
7475
):
7576
super().__init__(event_context, user_id, visitor_attributes, bot_filtering)
7677
self.experiment = experiment
@@ -79,6 +80,7 @@ def __init__(
7980
self.rule_key = rule_key
8081
self.rule_type = rule_type
8182
self.enabled = enabled
83+
self.cmab_uuid = cmab_uuid
8284

8385

8486
class ConversionEvent(UserEvent):

optimizely/event/user_event_factory.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ def create_impression_event(
4040
rule_type: str,
4141
enabled: bool,
4242
user_id: str,
43-
user_attributes: Optional[UserAttributes]
43+
user_attributes: Optional[UserAttributes],
44+
cmab_uuid: Optional[str]
4445
) -> Optional[user_event.ImpressionEvent]:
4546
""" Create impression Event to be sent to the logging endpoint.
4647
@@ -90,6 +91,7 @@ def create_impression_event(
9091
rule_type,
9192
enabled,
9293
project_config.get_bot_filtering_value(),
94+
cmab_uuid,
9395
)
9496

9597
@classmethod

optimizely/optimizely.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
from typing import TYPE_CHECKING, Any, Optional
1717

18+
1819
from . import decision_service
1920
from . import entities
2021
from . import event_builder
@@ -260,7 +261,7 @@ def _validate_user_inputs(
260261
def _send_impression_event(
261262
self, project_config: project_config.ProjectConfig, experiment: Optional[entities.Experiment],
262263
variation: Optional[entities.Variation], flag_key: str, rule_key: str, rule_type: str,
263-
enabled: bool, user_id: str, attributes: Optional[UserAttributes]
264+
enabled: bool, user_id: str, attributes: Optional[UserAttributes], cmab_uuid: Optional[str] = None
264265
) -> None:
265266
""" Helper method to send impression event.
266267
@@ -280,7 +281,9 @@ def _send_impression_event(
280281

281282
variation_id = variation.id if variation is not None else None
282283
user_event = user_event_factory.UserEventFactory.create_impression_event(
283-
project_config, experiment, variation_id, flag_key, rule_key, rule_type, enabled, user_id, attributes
284+
project_config, experiment, variation_id,
285+
flag_key, rule_key, rule_type,
286+
enabled, user_id, attributes, cmab_uuid
284287
)
285288

286289
if user_event is None:
@@ -719,6 +722,8 @@ def is_feature_enabled(self, feature_key: str, user_id: str, attributes: Optiona
719722
user_context = OptimizelyUserContext(self, self.logger, user_id, attributes, False)
720723

721724
decision = self.decision_service.get_variation_for_feature(project_config, feature, user_context)['decision']
725+
cmab_uuid = decision.cmab_uuid
726+
722727
is_source_experiment = decision.source == enums.DecisionSources.FEATURE_TEST
723728
is_source_rollout = decision.source == enums.DecisionSources.ROLLOUT
724729

@@ -729,7 +734,7 @@ def is_feature_enabled(self, feature_key: str, user_id: str, attributes: Optiona
729734
if (is_source_rollout or not decision.variation) and project_config.get_send_flag_decisions_value():
730735
self._send_impression_event(
731736
project_config, decision.experiment, decision.variation, feature.key, decision.experiment.key if
732-
decision.experiment else '', str(decision.source), feature_enabled, user_id, attributes
737+
decision.experiment else '', str(decision.source), feature_enabled, user_id, attributes, cmab_uuid
733738
)
734739

735740
# 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
740745
}
741746
self._send_impression_event(
742747
project_config, decision.experiment, decision.variation, feature.key, decision.experiment.key,
743-
str(decision.source), feature_enabled, user_id, attributes
748+
str(decision.source), feature_enabled, user_id, attributes, cmab_uuid
744749
)
745750

746751
if feature_enabled:
@@ -1193,7 +1198,9 @@ def _create_optimizely_decision(
11931198
flag_decision.variation,
11941199
flag_key, rule_key or '',
11951200
str(decision_source), feature_enabled,
1196-
user_id, attributes)
1201+
user_id, attributes,
1202+
flag_decision.cmab_uuid
1203+
)
11971204

11981205
decision_event_dispatched = True
11991206

tests/test_event_factory.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ def test_create_impression_event(self):
113113
False,
114114
'test_user',
115115
None,
116+
None
116117
)
117118

118119
log_event = EventFactory.create_log_event(event_obj, self.logger)
@@ -177,6 +178,7 @@ def test_create_impression_event__with_attributes(self):
177178
True,
178179
'test_user',
179180
{'test_attribute': 'test_value'},
181+
None
180182
)
181183

182184
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):
239241
True,
240242
'test_user',
241243
{'do_you_know_me': 'test_value'},
244+
None
242245
)
243246

244247
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(
394397
False,
395398
'test_user',
396399
{'$opt_user_agent': 'Edge'},
400+
None
397401
)
398402

399403
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
466470
False,
467471
'test_user',
468472
None,
473+
None
469474
)
470475

471476
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
544549
True,
545550
'test_user',
546551
{'$opt_user_agent': 'Chrome'},
552+
None
547553
)
548554

549555
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
920926
self._validate_event_object(
921927
log_event, EventFactory.EVENT_ENDPOINT, expected_params, EventFactory.HTTP_VERB, EventFactory.HTTP_HEADERS,
922928
)
929+
930+
def test_create_impression_event_with_cmab_uuid(self):
931+
""" Test that create_impression_event creates LogEvent object with CMAB UUID in metadata. """
932+
933+
expected_params = {
934+
'account_id': '12001',
935+
'project_id': '111001',
936+
'visitors': [
937+
{
938+
'visitor_id': 'test_user',
939+
'attributes': [],
940+
'snapshots': [
941+
{
942+
'decisions': [
943+
{'variation_id': '111129', 'experiment_id': '111127', 'campaign_id': '111182',
944+
'metadata': {'flag_key': '',
945+
'rule_key': 'rule_key',
946+
'rule_type': 'experiment',
947+
'variation_key': 'variation',
948+
'enabled': False,
949+
'cmab_uuid': 'test-cmab-uuid-123'
950+
}
951+
}
952+
],
953+
'events': [
954+
{
955+
'timestamp': 42123,
956+
'entity_id': '111182',
957+
'uuid': 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c',
958+
'key': 'campaign_activated',
959+
}
960+
],
961+
}
962+
],
963+
}
964+
],
965+
'client_name': 'python-sdk',
966+
'client_version': version.__version__,
967+
'enrich_decisions': True,
968+
'anonymize_ip': False,
969+
'revision': '42',
970+
}
971+
972+
with mock.patch('time.time', return_value=42.123), mock.patch(
973+
'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c'
974+
):
975+
event_obj = UserEventFactory.create_impression_event(
976+
self.project_config,
977+
self.project_config.get_experiment_from_key('test_experiment'),
978+
'111129',
979+
'',
980+
'rule_key',
981+
'experiment',
982+
False,
983+
'test_user',
984+
None,
985+
'test-cmab-uuid-123' # cmab_uuid parameter
986+
)
987+
988+
log_event = EventFactory.create_log_event(event_obj, self.logger)
989+
990+
self._validate_event_object(
991+
log_event, EventFactory.EVENT_ENDPOINT, expected_params, EventFactory.HTTP_VERB, EventFactory.HTTP_HEADERS,
992+
)
993+
994+
def test_create_impression_event_without_cmab_uuid(self):
995+
""" Test that create_impression_event creates LogEvent object without CMAB UUID when not provided. """
996+
997+
expected_params = {
998+
'account_id': '12001',
999+
'project_id': '111001',
1000+
'visitors': [
1001+
{
1002+
'visitor_id': 'test_user',
1003+
'attributes': [],
1004+
'snapshots': [
1005+
{
1006+
'decisions': [
1007+
{
1008+
'variation_id': '111129', 'experiment_id': '111127', 'campaign_id': '111182',
1009+
'metadata': {
1010+
'flag_key': '',
1011+
'rule_key': 'rule_key',
1012+
'rule_type': 'experiment',
1013+
'variation_key': 'variation',
1014+
'enabled': False
1015+
}
1016+
}
1017+
],
1018+
'events': [
1019+
{
1020+
'timestamp': 42123,
1021+
'entity_id': '111182',
1022+
'uuid': 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c',
1023+
'key': 'campaign_activated',
1024+
}
1025+
],
1026+
}
1027+
],
1028+
}
1029+
],
1030+
'client_name': 'python-sdk',
1031+
'client_version': version.__version__,
1032+
'enrich_decisions': True,
1033+
'anonymize_ip': False,
1034+
'revision': '42',
1035+
}
1036+
1037+
with mock.patch('time.time', return_value=42.123), mock.patch(
1038+
'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c'
1039+
):
1040+
event_obj = UserEventFactory.create_impression_event(
1041+
self.project_config,
1042+
self.project_config.get_experiment_from_key('test_experiment'),
1043+
'111129',
1044+
'',
1045+
'rule_key',
1046+
'experiment',
1047+
False,
1048+
'test_user',
1049+
None,
1050+
None # No cmab_uuid
1051+
)
1052+
1053+
log_event = EventFactory.create_log_event(event_obj, self.logger)
1054+
1055+
# Verify no cmab_uuid in metadata
1056+
metadata = log_event.params['visitors'][0]['snapshots'][0]['decisions'][0]['metadata']
1057+
self.assertNotIn('cmab_uuid', metadata)
1058+
1059+
self._validate_event_object(
1060+
log_event, EventFactory.EVENT_ENDPOINT, expected_params, EventFactory.HTTP_VERB, EventFactory.HTTP_HEADERS,
1061+
)

tests/test_optimizely.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5750,3 +5750,77 @@ def test_decide_returns_error_decision_when_decision_service_fails(self):
57505750
self.assertIsNone(decision.rule_key)
57515751
self.assertEqual(decision.flag_key, 'test_feature_in_experiment')
57525752
self.assertIn('CMAB service failed to fetch decision', decision.reasons)
5753+
5754+
def test_decide_includes_cmab_uuid_in_dispatched_event(self):
5755+
"""Test that decide dispatches event with correct CMAB UUID."""
5756+
import copy
5757+
from typing import List
5758+
config_dict = copy.deepcopy(self.config_dict_with_features)
5759+
config_dict['experiments'][0]['cmab'] = {'attributeIds': ['808797688', '808797689'], 'trafficAllocation': 4000}
5760+
config_dict['experiments'][0]['trafficAllocation'] = []
5761+
5762+
class TestEventDispatcher:
5763+
"""Custom event dispatcher for testing that captures dispatched events."""
5764+
5765+
def __init__(self):
5766+
self.dispatched_events: List[event_builder.Event] = []
5767+
5768+
def dispatch_event(self, event: event_builder.Event) -> None:
5769+
"""Capture the event instead of actually dispatching it."""
5770+
self.dispatched_events.append(event)
5771+
5772+
test_dispatcher = TestEventDispatcher()
5773+
5774+
opt_obj = optimizely.Optimizely(json.dumps(config_dict), event_dispatcher=test_dispatcher)
5775+
user_context = opt_obj.create_user_context('test_user')
5776+
project_config = opt_obj.config_manager.get_config()
5777+
5778+
# Mock decision service to return a CMAB result
5779+
expected_cmab_uuid = 'uuid-cmab'
5780+
mock_experiment = project_config.get_experiment_from_key('test_experiment')
5781+
mock_variation = project_config.get_variation_from_id('test_experiment', '111129')
5782+
5783+
# Create decision with CMAB UUID
5784+
decision_with_cmab = decision_service.Decision(
5785+
mock_experiment,
5786+
mock_variation,
5787+
enums.DecisionSources.FEATURE_TEST,
5788+
expected_cmab_uuid
5789+
)
5790+
5791+
# Mock the decision service method that's actually called by decide
5792+
with mock.patch.object(
5793+
opt_obj.decision_service, 'get_variations_for_feature_list',
5794+
return_value=[{
5795+
'decision': decision_with_cmab,
5796+
'reasons': [],
5797+
'error': False
5798+
}]
5799+
):
5800+
# Call decide
5801+
decision = user_context.decide('test_feature_in_experiment')
5802+
5803+
# Verify the decision contains the expected information
5804+
self.assertTrue(decision.enabled)
5805+
self.assertEqual(decision.variation_key, 'variation')
5806+
self.assertEqual(decision.rule_key, 'test_experiment')
5807+
self.assertEqual(decision.flag_key, 'test_feature_in_experiment')
5808+
5809+
# Verify an event was dispatched
5810+
time.sleep(0.1)
5811+
self.assertEqual(len(test_dispatcher.dispatched_events), 1)
5812+
5813+
dispatched_event = test_dispatcher.dispatched_events[0]
5814+
5815+
# Verify the structure exists before accessing
5816+
self.assertIn('visitors', dispatched_event.params)
5817+
self.assertTrue(len(dispatched_event.params['visitors']) > 0)
5818+
self.assertIn('snapshots', dispatched_event.params['visitors'][0])
5819+
self.assertTrue(len(dispatched_event.params['visitors'][0]['snapshots']) > 0)
5820+
self.assertIn('decisions', dispatched_event.params['visitors'][0]['snapshots'][0])
5821+
self.assertTrue(len(dispatched_event.params['visitors'][0]['snapshots'][0]['decisions']) > 0)
5822+
5823+
# Get the metadata and assert CMAB UUID
5824+
metadata = dispatched_event.params['visitors'][0]['snapshots'][0]['decisions'][0]['metadata']
5825+
self.assertIn('cmab_uuid', metadata)
5826+
self.assertEqual(metadata['cmab_uuid'], expected_cmab_uuid)

0 commit comments

Comments
 (0)