Skip to content

Commit d9e8f83

Browse files
committed
[FSSDK-11458] Python - Add SDK Multi-Region Support for Data Hosting
1 parent 82ec019 commit d9e8f83

File tree

8 files changed

+158
-11
lines changed

8 files changed

+158
-11
lines changed

optimizely/event/event_factory.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,10 @@ class EventFactory:
4242
to record the events via the Optimizely Events API ("https://developers.optimizely.com/x/events/api/index.html")
4343
"""
4444

45-
EVENT_ENDPOINT: Final = 'https://logx.optimizely.com/v1/events'
45+
EVENT_ENDPOINTS: Final = {
46+
'US': 'https://logx.optimizely.com/v1/events',
47+
'EU': 'https://eu.logx.optimizely.com/v1/events'
48+
}
4649
HTTP_VERB: Final = 'POST'
4750
HTTP_HEADERS: Final = {'Content-Type': 'application/json'}
4851
ACTIVATE_EVENT_KEY: Final = 'campaign_activated'
@@ -97,7 +100,10 @@ def create_log_event(
97100

98101
event_params = event_batch.get_event_params()
99102

100-
return log_event.LogEvent(cls.EVENT_ENDPOINT, event_params, cls.HTTP_VERB, cls.HTTP_HEADERS)
103+
region = user_context.region or 'US'
104+
endpoint = cls.EVENT_ENDPOINTS.get(region)
105+
106+
return log_event.LogEvent(endpoint, event_params, cls.HTTP_VERB, cls.HTTP_HEADERS)
101107

102108
@classmethod
103109
def _create_visitor(cls, event: Optional[user_event.UserEvent], logger: Logger) -> Optional[payload.Visitor]:

optimizely/event/user_event.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from sys import version_info
1818

1919
from optimizely import version
20+
from optimizely.project_config import Region
2021

2122

2223
if version_info < (3, 8):
@@ -97,10 +98,11 @@ def __init__(
9798
class EventContext:
9899
""" Class respresenting User Event Context. """
99100

100-
def __init__(self, account_id: str, project_id: str, revision: str, anonymize_ip: bool):
101+
def __init__(self, account_id: str, project_id: str, revision: str, anonymize_ip: bool, region: Region):
101102
self.account_id = account_id
102103
self.project_id = project_id
103104
self.revision = revision
104105
self.client_name = CLIENT_NAME
105106
self.client_version = version.__version__
106107
self.anonymize_ip = anonymize_ip
108+
self.region = region or 'US'

optimizely/event/user_event_factory.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ def create_impression_event(
7676
variation = project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id)
7777

7878
event_context = user_event.EventContext(
79-
project_config.account_id, project_config.project_id, project_config.revision, project_config.anonymize_ip,
79+
project_config.account_id, project_config.project_id, project_config.revision, project_config.anonymize_ip, project_config.region
8080
)
8181

8282
return user_event.ImpressionEvent(
@@ -115,7 +115,7 @@ def create_conversion_event(
115115
"""
116116

117117
event_context = user_event.EventContext(
118-
project_config.account_id, project_config.project_id, project_config.revision, project_config.anonymize_ip,
118+
project_config.account_id, project_config.project_id, project_config.revision, project_config.anonymize_ip, project_config.region
119119
)
120120

121121
return user_event.ConversionEvent(

optimizely/event_builder.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,10 @@ class EventBuilder:
5454
""" Class which encapsulates methods to build events for tracking
5555
impressions and conversions using the new V3 event API (batch). """
5656

57-
EVENTS_URL: Final = 'https://logx.optimizely.com/v1/events'
57+
EVENTS_URLS: Final = {
58+
'US': 'https://logx.optimizely.com/v1/events',
59+
'EU': 'https://eu.logx.optimizely.com/v1/events'
60+
}
5861
HTTP_VERB: Final = 'POST'
5962
HTTP_HEADERS: Final = {'Content-Type': 'application/json'}
6063

@@ -266,7 +269,10 @@ def create_impression_event(
266269

267270
params[self.EventParams.USERS][0][self.EventParams.SNAPSHOTS].append(impression_params)
268271

269-
return Event(self.EVENTS_URL, params, http_verb=self.HTTP_VERB, headers=self.HTTP_HEADERS)
272+
region = project_config.region or 'US'
273+
events_url = self.EVENTS_URLS.get(region)
274+
275+
return Event(events_url, params, http_verb=self.HTTP_VERB, headers=self.HTTP_HEADERS)
270276

271277
def create_conversion_event(
272278
self, project_config: ProjectConfig, event_key: str,
@@ -289,4 +295,8 @@ def create_conversion_event(
289295
conversion_params = self._get_required_params_for_conversion(project_config, event_key, event_tags)
290296

291297
params[self.EventParams.USERS][0][self.EventParams.SNAPSHOTS].append(conversion_params)
292-
return Event(self.EVENTS_URL, params, http_verb=self.HTTP_VERB, headers=self.HTTP_HEADERS)
298+
299+
region = project_config.region or 'US'
300+
events_url = self.EVENTS_URLS.get(region)
301+
302+
return Event(events_url, params, http_verb=self.HTTP_VERB, headers=self.HTTP_HEADERS)

optimizely/project_config.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@
2222
from .helpers import types
2323

2424
if version_info < (3, 8):
25-
from typing_extensions import Final
25+
from typing_extensions import Final, Literal
2626
else:
27-
from typing import Final # type: ignore
27+
from typing import Final, Literal # type: ignore
2828

2929
if TYPE_CHECKING:
3030
# prevent circular dependenacy by skipping import at runtime
@@ -41,6 +41,8 @@
4141

4242
EntityClass = TypeVar('EntityClass')
4343

44+
Region = Literal['US', 'EU']
45+
4446

4547
class ProjectConfig:
4648
""" Representation of the Optimizely project config. """
@@ -84,6 +86,7 @@ def __init__(self, datafile: str | bytes, logger: Logger, error_handler: Any):
8486
self.public_key_for_odp: Optional[str] = None
8587
self.host_for_odp: Optional[str] = None
8688
self.all_segments: list[str] = []
89+
self.region: Region = config.get('region') or 'US'
8790

8891
# Utility maps for quick lookup
8992
self.group_id_map: dict[str, entities.Group] = self._generate_key_map(self.groups, 'id', entities.Group)

tests/test_config.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from optimizely import logger
2222
from optimizely import optimizely
2323
from optimizely.helpers import enums
24-
from optimizely.project_config import ProjectConfig
24+
from optimizely.project_config import ProjectConfig, Region
2525
from . import base
2626

2727

@@ -154,6 +154,31 @@ def test_init(self):
154154
self.assertEqual(expected_variation_key_map, self.project_config.variation_key_map)
155155
self.assertEqual(expected_variation_id_map, self.project_config.variation_id_map)
156156

157+
def test_region_when_no_region(self):
158+
""" Test that region defaults to 'US' when not specified in the config. """
159+
config_dict = copy.deepcopy(self.config_dict_with_multiple_experiments)
160+
opt_obj = optimizely.Optimizely(json.dumps(config_dict))
161+
project_config = opt_obj.config_manager.get_config()
162+
self.assertEqual(project_config.region, Region.US)
163+
164+
165+
def test_region_when_specified_in_datafile(self):
166+
""" Test that region is set to 'US' when specified in the config. """
167+
config_dict_us = copy.deepcopy(self.config_dict_with_multiple_experiments)
168+
config_dict_us['region'] = 'US'
169+
opt_obj_us = optimizely.Optimizely(json.dumps(config_dict_us))
170+
project_config_us = opt_obj_us.config_manager.get_config()
171+
self.assertEqual(project_config_us.region, Region.US)
172+
173+
""" Test that region is set to 'EU' when specified in the config. """
174+
config_dict_eu = copy.deepcopy(self.config_dict_with_multiple_experiments)
175+
config_dict_eu['region'] = 'EU'
176+
opt_obj_eu = optimizely.Optimizely(json.dumps(config_dict_eu))
177+
project_config_eu = opt_obj_eu.config_manager.get_config()
178+
self.assertEqual(project_config_eu.region, Region.EU)
179+
180+
181+
157182
def test_cmab_field_population(self):
158183
""" Test that the cmab field is populated correctly in experiments."""
159184

tests/test_optimizely.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,7 @@ def test_activate(self):
365365
'enrich_decisions': True,
366366
'anonymize_ip': False,
367367
'revision': '42',
368+
'region': 'US',
368369
}
369370

370371
log_event = EventFactory.create_log_event(mock_process.call_args[0][0], self.optimizely.logger)
@@ -385,6 +386,76 @@ def test_activate(self):
385386
{'Content-Type': 'application/json'},
386387
)
387388

389+
def test_activate_with_eu_hosting(self):
390+
""" Test that activate calls process with right params and returns expected variation. """
391+
""" Test EU hosting for activate method. """
392+
393+
with mock.patch(
394+
'optimizely.decision_service.DecisionService.get_variation',
395+
return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []),
396+
) as mock_decision, mock.patch('time.time', return_value=42), mock.patch(
397+
'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c'
398+
), mock.patch(
399+
'optimizely.event.event_processor.BatchEventProcessor.process'
400+
) as mock_process:
401+
self.assertEqual('variation', self.optimizely.activate('test_experiment', 'test_user'))
402+
403+
expected_params = {
404+
'account_id': '12001',
405+
'project_id': '111001',
406+
'visitors': [
407+
{
408+
'visitor_id': 'test_user',
409+
'attributes': [],
410+
'snapshots': [
411+
{
412+
'decisions': [
413+
{'variation_id': '111129', 'experiment_id': '111127', 'campaign_id': '111182',
414+
'metadata': {'flag_key': '',
415+
'rule_key': 'test_experiment',
416+
'rule_type': 'experiment',
417+
'variation_key': 'variation',
418+
'enabled': True},
419+
}
420+
],
421+
'events': [
422+
{
423+
'timestamp': 42000,
424+
'entity_id': '111182',
425+
'uuid': 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c',
426+
'key': 'campaign_activated',
427+
}
428+
],
429+
}
430+
],
431+
}
432+
],
433+
'client_version': version.__version__,
434+
'client_name': 'python-sdk',
435+
'enrich_decisions': True,
436+
'anonymize_ip': False,
437+
'revision': '42',
438+
'region': 'EU',
439+
}
440+
441+
log_event = EventFactory.create_log_event(mock_process.call_args[0][0], self.optimizely.logger)
442+
user_context = mock_decision.call_args[0][2]
443+
user_profile_tracker = mock_decision.call_args[0][3]
444+
445+
mock_decision.assert_called_once_with(
446+
self.project_config, self.project_config.get_experiment_from_key('test_experiment'),
447+
user_context, user_profile_tracker
448+
)
449+
self.assertEqual(1, mock_process.call_count)
450+
451+
self._validate_event_object(
452+
log_event.__dict__,
453+
'https://eu.logx.optimizely.com/v1/events',
454+
expected_params,
455+
'POST',
456+
{'Content-Type': 'application/json'},
457+
)
458+
388459
def test_add_activate_remove_clear_listener(self):
389460
callbackhit = [False]
390461
""" Test adding a listener activate passes correctly and gets called"""
@@ -764,6 +835,7 @@ def test_activate__with_attributes__audience_match(self):
764835
'enrich_decisions': True,
765836
'anonymize_ip': False,
766837
'revision': '42',
838+
'region': 'US',
767839
}
768840

769841
log_event = EventFactory.create_log_event(mock_process.call_args[0][0], self.optimizely.logger)
@@ -848,6 +920,7 @@ def test_activate__with_attributes_of_different_types(self):
848920
'enrich_decisions': True,
849921
'anonymize_ip': False,
850922
'revision': '42',
923+
'region': 'US',
851924
}
852925

853926
log_event = EventFactory.create_log_event(mock_process.call_args[0][0], self.optimizely.logger)
@@ -1044,6 +1117,7 @@ def test_activate__with_attributes__audience_match__forced_bucketing(self):
10441117
'enrich_decisions': True,
10451118
'anonymize_ip': False,
10461119
'revision': '42',
1120+
'region': 'US',
10471121
}
10481122

10491123
log_event = EventFactory.create_log_event(mock_process.call_args[0][0], self.optimizely.logger)
@@ -1120,6 +1194,7 @@ def test_activate__with_attributes__audience_match__bucketing_id_provided(self):
11201194
'enrich_decisions': True,
11211195
'anonymize_ip': False,
11221196
'revision': '42',
1197+
'region': 'US',
11231198
}
11241199

11251200
log_event = EventFactory.create_log_event(mock_process.call_args[0][0], self.optimizely.logger)
@@ -1288,6 +1363,7 @@ def test_track__with_attributes(self):
12881363
'enrich_decisions': True,
12891364
'anonymize_ip': False,
12901365
'revision': '42',
1366+
'region': 'US',
12911367
}
12921368

12931369
log_event = EventFactory.create_log_event(mock_process.call_args[0][0], self.optimizely.logger)
@@ -1424,6 +1500,7 @@ def test_track__with_attributes__bucketing_id_provided(self):
14241500
'enrich_decisions': True,
14251501
'anonymize_ip': False,
14261502
'revision': '42',
1503+
'region': 'US',
14271504
}
14281505

14291506
log_event = EventFactory.create_log_event(mock_process.call_args[0][0], self.optimizely.logger)
@@ -1504,6 +1581,7 @@ def test_track__with_event_tags(self):
15041581
'enrich_decisions': True,
15051582
'anonymize_ip': False,
15061583
'revision': '42',
1584+
'region': 'US',
15071585
}
15081586
log_event = EventFactory.create_log_event(mock_process.call_args[0][0], self.optimizely.logger)
15091587

@@ -1560,6 +1638,7 @@ def test_track__with_event_tags_revenue(self):
15601638
'account_id': '12001',
15611639
'anonymize_ip': False,
15621640
'revision': '42',
1641+
'region': 'US',
15631642
}
15641643

15651644
log_event = EventFactory.create_log_event(mock_process.call_args[0][0], self.optimizely.logger)
@@ -1648,6 +1727,7 @@ def test_track__with_event_tags__forced_bucketing(self):
16481727
'enrich_decisions': True,
16491728
'anonymize_ip': False,
16501729
'revision': '42',
1730+
'region': 'US',
16511731
}
16521732

16531733
log_event = EventFactory.create_log_event(mock_process.call_args[0][0], self.optimizely.logger)
@@ -1703,6 +1783,7 @@ def test_track__with_invalid_event_tags(self):
17031783
'account_id': '12001',
17041784
'anonymize_ip': False,
17051785
'revision': '42',
1786+
'region': 'US',
17061787
}
17071788

17081789
log_event = EventFactory.create_log_event(mock_process.call_args[0][0], self.optimizely.logger)
@@ -2103,6 +2184,7 @@ def test_is_feature_enabled__returns_true_for_feature_experiment_if_feature_enab
21032184
'enrich_decisions': True,
21042185
'anonymize_ip': False,
21052186
'revision': '1',
2187+
'region': 'US',
21062188
}
21072189

21082190
log_event = EventFactory.create_log_event(mock_process.call_args[0][0], self.optimizely.logger)
@@ -2204,6 +2286,7 @@ def test_is_feature_enabled__returns_false_for_feature_experiment_if_feature_dis
22042286
'enrich_decisions': True,
22052287
'anonymize_ip': False,
22062288
'revision': '1',
2289+
'region': 'US',
22072290
}
22082291
log_event = EventFactory.create_log_event(mock_process.call_args[0][0], self.optimizely.logger)
22092292

@@ -2356,6 +2439,7 @@ def test_is_feature_enabled__returns_true_for_feature_rollout_if_feature_enabled
23562439
'enrich_decisions': True,
23572440
'anonymize_ip': False,
23582441
'revision': '1',
2442+
'region': 'US',
23592443
}
23602444
log_event = EventFactory.create_log_event(mock_process.call_args[0][0], self.optimizely.logger)
23612445

0 commit comments

Comments
 (0)