optimizely-sdk 5.4.0__tar.gz → 5.6.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/CHANGELOG.md +49 -0
- {optimizely-sdk-5.4.0/optimizely_sdk.egg-info → optimizely_sdk-5.6.0}/PKG-INFO +78 -3
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/cmab/cmab_service.py +1 -1
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/config_manager.py +6 -9
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/decision_service.py +49 -4
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/entities.py +16 -4
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/event/event_factory.py +11 -3
- optimizely_sdk-5.6.0/optimizely/event/event_id_normalizer.py +93 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/event/payload.py +8 -2
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/event_builder.py +12 -6
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/event_dispatcher.py +1 -1
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/helpers/enums.py +8 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/helpers/types.py +7 -2
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/helpers/validator.py +2 -2
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/odp/odp_event_manager.py +31 -3
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/odp/odp_manager.py +2 -2
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/optimizely.py +2 -2
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/optimizely_user_context.py +3 -1
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/project_config.py +133 -50
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/version.py +1 -1
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0/optimizely_sdk.egg-info}/PKG-INFO +78 -3
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely_sdk.egg-info/SOURCES.txt +1 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely_sdk.egg-info/requires.txt +6 -6
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/requirements/test.txt +1 -1
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/LICENSE +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/MANIFEST.in +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/README.md +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/__init__.py +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/bucketer.py +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/cmab/__init__.py +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/cmab/cmab_client.py +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/decision/__init__.py +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/decision/optimizely_decide_option.py +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/decision/optimizely_decision.py +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/decision/optimizely_decision_message.py +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/error_handler.py +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/event/__init__.py +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/event/event_processor.py +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/event/log_event.py +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/event/user_event.py +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/event/user_event_factory.py +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/exceptions.py +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/helpers/__init__.py +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/helpers/audience.py +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/helpers/condition.py +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/helpers/condition_tree_evaluator.py +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/helpers/constants.py +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/helpers/event_tag_utils.py +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/helpers/experiment.py +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/helpers/sdk_settings.py +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/lib/__init__.py +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/lib/pymmh3.py +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/logger.py +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/notification_center.py +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/notification_center_registry.py +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/odp/__init__.py +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/odp/lru_cache.py +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/odp/odp_config.py +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/odp/odp_event.py +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/odp/odp_event_api_manager.py +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/odp/odp_segment_api_manager.py +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/odp/odp_segment_manager.py +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/odp/optimizely_odp_option.py +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/optimizely_config.py +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/optimizely_factory.py +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/py.typed +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/user_profile.py +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely_sdk.egg-info/dependency_links.txt +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely_sdk.egg-info/top_level.txt +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/requirements/core.txt +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/requirements/docs.txt +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/requirements/typing.txt +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/setup.cfg +0 -0
- {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/setup.py +0 -0
|
@@ -1,5 +1,54 @@
|
|
|
1
1
|
# Optimizely Python SDK Changelog
|
|
2
2
|
|
|
3
|
+
## 5.6.0
|
|
4
|
+
July 10th, 2026
|
|
5
|
+
|
|
6
|
+
### New Features
|
|
7
|
+
|
|
8
|
+
#### **Local Holdouts Support**
|
|
9
|
+
|
|
10
|
+
- Added local holdouts support to the Python SDK, enabling flag-level traffic exclusion from experiments. ([#512](https://github.com/optimizely/python-sdk/pull/512))
|
|
11
|
+
- Added `localHoldouts` datafile section parsing for backward compatibility with older datafile formats. ([#517](https://github.com/optimizely/python-sdk/pull/517))
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
- Fixed invalid IDs in holdout decision events by normalizing `campaign_id`, `variation_id`, and `entity_id` fields. ([#518](https://github.com/optimizely/python-sdk/pull/518))
|
|
18
|
+
- Fixed CMAB prediction requests to use attribute ID instead of attribute key. ([#520](https://github.com/optimizely/python-sdk/pull/520))
|
|
19
|
+
- Fixed ODP identify events being sent with a single identifier, which is a no-op. ([#513](https://github.com/optimizely/python-sdk/pull/513))
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
## 5.5.0
|
|
25
|
+
April 30th, 2026
|
|
26
|
+
|
|
27
|
+
### New Features
|
|
28
|
+
|
|
29
|
+
#### **Feature Rollout Support**
|
|
30
|
+
|
|
31
|
+
- Added support for Feature Rollouts, a new rule type that combines Targeted Delivery simplicity with A/B test measurement capabilities. During project config parsing, the "everyone else" variation from the flag's rollout is automatically injected into feature rollout experiments, enabling correct evaluation without changes to decision logic. ([#499](https://github.com/optimizely/python-sdk/pull/499))
|
|
32
|
+
- Removed experiment type validation from config parsing to support flexible rule types. ([#500](https://github.com/optimizely/python-sdk/pull/500))
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
### Enhancements
|
|
37
|
+
|
|
38
|
+
- Added event retry support for reliable event dispatching. ([#475](https://github.com/optimizely/python-sdk/pull/475))
|
|
39
|
+
- Excluded CMAB experiments from UserProfileService to prevent stale cached decisions. ([#474](https://github.com/optimizely/python-sdk/pull/474))
|
|
40
|
+
- Cleaned up flag-level holdout fields for simplified configuration. ([#507](https://github.com/optimizely/python-sdk/pull/507))
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
### Bug Fixes
|
|
45
|
+
|
|
46
|
+
- Fixed `return` in `finally` block silently swallowing exceptions. ([#505](https://github.com/optimizely/python-sdk/pull/505))
|
|
47
|
+
- Fixed `PollingConfigManager` rejecting valid URL-only configuration. ([#497](https://github.com/optimizely/python-sdk/pull/497))
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
|
|
3
52
|
## 5.4.0
|
|
4
53
|
December 19th, 2025
|
|
5
54
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: optimizely-sdk
|
|
3
|
-
Version: 5.
|
|
3
|
+
Version: 5.6.0
|
|
4
4
|
Summary: Python SDK for Optimizely Feature Experimentation, Optimizely Full Stack (legacy), and Optimizely Rollouts.
|
|
5
5
|
Home-page: https://github.com/optimizely/python-sdk
|
|
6
6
|
Author: Optimizely
|
|
@@ -17,8 +17,34 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.11
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.12
|
|
19
19
|
Description-Content-Type: text/markdown
|
|
20
|
-
Provides-Extra: test
|
|
21
20
|
License-File: LICENSE
|
|
21
|
+
Requires-Dist: jsonschema>=3.2.0
|
|
22
|
+
Requires-Dist: pyrsistent>=0.16.0
|
|
23
|
+
Requires-Dist: requests>=2.21
|
|
24
|
+
Requires-Dist: idna>=2.10
|
|
25
|
+
Requires-Dist: rpds-py<0.20.0; python_version < "3.11"
|
|
26
|
+
Provides-Extra: test
|
|
27
|
+
Requires-Dist: pytest>=6.2.0; extra == "test"
|
|
28
|
+
Requires-Dist: coverage; extra == "test"
|
|
29
|
+
Requires-Dist: pytest-cov; extra == "test"
|
|
30
|
+
Requires-Dist: jsonschema>=3.2.0; extra == "test"
|
|
31
|
+
Requires-Dist: pyrsistent>=0.16.0; extra == "test"
|
|
32
|
+
Requires-Dist: requests>=2.21; extra == "test"
|
|
33
|
+
Requires-Dist: rpds-py<0.20.0; python_version < "3.11" and extra == "test"
|
|
34
|
+
Requires-Dist: python-coveralls; extra == "test"
|
|
35
|
+
Requires-Dist: funcsigs>=0.4; extra == "test"
|
|
36
|
+
Requires-Dist: idna>=2.10; extra == "test"
|
|
37
|
+
Requires-Dist: ruff>=0.15.0; extra == "test"
|
|
38
|
+
Dynamic: author
|
|
39
|
+
Dynamic: author-email
|
|
40
|
+
Dynamic: classifier
|
|
41
|
+
Dynamic: description
|
|
42
|
+
Dynamic: description-content-type
|
|
43
|
+
Dynamic: home-page
|
|
44
|
+
Dynamic: license-file
|
|
45
|
+
Dynamic: provides-extra
|
|
46
|
+
Dynamic: requires-dist
|
|
47
|
+
Dynamic: summary
|
|
22
48
|
|
|
23
49
|
Optimizely Feature Experimentation is A/B testing and feature management for product development teams. Experiment in any application. Make every feature on your roadmap an opportunity to learn. Learn more at https://www.optimizely.com/products/experiment/feature-experimentation/ or see our documentation at https://docs.developers.optimizely.com/experimentation/v4.0.0-full-stack/docs/welcome. # Optimizely Python SDK
|
|
24
50
|
|
|
@@ -278,6 +304,55 @@ idna (BSD 3-Clause License https://github.com/kjd/idna/blob/master/LICENSE.md)
|
|
|
278
304
|
- Swift - https://github.com/optimizely/swift-sdk
|
|
279
305
|
# Optimizely Python SDK Changelog
|
|
280
306
|
|
|
307
|
+
## 5.6.0
|
|
308
|
+
July 10th, 2026
|
|
309
|
+
|
|
310
|
+
### New Features
|
|
311
|
+
|
|
312
|
+
#### **Local Holdouts Support**
|
|
313
|
+
|
|
314
|
+
- Added local holdouts support to the Python SDK, enabling flag-level traffic exclusion from experiments. ([#512](https://github.com/optimizely/python-sdk/pull/512))
|
|
315
|
+
- Added `localHoldouts` datafile section parsing for backward compatibility with older datafile formats. ([#517](https://github.com/optimizely/python-sdk/pull/517))
|
|
316
|
+
|
|
317
|
+
---
|
|
318
|
+
|
|
319
|
+
### Bug Fixes
|
|
320
|
+
|
|
321
|
+
- Fixed invalid IDs in holdout decision events by normalizing `campaign_id`, `variation_id`, and `entity_id` fields. ([#518](https://github.com/optimizely/python-sdk/pull/518))
|
|
322
|
+
- Fixed CMAB prediction requests to use attribute ID instead of attribute key. ([#520](https://github.com/optimizely/python-sdk/pull/520))
|
|
323
|
+
- Fixed ODP identify events being sent with a single identifier, which is a no-op. ([#513](https://github.com/optimizely/python-sdk/pull/513))
|
|
324
|
+
|
|
325
|
+
---
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
## 5.5.0
|
|
329
|
+
April 30th, 2026
|
|
330
|
+
|
|
331
|
+
### New Features
|
|
332
|
+
|
|
333
|
+
#### **Feature Rollout Support**
|
|
334
|
+
|
|
335
|
+
- Added support for Feature Rollouts, a new rule type that combines Targeted Delivery simplicity with A/B test measurement capabilities. During project config parsing, the "everyone else" variation from the flag's rollout is automatically injected into feature rollout experiments, enabling correct evaluation without changes to decision logic. ([#499](https://github.com/optimizely/python-sdk/pull/499))
|
|
336
|
+
- Removed experiment type validation from config parsing to support flexible rule types. ([#500](https://github.com/optimizely/python-sdk/pull/500))
|
|
337
|
+
|
|
338
|
+
---
|
|
339
|
+
|
|
340
|
+
### Enhancements
|
|
341
|
+
|
|
342
|
+
- Added event retry support for reliable event dispatching. ([#475](https://github.com/optimizely/python-sdk/pull/475))
|
|
343
|
+
- Excluded CMAB experiments from UserProfileService to prevent stale cached decisions. ([#474](https://github.com/optimizely/python-sdk/pull/474))
|
|
344
|
+
- Cleaned up flag-level holdout fields for simplified configuration. ([#507](https://github.com/optimizely/python-sdk/pull/507))
|
|
345
|
+
|
|
346
|
+
---
|
|
347
|
+
|
|
348
|
+
### Bug Fixes
|
|
349
|
+
|
|
350
|
+
- Fixed `return` in `finally` block silently swallowing exceptions. ([#505](https://github.com/optimizely/python-sdk/pull/505))
|
|
351
|
+
- Fixed `PollingConfigManager` rejecting valid URL-only configuration. ([#497](https://github.com/optimizely/python-sdk/pull/497))
|
|
352
|
+
|
|
353
|
+
---
|
|
354
|
+
|
|
355
|
+
|
|
281
356
|
## 5.4.0
|
|
282
357
|
December 19th, 2025
|
|
283
358
|
|
|
@@ -163,7 +163,7 @@ class DefaultCmabService:
|
|
|
163
163
|
for attribute_id in cmab_attribute_ids:
|
|
164
164
|
attribute = project_config.attribute_id_map.get(attribute_id)
|
|
165
165
|
if attribute and attribute.key in user_attributes:
|
|
166
|
-
filtered_user_attributes[attribute.
|
|
166
|
+
filtered_user_attributes[attribute.id] = user_attributes[attribute.key]
|
|
167
167
|
|
|
168
168
|
return filtered_user_attributes
|
|
169
169
|
|
|
@@ -145,14 +145,14 @@ class StaticConfigManager(BaseConfigManager):
|
|
|
145
145
|
except optimizely_exceptions.UnsupportedDatafileVersionException as error:
|
|
146
146
|
error_msg = error.args[0]
|
|
147
147
|
error_to_handle = error
|
|
148
|
-
except:
|
|
148
|
+
except Exception:
|
|
149
149
|
error_msg = enums.Errors.INVALID_INPUT.format('datafile')
|
|
150
150
|
error_to_handle = optimizely_exceptions.InvalidInputException(error_msg)
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
151
|
+
|
|
152
|
+
if error_msg or config is None:
|
|
153
|
+
self.logger.error(error_msg)
|
|
154
|
+
self.error_handler.handle_error(error_to_handle or Exception('Unknown Error'))
|
|
155
|
+
return
|
|
156
156
|
|
|
157
157
|
previous_revision = self._config.get_revision() if self._config else None
|
|
158
158
|
|
|
@@ -236,9 +236,6 @@ class PollingConfigManager(StaticConfigManager):
|
|
|
236
236
|
)
|
|
237
237
|
self._sdk_key = sdk_key or self._sdk_key
|
|
238
238
|
|
|
239
|
-
if self._sdk_key is None:
|
|
240
|
-
raise optimizely_exceptions.InvalidInputException(enums.Errors.MISSING_SDK_KEY)
|
|
241
|
-
|
|
242
239
|
self.datafile_url = self.get_datafile_url(
|
|
243
240
|
self._sdk_key, url, url_template or self.DATAFILE_URL_TEMPLATE
|
|
244
241
|
)
|
|
@@ -515,6 +515,11 @@ class DecisionService:
|
|
|
515
515
|
'reasons': decide_reasons,
|
|
516
516
|
'variation': None
|
|
517
517
|
}
|
|
518
|
+
ignore_user_profile = True
|
|
519
|
+
self.logger.debug(
|
|
520
|
+
f'Skipping user profile service for CMAB experiment "{experiment.key}". '
|
|
521
|
+
f'CMAB decisions are dynamic and not stored for sticky bucketing.'
|
|
522
|
+
)
|
|
518
523
|
variation_id = cmab_decision['variation_id'] if cmab_decision else None
|
|
519
524
|
cmab_uuid = cmab_decision['cmab_uuid'] if cmab_decision else None
|
|
520
525
|
variation = project_config.get_variation_from_id(experiment_key=experiment.key,
|
|
@@ -605,6 +610,24 @@ class DecisionService:
|
|
|
605
610
|
return Decision(experiment=rule, variation=forced_decision_variation,
|
|
606
611
|
source=enums.DecisionSources.ROLLOUT, cmab_uuid=None), decide_reasons
|
|
607
612
|
|
|
613
|
+
# Check local holdouts targeting this specific delivery rule (FSSDK-12369)
|
|
614
|
+
local_holdouts = project_config.get_holdouts_for_rule(rule.id)
|
|
615
|
+
for holdout in local_holdouts:
|
|
616
|
+
local_holdout_decision = self.get_variation_for_holdout(
|
|
617
|
+
holdout, user_context, project_config
|
|
618
|
+
)
|
|
619
|
+
decide_reasons.extend(local_holdout_decision['reasons'])
|
|
620
|
+
|
|
621
|
+
local_decision = local_holdout_decision['decision']
|
|
622
|
+
if local_decision.variation is not None:
|
|
623
|
+
message = (
|
|
624
|
+
f"The user '{user_id}' is bucketed into local holdout '{holdout.key}' "
|
|
625
|
+
f"for delivery rule '{rule.key}'."
|
|
626
|
+
)
|
|
627
|
+
self.logger.info(message)
|
|
628
|
+
decide_reasons.append(message)
|
|
629
|
+
return local_decision, decide_reasons
|
|
630
|
+
|
|
608
631
|
bucketing_id, bucket_reasons = self._get_bucketing_id(user_id, attributes)
|
|
609
632
|
decide_reasons += bucket_reasons
|
|
610
633
|
|
|
@@ -728,9 +751,9 @@ class DecisionService:
|
|
|
728
751
|
reasons = decide_reasons.copy() if decide_reasons else []
|
|
729
752
|
user_id = user_context.user_id
|
|
730
753
|
|
|
731
|
-
# Check holdouts
|
|
732
|
-
|
|
733
|
-
for holdout in
|
|
754
|
+
# Check global holdouts (flag level — before any rules are evaluated)
|
|
755
|
+
global_holdouts = project_config.get_global_holdouts()
|
|
756
|
+
for holdout in global_holdouts:
|
|
734
757
|
holdout_decision = self.get_variation_for_holdout(holdout, user_context, project_config)
|
|
735
758
|
reasons.extend(holdout_decision['reasons'])
|
|
736
759
|
|
|
@@ -751,7 +774,7 @@ class DecisionService:
|
|
|
751
774
|
'reasons': reasons
|
|
752
775
|
}
|
|
753
776
|
|
|
754
|
-
# If no holdout decision, check experiments then rollouts
|
|
777
|
+
# If no global holdout decision, check experiments then rollouts
|
|
755
778
|
if feature_flag.experimentIds:
|
|
756
779
|
for experiment_id in feature_flag.experimentIds:
|
|
757
780
|
experiment = project_config.get_experiment_from_id(experiment_id)
|
|
@@ -773,6 +796,28 @@ class DecisionService:
|
|
|
773
796
|
'reasons': reasons
|
|
774
797
|
}
|
|
775
798
|
|
|
799
|
+
# Check local holdouts targeting this specific experiment rule (FSSDK-12369)
|
|
800
|
+
local_holdouts = project_config.get_holdouts_for_rule(experiment.id)
|
|
801
|
+
for holdout in local_holdouts:
|
|
802
|
+
local_holdout_decision = self.get_variation_for_holdout(
|
|
803
|
+
holdout, user_context, project_config
|
|
804
|
+
)
|
|
805
|
+
reasons.extend(local_holdout_decision['reasons'])
|
|
806
|
+
|
|
807
|
+
local_decision = local_holdout_decision['decision']
|
|
808
|
+
if local_decision.variation is not None:
|
|
809
|
+
message = (
|
|
810
|
+
f"The user '{user_id}' is bucketed into local holdout '{holdout.key}' "
|
|
811
|
+
f"for experiment rule '{experiment.key}'."
|
|
812
|
+
)
|
|
813
|
+
self.logger.info(message)
|
|
814
|
+
reasons.append(message)
|
|
815
|
+
return {
|
|
816
|
+
'decision': local_holdout_decision['decision'],
|
|
817
|
+
'error': False,
|
|
818
|
+
'reasons': reasons
|
|
819
|
+
}
|
|
820
|
+
|
|
776
821
|
# Get variation for experiment
|
|
777
822
|
variation_result = self.get_variation(
|
|
778
823
|
project_config, experiment, user_context, user_profile_tracker, reasons, decide_options
|
|
@@ -87,6 +87,7 @@ class Experiment(BaseEntity):
|
|
|
87
87
|
groupId: Optional[str] = None,
|
|
88
88
|
groupPolicy: Optional[str] = None,
|
|
89
89
|
cmab: Optional[CmabDict] = None,
|
|
90
|
+
type: Optional[str] = None,
|
|
90
91
|
**kwargs: Any
|
|
91
92
|
):
|
|
92
93
|
self.id = id
|
|
@@ -101,6 +102,7 @@ class Experiment(BaseEntity):
|
|
|
101
102
|
self.groupId = groupId
|
|
102
103
|
self.groupPolicy = groupPolicy
|
|
103
104
|
self.cmab = cmab
|
|
105
|
+
self.type = type
|
|
104
106
|
|
|
105
107
|
def get_audience_conditions_or_ids(self) -> Sequence[str | list[str]]:
|
|
106
108
|
""" Returns audienceConditions if present, otherwise audienceIds. """
|
|
@@ -220,9 +222,8 @@ class Holdout(BaseEntity):
|
|
|
220
222
|
variations: list[VariationDict],
|
|
221
223
|
trafficAllocation: list[TrafficAllocation],
|
|
222
224
|
audienceIds: list[str],
|
|
223
|
-
includedFlags: Optional[list[str]] = None,
|
|
224
|
-
excludedFlags: Optional[list[str]] = None,
|
|
225
225
|
audienceConditions: Optional[Sequence[str | list[str]]] = None,
|
|
226
|
+
includedRules: Optional[list[str]] = None,
|
|
226
227
|
**kwargs: Any
|
|
227
228
|
):
|
|
228
229
|
self.id = id
|
|
@@ -232,8 +233,9 @@ class Holdout(BaseEntity):
|
|
|
232
233
|
self.trafficAllocation = trafficAllocation
|
|
233
234
|
self.audienceIds = audienceIds
|
|
234
235
|
self.audienceConditions = audienceConditions
|
|
235
|
-
|
|
236
|
-
|
|
236
|
+
# Per-rule targeting for local holdouts. Scope comes from the datafile
|
|
237
|
+
# section, not this field; ProjectConfig strips it on 'holdouts' entries.
|
|
238
|
+
self.included_rules: Optional[list[str]] = includedRules
|
|
237
239
|
|
|
238
240
|
def get_audience_conditions_or_ids(self) -> Sequence[str | list[str]]:
|
|
239
241
|
"""Returns audienceConditions if present, otherwise audienceIds.
|
|
@@ -243,6 +245,16 @@ class Holdout(BaseEntity):
|
|
|
243
245
|
"""
|
|
244
246
|
return self.audienceConditions if self.audienceConditions is not None else self.audienceIds
|
|
245
247
|
|
|
248
|
+
@property
|
|
249
|
+
def is_global(self) -> bool:
|
|
250
|
+
"""True if this is a global holdout.
|
|
251
|
+
|
|
252
|
+
Scope is set by the datafile section ('holdouts' vs 'localHoldouts').
|
|
253
|
+
ProjectConfig strips 'includedRules' on 'holdouts' entries, so this
|
|
254
|
+
property stays consistent with section membership.
|
|
255
|
+
"""
|
|
256
|
+
return self.included_rules is None
|
|
257
|
+
|
|
246
258
|
@property
|
|
247
259
|
def is_activated(self) -> bool:
|
|
248
260
|
"""Check if the holdout is activated (running).
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Copyright 2019, 2022, Optimizely
|
|
1
|
+
# Copyright 2019, 2022, 2026, Optimizely
|
|
2
2
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
3
3
|
# you may not use this file except in compliance with the License.
|
|
4
4
|
# You may obtain a copy of the License at
|
|
@@ -18,6 +18,7 @@ from optimizely import entities
|
|
|
18
18
|
from optimizely.helpers import enums
|
|
19
19
|
from optimizely.helpers import event_tag_utils
|
|
20
20
|
from optimizely.helpers import validator
|
|
21
|
+
from . import event_id_normalizer
|
|
21
22
|
from . import log_event
|
|
22
23
|
from . import payload
|
|
23
24
|
from . import user_event
|
|
@@ -134,12 +135,19 @@ class EventFactory:
|
|
|
134
135
|
if isinstance(event.experiment, entities.Experiment):
|
|
135
136
|
experiment_layerId = event.experiment.layerId
|
|
136
137
|
|
|
138
|
+
normalized_campaign_id = event_id_normalizer.normalize_campaign_id(
|
|
139
|
+
experiment_layerId, experiment_id
|
|
140
|
+
)
|
|
141
|
+
normalized_variation_id = event_id_normalizer.normalize_variation_id(variation_id)
|
|
142
|
+
|
|
137
143
|
metadata = payload.Metadata(event.flag_key, event.rule_key,
|
|
138
144
|
event.rule_type, variation_key,
|
|
139
145
|
event.enabled, event.cmab_uuid)
|
|
140
|
-
decision = payload.Decision(
|
|
146
|
+
decision = payload.Decision(
|
|
147
|
+
normalized_campaign_id, experiment_id, normalized_variation_id, metadata
|
|
148
|
+
)
|
|
141
149
|
snapshot_event = payload.SnapshotEvent(
|
|
142
|
-
|
|
150
|
+
normalized_campaign_id, event.uuid, cls.ACTIVATE_EVENT_KEY, event.timestamp,
|
|
143
151
|
)
|
|
144
152
|
|
|
145
153
|
snapshot = payload.Snapshot([snapshot_event], [decision])
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Copyright 2026, Optimizely
|
|
2
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
3
|
+
# you may not use this file except in compliance with the License.
|
|
4
|
+
# You may obtain a copy of the License at
|
|
5
|
+
#
|
|
6
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
7
|
+
#
|
|
8
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
9
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
10
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
11
|
+
# See the License for the specific language governing permissions and
|
|
12
|
+
# limitations under the License.
|
|
13
|
+
|
|
14
|
+
"""Normalization helpers for decision-event ID fields.
|
|
15
|
+
|
|
16
|
+
This module provides byte-equivalent, cross-SDK normalization for the
|
|
17
|
+
``campaign_id``, ``variation_id``, and impression ``entity_id`` fields that
|
|
18
|
+
appear in dispatched decision events.
|
|
19
|
+
|
|
20
|
+
Rules:
|
|
21
|
+
* ``campaign_id`` and impression ``entity_id`` accept **any non-empty
|
|
22
|
+
string** (numeric like ``"12345"`` or opaque like ``"default-12345"`` /
|
|
23
|
+
``"layer_abc"``). The fallback to ``experiment_id`` fires ONLY when the
|
|
24
|
+
value is the empty string, ``None``, or missing. Non-string types are
|
|
25
|
+
out of scope for this normalization path (the upstream datafile
|
|
26
|
+
producer delivers string or null values).
|
|
27
|
+
* ``variation_id`` retains the stricter contract: it MUST be a non-empty
|
|
28
|
+
string of decimal digits ``0-9`` (leading zeros allowed). Empty,
|
|
29
|
+
whitespace, non-string, and non-numeric inputs are normalized to
|
|
30
|
+
``None`` so the wire payload carries an explicit null.
|
|
31
|
+
* ``entity_id`` on impression events shares the campaign_id normalization
|
|
32
|
+
and is therefore byte-equivalent to the normalized campaign_id for the
|
|
33
|
+
same impression.
|
|
34
|
+
|
|
35
|
+
The normalization path MUST NOT log, warn, or raise. It must never drop or
|
|
36
|
+
defer event dispatch.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
from sys import version_info
|
|
41
|
+
from typing import Any, Optional
|
|
42
|
+
|
|
43
|
+
if version_info < (3, 10):
|
|
44
|
+
from typing_extensions import TypeGuard
|
|
45
|
+
else:
|
|
46
|
+
from typing import TypeGuard
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_non_empty_string(value: Any) -> TypeGuard[str]:
|
|
50
|
+
"""Return ``True`` if ``value`` is a non-empty :class:`str`.
|
|
51
|
+
Any non-empty string is accepted regardless of
|
|
52
|
+
character content (IDs may be opaque, e.g. ``"default-12345"``).
|
|
53
|
+
"""
|
|
54
|
+
return isinstance(value, str) and value != ''
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def is_numeric_id_string(value: Any) -> TypeGuard[str]:
|
|
58
|
+
"""Return ``True`` if ``value`` is a non-empty decimal-digit string.
|
|
59
|
+
Whitespace, signs, decimal points, exponents
|
|
60
|
+
and non-string types all return ``False``. Leading
|
|
61
|
+
zeros are accepted.
|
|
62
|
+
"""
|
|
63
|
+
if not isinstance(value, str):
|
|
64
|
+
return False
|
|
65
|
+
if value == '':
|
|
66
|
+
return False
|
|
67
|
+
return value.isascii() and value.isdigit()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def normalize_campaign_id(campaign_id: Any, experiment_id: Any) -> str:
|
|
71
|
+
"""Normalize a decision-event ``campaign_id``.
|
|
72
|
+
|
|
73
|
+
Returns ``campaign_id`` unchanged when it is a non-empty string (any
|
|
74
|
+
character content — numeric like ``"12345"`` or opaque like
|
|
75
|
+
``"default-12345"``). Otherwise falls back to ``experiment_id`` (when it
|
|
76
|
+
is itself a non-empty string). If neither is a non-empty string, returns
|
|
77
|
+
an empty string so the event still dispatches.
|
|
78
|
+
"""
|
|
79
|
+
if is_non_empty_string(campaign_id):
|
|
80
|
+
return campaign_id
|
|
81
|
+
if is_non_empty_string(experiment_id):
|
|
82
|
+
return experiment_id
|
|
83
|
+
return ''
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def normalize_variation_id(variation_id: Any) -> Optional[str]:
|
|
87
|
+
"""Normalize a decision-event ``variation_id``.
|
|
88
|
+
|
|
89
|
+
Returns the original value if it is a valid numeric ID string. Otherwise
|
|
90
|
+
returns ``None`` so the event payload carries an explicit null for the
|
|
91
|
+
downstream consumer.
|
|
92
|
+
"""
|
|
93
|
+
return variation_id if is_numeric_id_string(variation_id) else None
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Copyright 2019, 2022, Optimizely
|
|
1
|
+
# Copyright 2019, 2022, 2026, Optimizely
|
|
2
2
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
3
3
|
# you may not use this file except in compliance with the License.
|
|
4
4
|
# You may obtain a copy of the License at
|
|
@@ -71,7 +71,13 @@ class EventBatch:
|
|
|
71
71
|
class Decision:
|
|
72
72
|
""" Class respresenting Decision. """
|
|
73
73
|
|
|
74
|
-
def __init__(
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
campaign_id: str,
|
|
77
|
+
experiment_id: str,
|
|
78
|
+
variation_id: Optional[str],
|
|
79
|
+
metadata: Metadata,
|
|
80
|
+
):
|
|
75
81
|
self.campaign_id = campaign_id
|
|
76
82
|
self.experiment_id = experiment_id
|
|
77
83
|
self.variation_id = variation_id
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Copyright 2016-2019, 2022, Optimizely
|
|
1
|
+
# Copyright 2016-2019, 2022, 2026, Optimizely
|
|
2
2
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
3
3
|
# you may not use this file except in compliance with the License.
|
|
4
4
|
# You may obtain a copy of the License at
|
|
@@ -18,6 +18,7 @@ import uuid
|
|
|
18
18
|
from sys import version_info
|
|
19
19
|
|
|
20
20
|
from . import version
|
|
21
|
+
from .event import event_id_normalizer
|
|
21
22
|
from .helpers import enums
|
|
22
23
|
from .helpers import event_tag_utils
|
|
23
24
|
from .helpers import validator
|
|
@@ -178,7 +179,7 @@ class EventBuilder:
|
|
|
178
179
|
|
|
179
180
|
def _get_required_params_for_impression(
|
|
180
181
|
self, experiment: Experiment, variation_id: str
|
|
181
|
-
) -> dict[str, list[dict[str,
|
|
182
|
+
) -> dict[str, list[dict[str, Any]]]:
|
|
182
183
|
""" Get parameters that are required for the impression event to register.
|
|
183
184
|
|
|
184
185
|
Args:
|
|
@@ -188,19 +189,24 @@ class EventBuilder:
|
|
|
188
189
|
Returns:
|
|
189
190
|
Dict consisting of decisions and events info for impression event.
|
|
190
191
|
"""
|
|
191
|
-
snapshot: dict[str, list[dict[str,
|
|
192
|
+
snapshot: dict[str, list[dict[str, Any]]] = {}
|
|
193
|
+
|
|
194
|
+
normalized_campaign_id = event_id_normalizer.normalize_campaign_id(
|
|
195
|
+
experiment.layerId, experiment.id
|
|
196
|
+
)
|
|
197
|
+
normalized_variation_id = event_id_normalizer.normalize_variation_id(variation_id)
|
|
192
198
|
|
|
193
199
|
snapshot[self.EventParams.DECISIONS] = [
|
|
194
200
|
{
|
|
195
201
|
self.EventParams.EXPERIMENT_ID: experiment.id,
|
|
196
|
-
self.EventParams.VARIATION_ID:
|
|
197
|
-
self.EventParams.CAMPAIGN_ID:
|
|
202
|
+
self.EventParams.VARIATION_ID: normalized_variation_id,
|
|
203
|
+
self.EventParams.CAMPAIGN_ID: normalized_campaign_id,
|
|
198
204
|
}
|
|
199
205
|
]
|
|
200
206
|
|
|
201
207
|
snapshot[self.EventParams.EVENTS] = [
|
|
202
208
|
{
|
|
203
|
-
self.EventParams.EVENT_ID:
|
|
209
|
+
self.EventParams.EVENT_ID: normalized_campaign_id,
|
|
204
210
|
self.EventParams.TIME: self._get_time(),
|
|
205
211
|
self.EventParams.KEY: 'campaign_activated',
|
|
206
212
|
self.EventParams.UUID: str(uuid.uuid4()),
|
|
@@ -229,6 +229,14 @@ class OdpManagerConfig:
|
|
|
229
229
|
EVENT_TYPE: Final = 'fullstack'
|
|
230
230
|
|
|
231
231
|
|
|
232
|
+
class ExperimentTypes:
|
|
233
|
+
ab: Final = 'ab'
|
|
234
|
+
mab: Final = 'mab'
|
|
235
|
+
cmab: Final = 'cmab'
|
|
236
|
+
td: Final = 'td'
|
|
237
|
+
fr: Final = 'fr'
|
|
238
|
+
|
|
239
|
+
|
|
232
240
|
class OdpSegmentsCacheConfig:
|
|
233
241
|
"""ODP Segment Cache configs."""
|
|
234
242
|
DEFAULT_CAPACITY: Final = 10_000
|
|
@@ -117,6 +117,10 @@ class CmabDict(BaseEntity):
|
|
|
117
117
|
trafficAllocation: int
|
|
118
118
|
|
|
119
119
|
|
|
120
|
+
ExperimentType = Literal[
|
|
121
|
+
'ab', 'mab', 'cmab', 'td', 'fr'
|
|
122
|
+
]
|
|
123
|
+
|
|
120
124
|
HoldoutStatus = Literal['Draft', 'Running', 'Concluded', 'Archived']
|
|
121
125
|
|
|
122
126
|
|
|
@@ -126,5 +130,6 @@ class HoldoutDict(ExperimentDict):
|
|
|
126
130
|
Extends ExperimentDict with holdout-specific properties.
|
|
127
131
|
"""
|
|
128
132
|
holdoutStatus: HoldoutStatus
|
|
129
|
-
|
|
130
|
-
|
|
133
|
+
# Per-rule targeting for local holdouts. Scope comes from the datafile section,
|
|
134
|
+
# not this field; required on 'localHoldouts' entries, stripped on 'holdouts'.
|
|
135
|
+
includedRules: Optional[list[str]]
|
|
@@ -193,7 +193,7 @@ def is_user_profile_valid(user_profile: dict[str, Any]) -> bool:
|
|
|
193
193
|
if not user_profile:
|
|
194
194
|
return False
|
|
195
195
|
|
|
196
|
-
if
|
|
196
|
+
if type(user_profile) is not dict:
|
|
197
197
|
return False
|
|
198
198
|
|
|
199
199
|
if UserProfile.USER_ID_KEY not in user_profile:
|
|
@@ -203,7 +203,7 @@ def is_user_profile_valid(user_profile: dict[str, Any]) -> bool:
|
|
|
203
203
|
return False
|
|
204
204
|
|
|
205
205
|
experiment_bucket_map = user_profile.get(UserProfile.EXPERIMENT_BUCKET_MAP_KEY)
|
|
206
|
-
if
|
|
206
|
+
if type(experiment_bucket_map) is not dict:
|
|
207
207
|
return False
|
|
208
208
|
|
|
209
209
|
for decision in experiment_bucket_map.values():
|
|
@@ -163,6 +163,8 @@ class OdpEventManager:
|
|
|
163
163
|
|
|
164
164
|
self.logger.debug(f'ODP event queue: flushing batch size {batch_len}.')
|
|
165
165
|
should_retry = False
|
|
166
|
+
initial_retry_interval = 0.2 # 200ms
|
|
167
|
+
max_retry_interval = 1.0 # 1 second
|
|
166
168
|
|
|
167
169
|
for i in range(1 + self.retry_count):
|
|
168
170
|
try:
|
|
@@ -176,7 +178,12 @@ class OdpEventManager:
|
|
|
176
178
|
if not should_retry:
|
|
177
179
|
break
|
|
178
180
|
if i < self.retry_count:
|
|
179
|
-
|
|
181
|
+
# Exponential backoff: 200ms, 400ms, 800ms, ... capped at 1s
|
|
182
|
+
delay = initial_retry_interval * (2 ** i)
|
|
183
|
+
if delay > max_retry_interval:
|
|
184
|
+
delay = max_retry_interval
|
|
185
|
+
self.logger.debug(f'Error dispatching ODP events, retrying after {delay}s.')
|
|
186
|
+
time.sleep(delay)
|
|
180
187
|
|
|
181
188
|
if should_retry:
|
|
182
189
|
self.logger.error(Errors.ODP_EVENT_FAILED.format(f'Failed after {i} retries: {self._current_batch}'))
|
|
@@ -260,9 +267,30 @@ class OdpEventManager:
|
|
|
260
267
|
except Full:
|
|
261
268
|
self.logger.warning(Errors.ODP_EVENT_FAILED.format("Queue is full"))
|
|
262
269
|
|
|
263
|
-
def identify_user(self,
|
|
270
|
+
def identify_user(self, identifiers: dict[str, str]) -> None:
|
|
271
|
+
"""Send an identify event to ODP if there are multiple valid identifiers.
|
|
272
|
+
|
|
273
|
+
An identify event is only useful when there are two or more identifiers
|
|
274
|
+
to link together in the ODP user profile. Sending a single identifier
|
|
275
|
+
provides no linking value and wastes a network call.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
identifiers: A dictionary of identifier key-value pairs
|
|
279
|
+
(e.g. {"fs_user_id": "abc", "email": "a@b.com"}).
|
|
280
|
+
"""
|
|
281
|
+
# Filter out identifiers with None or empty string values.
|
|
282
|
+
valid_identifiers = {k: v for k, v in identifiers.items() if v}
|
|
283
|
+
|
|
284
|
+
# Identify requires 2+ identifiers to link (e.g., vuid + fs_user_id).
|
|
285
|
+
# A single identifier has no cross-reference value and generates unnecessary traffic.
|
|
286
|
+
if len(valid_identifiers) < 2:
|
|
287
|
+
self.logger.debug(
|
|
288
|
+
'ODP identify event is not dispatched (fewer than 2 valid identifiers).'
|
|
289
|
+
)
|
|
290
|
+
return
|
|
291
|
+
|
|
264
292
|
self.send_event(OdpManagerConfig.EVENT_TYPE, 'identified',
|
|
265
|
-
|
|
293
|
+
valid_identifiers, {})
|
|
266
294
|
|
|
267
295
|
def update_config(self) -> None:
|
|
268
296
|
"""Adds update config signal to event_queue."""
|