Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
353 changes: 352 additions & 1 deletion OptimizelySDK.Tests/DecisionServiceHoldoutTest.cs

Large diffs are not rendered by default.

628 changes: 618 additions & 10 deletions OptimizelySDK.Tests/TestData/HoldoutTestData.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion OptimizelySDK.Tests/UtilsTests/HoldoutConfigBasicTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ public void TestNullHoldouts()
}

// =====================================================================
// Level 1: Local Holdout / IsGlobal Classification Tests (FSSDK-12369)
// Level 1: Local Holdout / IsGlobal Classification Tests
// =====================================================================

[Test]
Expand Down
77 changes: 57 additions & 20 deletions OptimizelySDK/Bucketing/DecisionService.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2017-2022, 2024 Optimizely
* Copyright 2017-2022, 2024, 2026 Optimizely
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -686,7 +686,6 @@ ProjectConfig config
reasons);
}

// Check local holdouts targeting this specific delivery rule (FSSDK-12369)
var localHoldoutResult = EvaluateLocalHoldouts(rule.Id, user, config);
reasons += localHoldoutResult.DecisionReasons;
if (localHoldoutResult.ResultObject != null)
Expand Down Expand Up @@ -814,7 +813,6 @@ public virtual Result<FeatureDecision> GetVariationForFeatureExperiment(
}
else
{
// Check local holdouts targeting this specific experiment rule (FSSDK-12369)
var localHoldoutResult = EvaluateLocalHoldouts(experiment.Id, user, config);
reasons += localHoldoutResult.DecisionReasons;
if (localHoldoutResult.ResultObject != null)
Expand Down Expand Up @@ -916,7 +914,8 @@ public virtual Result<FeatureDecision> GetDecisionForFlag(

var userId = user.GetUserId();

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

if (holdoutDecision.ResultObject != null)
{
Logger.Log(LogLevel.INFO,
reasons.AddInfo(
$"The user \"{userId}\" is bucketed into holdout \"{holdout.Key}\" for feature flag \"{featureFlag.Key}\"."));
return Result<FeatureDecision>.NewResult(holdoutDecision.ResultObject, reasons);
if (holdout.ExcludeTargetedDeliveries)
{
Logger.Log(LogLevel.INFO,
reasons.AddInfo(
$"User \"{userId}\" is in holdout \"{holdout.Key}\" which excludes targeted deliveries. Targeted delivery rules will be evaluated normally."));
globalHoldoutDecision = holdoutDecision.ResultObject;
matchedHoldout = holdout;
}
else
{
Logger.Log(LogLevel.INFO,
reasons.AddInfo(
$"The user \"{userId}\" is bucketed into holdout \"{holdout.Key}\" for feature flag \"{featureFlag.Key}\"."));
return Result<FeatureDecision>.NewResult(holdoutDecision.ResultObject, reasons);
}
break;
}
}

// Check if the feature flag has an experiment and the user is bucketed into that experiment.
var experimentDecision = GetVariationForFeatureExperiment(featureFlag, user,
filteredAttributes, projectConfig, options, userProfileTracker);
reasons += experimentDecision.DecisionReasons;
if (globalHoldoutDecision == null)
{
var experimentDecision = GetVariationForFeatureExperiment(featureFlag, user,
filteredAttributes, projectConfig, options, userProfileTracker);
reasons += experimentDecision.DecisionReasons;

if (experimentDecision.ResultObject != null)
if (experimentDecision.ResultObject != null)
{
return Result<FeatureDecision>.NewResult(experimentDecision.ResultObject, reasons);
}
}
else
{
return Result<FeatureDecision>.NewResult(experimentDecision.ResultObject, reasons);
Logger.Log(LogLevel.INFO,
reasons.AddInfo(
$"Skipping experiment rules for user \"{userId}\" — holdout applies to A/B and MAB rules."));

if (matchedHoldout != null && matchedHoldout.ExcludeTargetedDeliveries)
{
Logger.Log(LogLevel.INFO,
reasons.AddInfo(
$"Holdout \"{matchedHoldout.Key}\" has excludeTargetedDeliveries enabled, continuing to rollout evaluation."));
}
}

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

Expand All @@ -951,17 +976,29 @@ public virtual Result<FeatureDecision> GetDecisionForFlag(
Logger.Log(LogLevel.INFO,
reasons.AddInfo(
$"The user \"{userId}\" is bucketed into a rollout for feature flag \"{featureFlag.Key}\"."));
if (globalHoldoutDecision != null)
{
rolloutDecision.ResultObject.HoldoutDecision = globalHoldoutDecision;
}
return Result<FeatureDecision>.NewResult(rolloutDecision.ResultObject, reasons);
}
else

if (globalHoldoutDecision != null)
{
var nullDecision = new FeatureDecision(null, null, FeatureDecision.DECISION_SOURCE_ROLLOUT);
nullDecision.HoldoutDecision = globalHoldoutDecision;
Logger.Log(LogLevel.INFO,
reasons.AddInfo(
$"The user \"{userId}\" is not bucketed into a rollout for feature flag \"{featureFlag.Key}\"."));
return Result<FeatureDecision>.NewResult(
new FeatureDecision(null, null, FeatureDecision.DECISION_SOURCE_ROLLOUT),
reasons);
$"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."));
return Result<FeatureDecision>.NewResult(nullDecision, reasons);
}

Logger.Log(LogLevel.INFO,
reasons.AddInfo(
$"The user \"{userId}\" is not bucketed into a rollout for feature flag \"{featureFlag.Key}\"."));
return Result<FeatureDecision>.NewResult(
new FeatureDecision(null, null, FeatureDecision.DECISION_SOURCE_ROLLOUT),
reasons);
}

public virtual List<Result<FeatureDecision>> GetVariationsForFeatureList(
Expand Down
4 changes: 3 additions & 1 deletion OptimizelySDK/Entity/Experiment.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2017-2019, Optimizely
* Copyright 2017-2019, 2026 Optimizely
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -25,6 +25,8 @@ public class Experiment : ExperimentCore

public const string EXPERIMENT_TYPE_FR = "fr";

public const string EXPERIMENT_TYPE_TD = "td";

/// <summary>
/// Group ID for the experiment
/// </summary>
Expand Down
1 change: 1 addition & 0 deletions OptimizelySDK/Entity/FeatureDecision.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public class FeatureDecision
public string Source { get; }
public string CmabUuid { get; }
public bool Error { get; }
public FeatureDecision HoldoutDecision { get; set; }

public FeatureDecision(ExperimentCore experiment, Variation variation, string source, string cmabUuid = null, bool error = false)
{
Expand Down
4 changes: 4 additions & 0 deletions OptimizelySDK/Entity/Holdout.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/

using System.Collections.Generic;
using Newtonsoft.Json;

namespace OptimizelySDK.Entity
{
Expand Down Expand Up @@ -51,6 +52,9 @@ public override string LayerId
/// </summary>
public string[] IncludedRules { get; set; }

[JsonProperty("exclude_targeted_deliveries")]
public bool ExcludeTargetedDeliveries { get; set; }

/// <summary>
/// True when global (IncludedRules is null). Consistent with section membership
/// because the config parser strips IncludedRules on 'holdouts'-section entries.
Expand Down
12 changes: 12 additions & 0 deletions OptimizelySDK/Optimizely.cs
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@
try
{
#if USE_ODP
InitializeComponents(eventDispatcher, logger, errorHandler, userProfileService,

Check warning on line 149 in OptimizelySDK/Optimizely.cs

View workflow job for this annotation

GitHub Actions / Build Standard 2.0

'Optimizely.InitializeComponents(IEventDispatcher, ILogger, IErrorHandler, UserProfileService, NotificationCenter, EventProcessor, OptimizelyDecideOption[], IOdpManager, ICmabService, CmabConfig)' is obsolete

Check warning on line 149 in OptimizelySDK/Optimizely.cs

View workflow job for this annotation

GitHub Actions / Build Standard 2.0

'Optimizely.InitializeComponents(IEventDispatcher, ILogger, IErrorHandler, UserProfileService, NotificationCenter, EventProcessor, OptimizelyDecideOption[], IOdpManager, ICmabService, CmabConfig)' is obsolete
null, eventProcessor, defaultDecideOptions, odpManager);
#else
InitializeComponents(eventDispatcher, logger, errorHandler, userProfileService,
Expand Down Expand Up @@ -218,7 +218,7 @@
ProjectConfigManager = configManager;

#if USE_ODP && USE_CMAB
InitializeComponents(eventDispatcher, logger, errorHandler, userProfileService,

Check warning on line 221 in OptimizelySDK/Optimizely.cs

View workflow job for this annotation

GitHub Actions / Build Standard 2.0

'Optimizely.InitializeComponents(IEventDispatcher, ILogger, IErrorHandler, UserProfileService, NotificationCenter, EventProcessor, OptimizelyDecideOption[], IOdpManager, ICmabService, CmabConfig)' is obsolete

Check warning on line 221 in OptimizelySDK/Optimizely.cs

View workflow job for this annotation

GitHub Actions / Build Standard 2.0

'Optimizely.InitializeComponents(IEventDispatcher, ILogger, IErrorHandler, UserProfileService, NotificationCenter, EventProcessor, OptimizelyDecideOption[], IOdpManager, ICmabService, CmabConfig)' is obsolete
notificationCenter, eventProcessor, defaultDecideOptions, odpManager, null, cmabConfig);
#elif USE_ODP
InitializeComponents(eventDispatcher, logger, errorHandler, userProfileService,
Expand Down Expand Up @@ -1126,6 +1126,18 @@
var ruleKey = flagDecision.Experiment?.Key;

var decisionEventDispatched = false;
if (flagDecision?.HoldoutDecision != null && !allOptions.Contains(OptimizelyDecideOption.DISABLE_DECISION_EVENT))
{
decisionEventDispatched = SendImpressionEvent(
flagDecision.HoldoutDecision.Experiment,
flagDecision.HoldoutDecision.Variation,
userId, user.GetAttributes(), projectConfig,
flagKey, FeatureDecision.DECISION_SOURCE_HOLDOUT, true
#if USE_CMAB
, null
#endif
) || decisionEventDispatched;
}
if (!allOptions.Contains(OptimizelyDecideOption.DISABLE_DECISION_EVENT))
{
decisionEventDispatched = SendImpressionEvent(
Expand Down
Loading