Skip to content

Commit e6e6f77

Browse files
[AI-FSSDK] [FSSDK-12735] Add excludeTargetedDeliveries support to holdout logic (#420)
1 parent 6bbba7e commit e6e6f77

8 files changed

Lines changed: 1110 additions & 34 deletions

File tree

OptimizelySDK.Tests/DecisionServiceHoldoutTest.cs

Lines changed: 414 additions & 1 deletion
Large diffs are not rendered by default.

OptimizelySDK.Tests/TestData/HoldoutTestData.json

Lines changed: 618 additions & 10 deletions
Large diffs are not rendered by default.

OptimizelySDK.Tests/UtilsTests/HoldoutConfigBasicTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ public void TestNullHoldouts()
126126
}
127127

128128
// =====================================================================
129-
// Level 1: Local Holdout / IsGlobal Classification Tests (FSSDK-12369)
129+
// Level 1: Local Holdout / IsGlobal Classification Tests
130130
// =====================================================================
131131

132132
[Test]

OptimizelySDK/Bucketing/DecisionService.cs

Lines changed: 57 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2017-2022, 2024 Optimizely
2+
* Copyright 2017-2022, 2024, 2026 Optimizely
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -686,7 +686,6 @@ ProjectConfig config
686686
reasons);
687687
}
688688

689-
// Check local holdouts targeting this specific delivery rule (FSSDK-12369)
690689
var localHoldoutResult = EvaluateLocalHoldouts(rule.Id, user, config);
691690
reasons += localHoldoutResult.DecisionReasons;
692691
if (localHoldoutResult.ResultObject != null)
@@ -814,7 +813,6 @@ public virtual Result<FeatureDecision> GetVariationForFeatureExperiment(
814813
}
815814
else
816815
{
817-
// Check local holdouts targeting this specific experiment rule (FSSDK-12369)
818816
var localHoldoutResult = EvaluateLocalHoldouts(experiment.Id, user, config);
819817
reasons += localHoldoutResult.DecisionReasons;
820818
if (localHoldoutResult.ResultObject != null)
@@ -916,7 +914,8 @@ public virtual Result<FeatureDecision> GetDecisionForFlag(
916914

917915
var userId = user.GetUserId();
918916

919-
// Check global holdouts first (highest priority — evaluated at flag level, before any rules)
917+
FeatureDecision globalHoldoutDecision = null;
918+
Holdout matchedHoldout = null;
920919
var globalHoldouts = projectConfig.GetGlobalHoldouts();
921920
foreach (var holdout in globalHoldouts)
922921
{
@@ -925,24 +924,50 @@ public virtual Result<FeatureDecision> GetDecisionForFlag(
925924

926925
if (holdoutDecision.ResultObject != null)
927926
{
928-
Logger.Log(LogLevel.INFO,
929-
reasons.AddInfo(
930-
$"The user \"{userId}\" is bucketed into holdout \"{holdout.Key}\" for feature flag \"{featureFlag.Key}\"."));
931-
return Result<FeatureDecision>.NewResult(holdoutDecision.ResultObject, reasons);
927+
if (holdout.ExcludeTargetedDeliveries)
928+
{
929+
Logger.Log(LogLevel.INFO,
930+
reasons.AddInfo(
931+
$"User \"{userId}\" is in holdout \"{holdout.Key}\" which excludes targeted deliveries. Targeted delivery rules will be evaluated normally."));
932+
globalHoldoutDecision = holdoutDecision.ResultObject;
933+
matchedHoldout = holdout;
934+
}
935+
else
936+
{
937+
Logger.Log(LogLevel.INFO,
938+
reasons.AddInfo(
939+
$"The user \"{userId}\" is bucketed into holdout \"{holdout.Key}\" for feature flag \"{featureFlag.Key}\"."));
940+
return Result<FeatureDecision>.NewResult(holdoutDecision.ResultObject, reasons);
941+
}
942+
break;
932943
}
933944
}
934945

935-
// Check if the feature flag has an experiment and the user is bucketed into that experiment.
936-
var experimentDecision = GetVariationForFeatureExperiment(featureFlag, user,
937-
filteredAttributes, projectConfig, options, userProfileTracker);
938-
reasons += experimentDecision.DecisionReasons;
946+
if (globalHoldoutDecision == null)
947+
{
948+
var experimentDecision = GetVariationForFeatureExperiment(featureFlag, user,
949+
filteredAttributes, projectConfig, options, userProfileTracker);
950+
reasons += experimentDecision.DecisionReasons;
939951

940-
if (experimentDecision.ResultObject != null)
952+
if (experimentDecision.ResultObject != null)
953+
{
954+
return Result<FeatureDecision>.NewResult(experimentDecision.ResultObject, reasons);
955+
}
956+
}
957+
else
941958
{
942-
return Result<FeatureDecision>.NewResult(experimentDecision.ResultObject, reasons);
959+
Logger.Log(LogLevel.INFO,
960+
reasons.AddInfo(
961+
$"Skipping experiment rules for user \"{userId}\" — holdout applies to A/B and MAB rules."));
962+
963+
if (matchedHoldout != null && matchedHoldout.ExcludeTargetedDeliveries)
964+
{
965+
Logger.Log(LogLevel.INFO,
966+
reasons.AddInfo(
967+
$"Holdout \"{matchedHoldout.Key}\" has excludeTargetedDeliveries enabled, continuing to rollout evaluation."));
968+
}
943969
}
944970

945-
// Check if the feature flag has rollout and the user is bucketed into one of its rules.
946971
var rolloutDecision = GetVariationForFeatureRollout(featureFlag, user, projectConfig);
947972
reasons += rolloutDecision.DecisionReasons;
948973

@@ -951,17 +976,29 @@ public virtual Result<FeatureDecision> GetDecisionForFlag(
951976
Logger.Log(LogLevel.INFO,
952977
reasons.AddInfo(
953978
$"The user \"{userId}\" is bucketed into a rollout for feature flag \"{featureFlag.Key}\"."));
979+
if (globalHoldoutDecision != null)
980+
{
981+
rolloutDecision.ResultObject.HoldoutDecision = globalHoldoutDecision;
982+
}
954983
return Result<FeatureDecision>.NewResult(rolloutDecision.ResultObject, reasons);
955984
}
956-
else
985+
986+
if (globalHoldoutDecision != null)
957987
{
988+
var nullDecision = new FeatureDecision(null, null, FeatureDecision.DECISION_SOURCE_ROLLOUT);
989+
nullDecision.HoldoutDecision = globalHoldoutDecision;
958990
Logger.Log(LogLevel.INFO,
959991
reasons.AddInfo(
960-
$"The user \"{userId}\" is not bucketed into a rollout for feature flag \"{featureFlag.Key}\"."));
961-
return Result<FeatureDecision>.NewResult(
962-
new FeatureDecision(null, null, FeatureDecision.DECISION_SOURCE_ROLLOUT),
963-
reasons);
992+
$"The user \"{userId}\" is not bucketed into any targeted delivery for feature flag \"{featureFlag.Key}\". Holdout impression will be sent but holdout variation is not applied as fallback."));
993+
return Result<FeatureDecision>.NewResult(nullDecision, reasons);
964994
}
995+
996+
Logger.Log(LogLevel.INFO,
997+
reasons.AddInfo(
998+
$"The user \"{userId}\" is not bucketed into a rollout for feature flag \"{featureFlag.Key}\"."));
999+
return Result<FeatureDecision>.NewResult(
1000+
new FeatureDecision(null, null, FeatureDecision.DECISION_SOURCE_ROLLOUT),
1001+
reasons);
9651002
}
9661003

9671004
public virtual List<Result<FeatureDecision>> GetVariationsForFeatureList(

OptimizelySDK/Entity/Experiment.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2017-2019, Optimizely
2+
* Copyright 2017-2019, 2026 Optimizely
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.

OptimizelySDK/Entity/FeatureDecision.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ public class FeatureDecision
2626
public string Source { get; }
2727
public string CmabUuid { get; }
2828
public bool Error { get; }
29+
public FeatureDecision HoldoutDecision { get; set; }
2930

3031
public FeatureDecision(ExperimentCore experiment, Variation variation, string source, string cmabUuid = null, bool error = false)
3132
{

OptimizelySDK/Entity/Holdout.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
*/
1616

1717
using System.Collections.Generic;
18+
using Newtonsoft.Json;
1819

1920
namespace OptimizelySDK.Entity
2021
{
@@ -51,6 +52,9 @@ public override string LayerId
5152
/// </summary>
5253
public string[] IncludedRules { get; set; }
5354

55+
[JsonProperty("excludeTargetedDeliveries")]
56+
public bool ExcludeTargetedDeliveries { get; set; }
57+
5458
/// <summary>
5559
/// True when global (IncludedRules is null). Consistent with section membership
5660
/// because the config parser strips IncludedRules on 'holdouts'-section entries.

OptimizelySDK/Optimizely.cs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1126,6 +1126,19 @@ ProjectConfig projectConfig
11261126
var ruleKey = flagDecision.Experiment?.Key;
11271127

11281128
var decisionEventDispatched = false;
1129+
if (flagDecision?.HoldoutDecision != null && !allOptions.Contains(OptimizelyDecideOption.DISABLE_DECISION_EVENT))
1130+
{
1131+
decisionEventDispatched = SendImpressionEvent(
1132+
flagDecision.HoldoutDecision.Experiment,
1133+
flagDecision.HoldoutDecision.Variation,
1134+
userId, user.GetAttributes(), projectConfig,
1135+
flagKey, FeatureDecision.DECISION_SOURCE_HOLDOUT,
1136+
flagDecision.HoldoutDecision.Variation?.FeatureEnabled ?? false
1137+
#if USE_CMAB
1138+
, null
1139+
#endif
1140+
) || decisionEventDispatched;
1141+
}
11291142
if (!allOptions.Contains(OptimizelyDecideOption.DISABLE_DECISION_EVENT))
11301143
{
11311144
decisionEventDispatched = SendImpressionEvent(
@@ -1140,7 +1153,7 @@ ProjectConfig projectConfig
11401153
#if USE_CMAB
11411154
, flagDecision.CmabUuid
11421155
#endif
1143-
);
1156+
) || decisionEventDispatched;
11441157
}
11451158

11461159
var decisionInfo = new Dictionary<string, object>

0 commit comments

Comments
 (0)