10000 [FSSDK-11184] Update: Send CMAB uuid in impression events by FarhanAnjum-opti · Pull Request #458 · optimizely/python-sdk · GitHub
[go: up one dir, main page]

Skip to content

[FSSDK-11184] Update: Send CMAB uuid in impression events #458

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 48 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
88b4f1e
update: integrate CMAB components into OptimizelyFactory
FarhanAnjum-opti Jun 4, 2025
2563c7b
update: add cmab_service parameter to Optimizely constructor for CMAB…
FarhanAnjum-opti Jun 4, 2025
fac8946
update: add docstring to DefaultCmabService class for improved docume…
FarhanAnjum-opti Jun 4, 2025
f74bc8c
update: implement CMAB support in bucketer and decision service, reve…
FarhanAnjum-opti Jun 13, 2025
6d1f73d
linting fix
FarhanAnjum-opti Jun 13, 2025
91d53b6
update: add cmab_uuid handling in DecisionService and related tests
FarhanAnjum-opti Jun 16, 2025
3eb755f
- updated function bucket_to_entity_id
FarhanAnjum-opti Jun 16, 2025
a5e4993
update: add None parameter to Decision constructor in user context tests
FarhanAnjum-opti Jun 16, 2025
c1cd97a
update: enhance CMAB decision handling and add related tests
FarhanAnjum-opti Jun 16, 2025
fd7c723
update: fix logger message formatting in CMAB experiment tests
FarhanAnjum-opti Jun 16, 2025
ec19c3b
mypy fix
FarhanAnjum-opti Jun 16, 2025
029262d
update: refine traffic allocation type hints and key naming in bucket…
FarhanAnjum-opti Jun 16, 2025
180fdee
update: remove unused import of cast in bucketer.py
FarhanAnjum-opti Jun 16, 2025
cd5ba39
update: fix return type for numeric_metric_value in get_numeric_value…
FarhanAnjum-opti Jun 16, 2025
92a3258
update: specify type hint for numeric_metric_value in get_numeric_val…
FarhanAnjum-opti Jun 16, 2025
fe100cb
update: fix logger reference in DefaultCmabClient initialization and …
FarhanAnjum-opti Jun 17, 2025
60a4ada
update: enhance error logging for CMAB fetch failures with detailed m…
FarhanAnjum-opti Jun 20, 2025
265d82b
update: enhance decision result handling by introducing VariationResu…
FarhanAnjum-opti Jun 20, 2025
6ca1102
update: refactor get_variation return structure and change tests acco…
FarhanAnjum-opti Jun 20, 2025
c2b3d96
-Error propagated to optimizely.py
FarhanAnjum-opti Jun 23, 2025
0e25622
update: add cmab_uuid parameter to impression events
FarhanAnjum-opti Jun 27, 2025
088f4af
update: add None parameter to impression events in decision tests
FarhanAnjum-opti Jun 27, 2025
b901c5f
update: modify get_variation to return VariationResult and adjust rel…
FarhanAnjum-opti Jun 27, 2025
fa77cca
Merge branch 'farhan-anjum/FSSDK-11175-add-decision-service-methods-f…
FarhanAnjum-opti Jun 27, 2025
d2fc631
update: unit test fixes
FarhanAnjum-opti Jun 27, 2025
752a030
Merge branch 'farhan-anjum/FSSDK-11175-add-decision-service-methods-f…
FarhanAnjum-opti Jun 27, 2025
cbf2c2c
update: include CMAB UUID in activation and add corresponding tests
FarhanAnjum-opti Jun 27, 2025
fdcdfbf
update: add tests for get_variation with and without CMAB UUID
FarhanAnjum-opti Jun 27, 2025
b9a8555
Revert "update: unit test fixes"
FarhanAnjum-opti Jun 30, 2025
a129854
Revert "update: modify get_variation to return VariationResult and ad…
FarhanAnjum-opti Jun 30, 2025
9d63477
Merge branch 'farhan-anjum/FSSDK-11175-add-decision-service-methods-f…
FarhanAnjum-opti Jun 30, 2025
1f7e2a9
update: make cmab_uuid parameter optional in _send_impression_event m…
FarhanAnjum-opti Jun 30, 2025
73a2802
chore: trigger CI by turning on python flag
FarhanAnjum-opti Jun 30, 2025
a6d9771
update: new class method to handle optimizely error decisions
FarhanAnjum-opti Jun 30, 2025
4743376
fix unit test
FarhanAnjum-opti Jun 30, 2025
6d79053
fix: update error logging format for CMAB fetch failures
FarhanAnjum-opti Jun 30, 2025
3c903c7
chore: trigger CI
FarhanAnjum-opti Jul 1, 2025
c637878
update: enhance decision service to handle error states and improve b…
FarhanAnjum-opti Jul 3, 2025
62bbf64
Merge branch 'farhan-anjum/FSSDK-11175-add-decision-service-methods-f…
FarhanAnjum-opti Jul 3, 2025
0bc4fbd
update: remove debug print statement from Optimizely class
FarhanAnjum-opti Jul 3, 2025
8668565
Merge branch 'farhan-anjum/FSSDK-11175-add-decision-service-methods-f…
FarhanAnjum-opti Jul 3, 2025
fcdad1f
update: enhance bucketing logic to support CMAB traffic allocations
FarhanAnjum-opti Jul 3, 2025
dd4790d
Merge branch 'farhan-anjum/FSSDK-11175-add-decision-service-methods-f…
FarhanAnjum-opti Jul 3, 2025
aca7df4
update: improve error logging for CMAB decision fetch failures
FarhanAnjum-opti Jul 3, 2025
c14b768
Merge branch 'farhan-anjum/FSSDK-11175-add-decision-service-methods-f…
FarhanAnjum-opti Jul 3, 2025
72955a0
update: improve logging and error handling in bucketer and decision s…
FarhanAnjum-opti Jul 7, 2025
265ea3c
Merge branch 'farhan-anjum/FSSDK-11175-add-decision-service-methods-f…
FarhanAnjum-opti Jul 7, 2025
b88c08b
Merge branch 'master' into farhan-anjum/FSSDK-11184-update-impression…
FarhanAnjum-opti Jul 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
update: implement CMAB support in bucketer and decision service, reve…
…rt OptimizelyFactory
  • Loading branch information
FarhanAnjum-opti committed Jun 13, 2025
commit f74bc8ca805cf964d63c340795a2e20f3c0a16b7
52 changes: 52 additions & 0 deletions optimizely/bucketer.py
8000
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,55 @@ def bucket(
decide_reasons.append(message)

return None, decide_reasons

def bucket_to_entity_id(
self,
bucketing_id: str,
experiment: Experiment,
traffic_allocations: list,
parent_id: Optional[str] = None
) -> tuple[Optional[str], list[str]]:
"""
Buckets the user and returns the entity ID (for CMAB experiments).
Args:
bucketing_id: The bucketing ID string for the user.
experiment: The experiment object (for group/groupPolicy logic if needed).
traffic_allocations: List of traffic allocation dicts (should have 'entity_id' and 'end_of_range' keys).
parent_id: (optional) Used for mutex group support; if not supplied, experiment.id is used.

Returns:
Tuple of (entity_id or None, list of decide reasons).
"""
decide_reasons = []

# If experiment is in a mutually exclusive group with random policy, check group bucketing first
group_id = getattr(experiment, 'groupId', None)
group_policy = getattr(experiment, 'groupPolicy', None)
if group_id and group_policy == 'random':
bucketing_key = f"{bucketing_id}{group_id}"
bucket_number = self._generate_bucket_value(bucketing_key)
# Group traffic allocation would need to be passed in or found here
# For now, skipping group-level allocation (you can extend this for mutex groups)
decide_reasons.append(f'Checked mutex group allocation for group "{group_id}".')

# Main bucketing for experiment or CMAB dummy entity
parent_id = parent_id or experiment.id
bucketing_key = f"{bucketing_id}{parent_id}"
bucket_number = self._generate_bucket_value(bucketing_key)
decide_reasons.append(
f'Assigned bucket {bucket_number} to bucketing ID "{bucketing_id}" for parent "{parent_id}".'
)

for allocation in traffic_allocations:
end_of_range = allocation.get("end_of_range") or allocation.get("endOfRange")
entity_id = allocation.get("entity_id") or allocation.get("entityId")
if end_of_range is not None and bucket_number < end_of_range:
decide_reasons.append(
f'User with bucketing ID "{bucketing_id}" bucketed into entity "{entity_id}".'
)
return entity_id, decide_reasons

decide_reasons.append(
f'User with bucketing ID "{bucketing_id}" not bucketed into any entity.'
)
return None, decide_reasons
99 changes: 94 additions & 5 deletions optimizely/decision_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
# limitations under the License.

from __future__ import annotations
from typing import TYPE_CHECKING, NamedTuple, Optional, Sequence
from typing import TYPE_CHECKING, NamedTuple, Optional, Sequence, List, TypedDict

from . import bucketer
from . import entities
Expand All @@ -23,28 +23,41 @@
from .helpers import validator
from .optimizely_user_context import OptimizelyUserContext, UserAttributes
from .user_profile import UserProfile, UserProfileService, UserProfileTracker
from .cmab.cmab_service import DefaultCmabService, CmabDecision
from optimizely.helpers.enums import Errors

if TYPE_CHECKING:
# prevent circular dependenacy by skipping import at runtime
from .project_config import ProjectConfig
from .logger import Logger


class CmabDecisionResult(TypedDict):
error: bool
result: Optional[CmabDecision]
reasons: List[str]


class Decision(NamedTuple):
"""Named tuple containing selected experiment, variation and source.
None if no experiment/variation was selected."""
experiment: Optional[entities.Experiment]
variation: Optional[entities.Variation]
source: Optional[str]
# cmab_uuid: Optional[str]


class DecisionService:
""" Class encapsulating all decision related capabilities. """

def __init__(self, logger: Logger, user_profile_service: Optional[UserProfileService]):
def __init__(self,
logger: Logger,
user_profile_service: Optional[UserProfileService],
cmab_service: DefaultCmabService):
self.bucketer = bucketer.Bucketer()
self.logger = logger
self.user_profile_service = user_profile_service
self.cmab_service = cmab_service

# Map of user IDs to another map of experiments to variations.
# This contains all the forced variations set by the user
Expand Down Expand Up @@ -76,6 +89,48 @@ def _get_bucketing_id(self, user_id: str, attributes: Optional[UserAttributes])

return user_id, decide_reasons

def _get_decision_for_cmab_experiment(
self,
project_config: ProjectConfig,
experiment: entities.Experiment,
user_context: OptimizelyUserContext,
options: Optional[Sequence[str]] = None
) -> CmabDecisionResult:
"""
Retrieves a decision for a contextual multi-armed bandit (CMAB) experiment.

Args:
project_config: Instance of ProjectConfig.
experiment: The experiment object for which the decision is to be made.
user_context: The user context containing user id and attributes.
options: Optional sequence of decide options.

Returns:
A dictionary containing:
- "error": Boolean indicating if there was an error.
- "result": The CmabDecision result or empty dict if error.
- "reasons": List of strings with reasons or error messages.
"""
try:
options_list = list(options) if options is not None else []
cmab_decision = self.cmab_service.get_decision(
project_config, user_context, experiment.id, options_list
)
return {
"error": False,
"result": cmab_decision,
"reasons": [],
}
except Exception as e:
error_message = Errors.CMAB_FETCH_FAILED.format(str(e))
if self.logger:
self.logger.error(error_message)
return {
"error": True,
"result": None,
"reasons": [error_message],
}

def set_forced_variation(
self, project_config: ProjectConfig, experiment_key: str,
user_id: str, variation_key: Optional[str]
Expand Down Expand Up @@ -313,7 +368,7 @@ def get_variation(
else:
self.logger.warning('User profile has invalid format.')

# Bucket user and store the new decision
# Check audience conditions
audience_conditions = experiment.get_audience_conditions_or_ids()
user_meets_audience_conditions, reasons_received = audience_helper.does_user_meet_audience_conditions(
project_config, audience_conditions,
Expand All @@ -330,8 +385,42 @@ def get_variation(
# Determine bucketing ID to be used
bucketing_id, bucketing_id_reasons = self._get_bucketing_id(user_id, user_context.get_user_attributes())
decide_reasons += bucketing_id_reasons
variation, bucket_reasons = self.bucketer.bucket(project_config, experiment, user_id, bucketing_id)
decide_reasons += bucket_reasons
6D40
if experiment.cmab:
CMAB_DUMMY_ENTITY_ID = "$"
# Build the CMAB-specific traffic allocation
cmab_traffic_allocation = [{
"entity_id": CMAB_DUMMY_ENTITY_ID,
"end_of_range": experiment.cmab['trafficAllocation']
}]

# Check if user is in CMAB traffic allocation
bucketed_entity_id, bucket_reasons = self.bucketer.bucket_to_entity_id(
bucketing_id, experiment, cmab_traffic_allocation
)
decide_reasons += bucket_reasons
if bucketed_entity_id != CMAB_DUMMY_ENTITY_ID:
message = f'User "{user_id}" not in CMAB experiment "{experiment.key}" due to traffic allocation.'
self.logger.info(message)
decide_reasons.append(message)
return None, decide_reasons

# User is in CMAB allocation, proceed to CMAB decision
decision_variation_value = self._get_decision_for_cmab_experiment(project_config,
experiment,
user_context,
options)
decide_reasons += decision_variation_value.get('reasons', [])
cmab_decision = decision_variation_value.get('result')
if not cmab_decision:
return None, decide_reasons
variation_id = cmab_decision['variation_id']
variation = project_config.get_variation_from_id(experiment_key=experiment.key, variation_id=variation_id)
else:
# Bucket the user
variation, bucket_reasons = self.bucketer.bucket(project_config, experiment, user_id, bucketing_id)
decide_reasons += bucket_reasons

if isinstance(variation, entities.Variation):
message = f'User "{user_id}" is in variation "{variation.key}" of experiment {experiment.key}.'
self.logger.info(message)
Expand Down
28 changes: 21 additions & 7 deletions optimizely/optimizely.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,18 @@
from .optimizely_config import OptimizelyConfig, OptimizelyConfigService
from .optimizely_user_context import OptimizelyUserContext, UserAttributes
from .project_config import ProjectConfig
from .cmab.cmab_service import DefaultCmabService
from .cmab.cmab_client import DefaultCmabClient, CmabRetryConfig
from .cmab.cmab_service import DefaultCmabService, CmabCacheValue
from .odp.lru_cache import LRUCache

if TYPE_CHECKING:
# prevent circular dependency by skipping import at runtime
from .user_profile import UserProfileService
from .helpers.event_tag_utils import EventTags

# Default constants for CMAB cache
DEFAULT_CMAB_CACHE_TIMEOUT = 30 * 60 * 1000 # 30 minutes in milliseconds
DEFAULT_CMAB_CACHE_SIZE = 1000

class Optimizely:
""" Class encapsulating all SDK functionality. """
Expand All @@ -71,7 +76,6 @@ def __init__(
default_decide_options: Optional[list[str]] = None,
event_processor_options: Optional[dict[str, Any]] = None,
settings: Optional[OptimizelySdkSettings] = None,
cmab_service: Optional[DefaultCmabService] = None
) -> None:
""" Optimizely init method for managing Custom projects.

Expand Down Expand Up @@ -100,7 +104,6 @@ def __init__(
default_decide_options: Optional list of decide options used with the decide APIs.
event_processor_options: Optional dict of options to be passed to the default batch event processor.
settings: Optional instance of OptimizelySdkSettings for sdk configuration.
cmab_service: Optional instance of DefaultCmabService for Contextual Multi-Armed Bandit (CMAB) support.
"""
self.logger_name = '.'.join([__name__, self.__class__.__name__])
self.is_valid = True
Expand Down Expand Up @@ -172,10 +175,21 @@ def __init__(
self._setup_odp(self.config_manager.get_sdk_key())

self.event_builder = event_builder.EventBuilder()
if cmab_service:
cmab_service.logger = self.logger
self.cmab_service = cmab_service
self.decision_service = decision_service.DecisionService(self.logger, user_profile_service, cmab_service)

# Initialize CMAB components

self.cmab_client = DefaultCmabClient(
retry_config=CmabRetryConfig(),
logger=logger
)
self.cmab_cache: LRUCache[str, CmabCacheValue] = LRUCache(DEFAULT_CMAB_CACHE_SIZE,
DEFAULT_CMAB_CACHE_TIMEOUT)
self.cmab_service = DefaultCmabService(
cmab_cache=self.cmab_cache,
cmab_client=self.cmab_client,
logger=self.logger
)
self.decision_service = decision_service.DecisionService(self.logger, user_profile_service, self.cmab_service)
self.user_profile_service = user_profile_service

def _validate_instantiation_options(self) -> None:
Expand Down
51 changes: 4 additions & 47 deletions optimizely/optimizely_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,11 @@
from .event_dispatcher import EventDispatcher, CustomEventDispatcher
from .notification_center import NotificationCenter
from .optimizely import Optimizely
from .cmab.cmab_client import DefaultCmabClient, CmabRetryConfig
from .cmab.cmab_service import DefaultCmabService, CmabCacheValue
from .odp.lru_cache import LRUCache

if TYPE_CHECKING:
# prevent circular dependenacy by skipping import at runtime
from .user_profile import UserProfileService

# Default constants for CMAB cache
DEFAULT_CMAB_CACHE_TIMEOUT = 30 * 60 * 1000 # 30 minutes in milliseconds
DEFAULT_CMAB_CACHE_SIZE = 1000


class OptimizelyFactory:
""" Optimizely factory to provides basic utility to instantiate the Optimizely
Expand All @@ -43,8 +36,6 @@ class OptimizelyFactory:
max_event_flush_interval: Optional[int] = None
polling_interval: Optional[float] = None
blocking_timeout: Optional[int] = None
cmab_cache_size: int = DEFAULT_CMAB_CACHE_SIZE
cmab_cache_timeout: int = DEFAULT_CMAB_CACHE_TIMEOUT

@staticmethod
def set_batch_size(batch_size: int) -> int:
Expand Down Expand Up @@ -113,36 +104,16 @@ def default_instance(sdk_key: str, datafile: Optional[str] = None) -> Optimizely
notification_center=notification_center,
)

# Initialize CMAB components
cmab_client = DefaultCmabClient(
retry_config=CmabRetryConfig(),
logger=logger
)
cmab_cache: LRUCache[str, CmabCacheValue] = LRUCache(OptimizelyFactory.cmab_cache_size,
OptimizelyFactory.cmab_cache_timeout)
cmab_service = DefaultCmabService(
cmab_cache=cmab_cache,
cmab_client=cmab_client,
logger=logger
)

optimizely = Optimizely(
datafile, None, logger, error_handler, None, None, sdk_key, config_manager, notification_center,
event_processor, cmab_service=cmab_service
event_processor
)
return optimizely

@staticmethod
def default_instance_with_config_manager(config_manager: BaseConfigManager) -> Optimizely:
# Initialize CMAB components
cmab_client = DefaultCmabClient(retry_config=CmabRetryConfig())
cmab_cache: LRUCache[str, CmabCacheValue] = LRUCache(OptimizelyFactory.cmab_cache_size,
OptimizelyFactory.cmab_cache_timeout)
cmab_service = DefaultCmabService(cmab_cache=cmab_cache, cmab_client=cmab_client)

return Optimizely(
config_manager=config_manager,
cmab_service=cmab_service
config_manager=config_manager
)

@staticmethod
Expand Down Expand Up @@ -203,21 +174,7 @@ def custom_instance(
notification_center=notification_center,
)

# Initialize CMAB components
cmab_client = DefaultCmabClient(
retry_config=CmabRetryConfig(),
logger=logger
)
cmab_cache: LRUCache[str, CmabCacheValue] = LRUCache(OptimizelyFactory.cmab_cache_size,
OptimizelyFactory.cmab_cache_timeout)
cmab_service = DefaultCmabService(
cmab_cache=cmab_cache,
cmab_client=cmab_client,
logger=logger
)

return Optimizely(
datafile, event_dispatcher, logger, error_handler, skip_json_validation, user_profile_service,
sdk_key, config_manager, notification_center, event_processor, settings=settings,
cmab_service=cmab_service
)
sdk_key, config_manager, notification_center, event_processor, settings=settings
)
0