Skip to content

Commit c56a99a

Browse files
committed
[AI-FSSDK] [FSSDK-12735] Fix holdout impression event tracking and add bypass decision reason
1 parent c0c1a6c commit c56a99a

3 files changed

Lines changed: 49 additions & 4 deletions

File tree

‎optimizely/decision_service.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -747,6 +747,7 @@ def get_decision_for_flag(
747747
user_id = user_context.user_id
748748

749749
global_holdout_result: DecisionResult | None = None
750+
global_holdout_key: str | None = None
750751

751752
# Check global holdouts (flag level — before any rules are evaluated)
752753
global_holdouts = project_config.get_global_holdouts()
@@ -779,6 +780,7 @@ def get_decision_for_flag(
779780
self.logger.info(message)
780781
reasons.append(message)
781782
global_holdout_result = holdout_decision
783+
global_holdout_key = holdout.key
782784
break
783785

784786
# Check experiments then rollouts
@@ -858,6 +860,14 @@ def get_decision_for_flag(
858860
result['holdout_decision'] = global_holdout_result['decision']
859861
return result
860862

863+
if global_holdout_result is not None:
864+
message = (
865+
f"Holdout '{global_holdout_key}' has excludeTargetedDeliveries enabled, "
866+
f"continuing to rollout evaluation."
867+
)
868+
self.logger.info(message)
869+
reasons.append(message)
870+
861871
# If no experiment decision, check rollouts
862872
rollout_decision, rollout_reasons = self.get_variation_for_rollout(
863873
project_config, feature_flag, user_context

‎optimizely/optimizely.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1283,6 +1283,7 @@ def _create_optimizely_decision(
12831283
attributes,
12841284
holdout_decision.cmab_uuid
12851285
)
1286+
decision_event_dispatched = True
12861287

12871288
# Send impression event if Decision came from a feature
12881289
if OptimizelyDecideOption.DISABLE_DECISION_EVENT not in decide_options:

‎tests/test_decision_service_holdout.py‎

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1866,7 +1866,8 @@ def test_global_holdout_exclude_td_true_blocks_ab_experiment(self):
18661866

18671867
def test_global_holdout_exclude_td_true_no_td_returns_non_holdout_decision(self):
18681868
"""When exclude_targeted_deliveries=True but no TD experiments match,
1869-
returns a non-holdout decision with the bypassed holdout attached."""
1869+
returns a non-holdout decision with the bypassed holdout attached
1870+
and includes the rollout evaluation reason."""
18701871
opt = self._make_opt_with_td(
18711872
[_holdout_with_etd('gh1', 'global_exclude_td', exclude_targeted_deliveries=True)],
18721873
)
@@ -1881,6 +1882,12 @@ def test_global_holdout_exclude_td_true_no_td_returns_non_holdout_decision(self)
18811882
self.assertNotEqual(decision.source, enums.DecisionSources.HOLDOUT)
18821883
self.assertIsNotNone(result.get('holdout_decision'))
18831884

1885+
expected_reason = (
1886+
"Holdout 'global_exclude_td' has excludeTargetedDeliveries enabled, "
1887+
"continuing to rollout evaluation."
1888+
)
1889+
self.assertIn(expected_reason, result['reasons'])
1890+
18841891
# ------------------------------------------------------------------
18851892
# Test 5: Local holdout on delivery rule with exclude_targeted_deliveries=True
18861893
# ------------------------------------------------------------------
@@ -2019,7 +2026,8 @@ def test_global_holdout_exclude_td_true_td_match_has_holdout_decision(self):
20192026

20202027
def test_global_holdout_exclude_td_true_no_td_has_holdout_decision(self):
20212028
"""When exclude_targeted_deliveries=True and no TD matches,
2022-
result is non-holdout but holdout_decision key is present."""
2029+
result is non-holdout but holdout_decision key is present,
2030+
and reasons include the rollout evaluation continuation message."""
20232031
opt = self._make_opt_with_td(
20242032
[_holdout_with_etd('gh1', 'global_exclude_td', exclude_targeted_deliveries=True)],
20252033
)
@@ -2036,18 +2044,38 @@ def test_global_holdout_exclude_td_true_no_td_has_holdout_decision(self):
20362044
self.assertIsNotNone(holdout_dec)
20372045
self.assertEqual(holdout_dec.source, enums.DecisionSources.HOLDOUT)
20382046

2047+
expected_reason = (
2048+
"Holdout 'global_exclude_td' has excludeTargetedDeliveries enabled, "
2049+
"continuing to rollout evaluation."
2050+
)
2051+
self.assertIn(expected_reason, result['reasons'])
2052+
20392053
# ------------------------------------------------------------------
20402054
# Test 11: Holdout impression event dispatched for bypassed holdout
20412055
# ------------------------------------------------------------------
20422056

20432057
def test_holdout_impression_sent_when_td_evaluated(self):
20442058
"""When exclude_targeted_deliveries=True and TD matches,
2045-
two impression events are sent: one for holdout, one for TD."""
2059+
two impression events are sent: one for holdout, one for TD,
2060+
and decision_event_dispatched is True in notification."""
20462061
opt = self._make_opt_with_td(
20472062
[_holdout_with_etd('gh1', 'global_exclude_td', exclude_targeted_deliveries=True)],
20482063
experiment_type='td',
20492064
)
2050-
with mock.patch.object(opt, '_send_impression_event') as mock_send:
2065+
2066+
captured_notifications: list[dict[str, object]] = []
2067+
2068+
def capture_notification(notification_type: str, user_id: str,
2069+
user_attributes: dict[str, object],
2070+
decision_info: dict[str, object]) -> None:
2071+
captured_notifications.append(decision_info.copy())
2072+
2073+
opt.notification_center.add_notification_listener(
2074+
enums.NotificationTypes.DECISION,
2075+
capture_notification
2076+
)
2077+
2078+
with mock.patch.object(opt, '_send_impression_event', wraps=opt._send_impression_event) as mock_send:
20512079
user_ctx = opt.create_user_context('testUserId', {})
20522080
user_ctx.decide('test_feature_in_experiment')
20532081

@@ -2056,3 +2084,9 @@ def test_holdout_impression_sent_when_td_evaluated(self):
20562084
for call in mock_send.call_args_list]
20572085
self.assertIn(str(enums.DecisionSources.HOLDOUT), call_rule_types)
20582086
self.assertIn(str(enums.DecisionSources.FEATURE_TEST), call_rule_types)
2087+
2088+
self.assertEqual(len(captured_notifications), 1)
2089+
self.assertTrue(
2090+
captured_notifications[0].get('decision_event_dispatched'),
2091+
'decision_event_dispatched should be True when holdout impression is sent'
2092+
)

0 commit comments

Comments
 (0)