Skip to content

[FSSDK-11522] feat: update decision service to apply ho #576

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

Merged
merged 17 commits into from
Aug 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
5cb4eb3
fix: update method parameters in Optimizely.java and Bucketer.java to…
muzahidul-opti Aug 6, 2025
b7f169b
feat: add holdout rule support in DecisionService
muzahidul-opti Aug 6, 2025
c68b096
fix(core-api): fix bucket method parameter in Bucketer.java
muzahidul-opti Aug 6, 2025
d8068e2
fix: correct visibility and initialization of constants in ValidProje…
muzahidul-opti Aug 7, 2025
8f831f1
test: add tests for holdout feature in DecisionServiceTest
muzahidul-opti Aug 7, 2025
524fc9f
feat: add HOLDOUT_TYPEDAUDIENCE_HOLDOUT to ValidProjectConfigV4
muzahidul-opti Aug 7, 2025
c17e2a6
fix(core-api): update Experiment import to ExperimentCore in FeatureD…
muzahidul-opti Aug 7, 2025
2bd5e17
chore: clarify holdout logs and replace wildcards
muzahidul-opti Aug 8, 2025
56ab9f1
test: add holdout decision test to verify behavior
muzahidul-opti Aug 8, 2025
1f33478
refactor: rename test from 'decide_holdoutApplied_basic' to 'decide_w…
muzahidul-opti Aug 8, 2025
2e98731
fix: update eventHandler call to include 'nationality' for impression…
muzahidul-opti Aug 8, 2025
62b24a9
Add decision notification test case for ho
muzahidul-opti Aug 11, 2025
075fd4e
Revert back original test cases
muzahidul-opti Aug 11, 2025
5a1cca2
feat: add method for handling decision notifications with holdouts
muzahidul-opti Aug 11, 2025
3ea6d9e
Revert original test cases
muzahidul-opti Aug 11, 2025
8b36afd
feat: implement decision notification handling with holdouts
muzahidul-opti Aug 11, 2025
d8fc1e4
feat(core-api): deprecate ExperimentCore and replace with Experiment …
muzahidul-opti Aug 12, 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
11 changes: 8 additions & 3 deletions core-api/src/main/java/com/optimizely/ab/Optimizely.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import com.optimizely.ab.config.DatafileProjectConfig;
import com.optimizely.ab.config.EventType;
import com.optimizely.ab.config.Experiment;
import com.optimizely.ab.config.ExperimentCore;
import com.optimizely.ab.config.FeatureFlag;
import com.optimizely.ab.config.FeatureVariable;
import com.optimizely.ab.config.FeatureVariableUsageInstance;
Expand Down Expand Up @@ -319,7 +320,7 @@ private void sendImpression(@Nonnull ProjectConfig projectConfig,
* @param ruleType It can either be experiment in case impression event is sent from activate or it's feature-test or rollout
*/
private boolean sendImpression(@Nonnull ProjectConfig projectConfig,
@Nullable Experiment experiment,
@Nullable ExperimentCore experiment,
@Nonnull String userId,
@Nonnull Map<String, ?> filteredAttributes,
@Nullable Variation variation,
Expand All @@ -344,13 +345,17 @@ private boolean sendImpression(@Nonnull ProjectConfig projectConfig,
if (experiment != null) {
logger.info("Activating user \"{}\" in experiment \"{}\".", userId, experiment.getKey());
}

// Legacy API methods only apply to the Experiment type and not to Holdout.
boolean isExperimentType = experiment instanceof Experiment;

// Kept For backwards compatibility.
// This notification is deprecated and the new DecisionNotifications
// are sent via their respective method calls.
if (notificationCenter.getNotificationManager(ActivateNotification.class).size() > 0) {
if (notificationCenter.getNotificationManager(ActivateNotification.class).size() > 0 && isExperimentType) {
LogEvent impressionEvent = EventFactory.createLogEvent(userEvent);
ActivateNotification activateNotification = new ActivateNotification(
experiment, userId, filteredAttributes, variation, impressionEvent);
(Experiment)experiment, userId, filteredAttributes, variation, impressionEvent);
notificationCenter.send(activateNotification);
}
return true;
Expand Down
27 changes: 17 additions & 10 deletions core-api/src/main/java/com/optimizely/ab/bucketing/Bucketer.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,25 +16,32 @@
*/
package com.optimizely.ab.bucketing;

import java.util.List;

import javax.annotation.Nonnull;
import javax.annotation.concurrent.Immutable;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.optimizely.ab.annotations.VisibleForTesting;
import com.optimizely.ab.bucketing.internal.MurmurHash3;
import com.optimizely.ab.config.*;
import com.optimizely.ab.config.Experiment;
import com.optimizely.ab.config.ExperimentCore;
import com.optimizely.ab.config.Group;
import com.optimizely.ab.config.ProjectConfig;
import com.optimizely.ab.config.TrafficAllocation;
import com.optimizely.ab.config.Variation;
import com.optimizely.ab.optimizelydecision.DecisionReasons;
import com.optimizely.ab.optimizelydecision.DecisionResponse;
import com.optimizely.ab.optimizelydecision.DefaultDecisionReasons;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import javax.annotation.concurrent.Immutable;
import java.util.List;

/**
* Default Optimizely bucketing algorithm that evenly distributes users using the Murmur3 hash of some provided
* identifier.
* <p>
* The user identifier <i>must</i> be provided in the first data argument passed to
* {@link #bucket(Experiment, String, ProjectConfig)} and <i>must</i> be non-null and non-empty.
* {@link #bucket(ExperimentCore, String, ProjectConfig)} and <i>must</i> be non-null and non-empty.
*
* @see <a href="https://en.wikipedia.org/wiki/MurmurHash">MurmurHash</a>
*/
Expand Down Expand Up @@ -89,7 +96,7 @@ private Experiment bucketToExperiment(@Nonnull Group group,
}

@Nonnull
private DecisionResponse<Variation> bucketToVariation(@Nonnull Experiment experiment,
private DecisionResponse<Variation> bucketToVariation(@Nonnull ExperimentCore experiment,
@Nonnull String bucketingId) {
DecisionReasons reasons = DefaultDecisionReasons.newInstance();

Expand Down Expand Up @@ -130,7 +137,7 @@ private DecisionResponse<Variation> bucketToVariation(@Nonnull Experiment experi
* @return A {@link DecisionResponse} including the {@link Variation} that user is bucketed into (or null) and the decision reasons
*/
@Nonnull
public DecisionResponse<Variation> bucket(@Nonnull Experiment experiment,
public DecisionResponse<Variation> bucket(@Nonnull ExperimentCore experiment,
@Nonnull String bucketingId,
@Nonnull ProjectConfig projectConfig) {
DecisionReasons reasons = DefaultDecisionReasons.newInstance();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,27 +15,39 @@
***************************************************************************/
package com.optimizely.ab.bucketing;

import java.util.AbstractMap;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.optimizely.ab.OptimizelyDecisionContext;
import com.optimizely.ab.OptimizelyForcedDecision;
import com.optimizely.ab.OptimizelyRuntimeException;
import com.optimizely.ab.OptimizelyUserContext;
import com.optimizely.ab.config.*;
import com.optimizely.ab.config.Experiment;
import com.optimizely.ab.config.FeatureFlag;
import com.optimizely.ab.config.Holdout;
import com.optimizely.ab.config.ProjectConfig;
import com.optimizely.ab.config.Rollout;
import com.optimizely.ab.config.Variation;
import com.optimizely.ab.error.ErrorHandler;
import com.optimizely.ab.internal.ControlAttribute;
import com.optimizely.ab.internal.ExperimentUtils;
import static com.optimizely.ab.internal.LoggingConstants.LoggingEntityType.EXPERIMENT;
import static com.optimizely.ab.internal.LoggingConstants.LoggingEntityType.RULE;
import com.optimizely.ab.optimizelydecision.DecisionReasons;
import com.optimizely.ab.optimizelydecision.DecisionResponse;
import com.optimizely.ab.optimizelydecision.DefaultDecisionReasons;
import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;

import static com.optimizely.ab.internal.LoggingConstants.LoggingEntityType.EXPERIMENT;
import static com.optimizely.ab.internal.LoggingConstants.LoggingEntityType.RULE;

/**
* Optimizely's decision service that determines which variation of an experiment the user will be allocated to.
Expand Down Expand Up @@ -240,10 +252,22 @@ public List<DecisionResponse<FeatureDecision>> getVariationsForFeatureList(@Non

List<DecisionResponse<FeatureDecision>> decisions = new ArrayList<>();

for (FeatureFlag featureFlag: featureFlags) {
flagLoop: for (FeatureFlag featureFlag: featureFlags) {
DecisionReasons reasons = DefaultDecisionReasons.newInstance();
reasons.merge(upsReasons);

List<Holdout> holdouts = projectConfig.getHoldoutForFlag(featureFlag.getId());
if (!holdouts.isEmpty()) {
for (Holdout holdout : holdouts) {
DecisionResponse<Variation> holdoutDecision = getVariationForHoldout(holdout, user, projectConfig);
reasons.merge(holdoutDecision.getReasons());
if (holdoutDecision.getResult() != null) {
decisions.add(new DecisionResponse<>(new FeatureDecision(holdout, holdoutDecision.getResult(), FeatureDecision.DecisionSource.HOLDOUT), reasons));
continue flagLoop;
}
}
}

DecisionResponse<FeatureDecision> decisionVariationResponse = getVariationFromExperiment(projectConfig, featureFlag, user, options, userProfileTracker);
reasons.merge(decisionVariationResponse.getReasons());

Expand Down Expand Up @@ -419,6 +443,54 @@ DecisionResponse<Variation> getWhitelistedVariation(@Nonnull Experiment experime
return new DecisionResponse(null, reasons);
}

/**
* Determines the variation for a holdout rule.
*
* @param holdout The holdout rule to evaluate.
* @param user The user context.
* @param projectConfig The current project configuration.
* @return A {@link DecisionResponse} with the variation (if any) and reasons.
*/
@Nonnull
DecisionResponse<Variation> getVariationForHoldout(@Nonnull Holdout holdout,
@Nonnull OptimizelyUserContext user,
@Nonnull ProjectConfig projectConfig) {
DecisionReasons reasons = DefaultDecisionReasons.newInstance();

if (!holdout.isActive()) {
String message = reasons.addInfo("Holdout (%s) is not running.", holdout.getKey());
logger.info(message);
return new DecisionResponse<>(null, reasons);
}

DecisionResponse<Boolean> decisionMeetAudience = ExperimentUtils.doesUserMeetAudienceConditions(projectConfig, holdout, user, EXPERIMENT, holdout.getKey());
reasons.merge(decisionMeetAudience.getReasons());

if (decisionMeetAudience.getResult()) {
// User meets audience conditions for holdout
String audienceMatchMessage = reasons.addInfo("User (%s) meets audience conditions for holdout (%s).", user.getUserId(), holdout.getKey());
logger.info(audienceMatchMessage);

String bucketingId = getBucketingId(user.getUserId(), user.getAttributes());
DecisionResponse<Variation> decisionVariation = bucketer.bucket(holdout, bucketingId, projectConfig);
reasons.merge(decisionVariation.getReasons());
Variation variation = decisionVariation.getResult();

if (variation != null) {
String message = reasons.addInfo("User (%s) is in variation (%s) of holdout (%s).", user.getUserId(), variation.getKey(), holdout.getKey());
logger.info(message);
} else {
String message = reasons.addInfo("User (%s) is in no holdout variation.", user.getUserId());
logger.info(message);
}
return new DecisionResponse<>(variation, reasons);
}

String message = reasons.addInfo("User (%s) does not meet conditions for holdout (%s).", user.getUserId(), holdout.getKey());
logger.info(message);
return new DecisionResponse<>(null, reasons);
}


// TODO: Logically, it makes sense to move this method to UserProfileTracker. But some tests are also calling this
// method, requiring us to refactor those tests as well. We'll look to refactor this later.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,17 @@
***************************************************************************/
package com.optimizely.ab.bucketing;

import com.optimizely.ab.config.Experiment;
import com.optimizely.ab.config.Variation;

import javax.annotation.Nullable;

import com.optimizely.ab.config.ExperimentCore;
import com.optimizely.ab.config.Variation;

public class FeatureDecision {
/**
* The {@link Experiment} the Feature is associated with.
* The {@link ExperimentCore} the Feature is associated with.
*/
@Nullable
public Experiment experiment;
public ExperimentCore experiment;

/**
* The {@link Variation} the user was bucketed into.
Expand All @@ -41,7 +41,8 @@ public class FeatureDecision {

public enum DecisionSource {
FEATURE_TEST("feature-test"),
ROLLOUT("rollout");
ROLLOUT("rollout"),
HOLDOUT("holdout");

private final String key;

Expand All @@ -58,11 +59,11 @@ public String toString() {
/**
* Initialize a FeatureDecision object.
*
* @param experiment The {@link Experiment} the Feature is associated with.
* @param experiment The {@link ExperimentCore} the Feature is associated with.
* @param variation The {@link Variation} the user was bucketed into.
* @param decisionSource The source of the variation.
*/
public FeatureDecision(@Nullable Experiment experiment, @Nullable Variation variation,
public FeatureDecision(@Nullable ExperimentCore experiment, @Nullable Variation variation,
@Nullable DecisionSource decisionSource) {
this.experiment = experiment;
this.variation = variation;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,23 +16,26 @@
*/
package com.optimizely.ab.event.internal;

import java.util.Map;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.optimizely.ab.bucketing.FeatureDecision;
import com.optimizely.ab.config.Experiment;
import com.optimizely.ab.config.ExperimentCore;
import com.optimizely.ab.config.ProjectConfig;
import com.optimizely.ab.config.Variation;
import com.optimizely.ab.event.internal.payload.DecisionMetadata;
import com.optimizely.ab.internal.EventTagUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.Map;

public class UserEventFactory {
private static final Logger logger = LoggerFactory.getLogger(UserEventFactory.class);

public static ImpressionEvent createImpressionEvent(@Nonnull ProjectConfig projectConfig,
@Nullable Experiment activatedExperiment,
@Nullable ExperimentCore activatedExperiment,
@Nullable Variation variation,
@Nonnull String userId,
@Nonnull Map<String, ?> attributes,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,22 +16,24 @@
*/
package com.optimizely.ab.internal;

import java.util.ArrayList;
import java.util.List;

import javax.annotation.Nonnull;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.optimizely.ab.OptimizelyUserContext;
import com.optimizely.ab.config.Experiment;
import com.optimizely.ab.config.ExperimentCore;
import com.optimizely.ab.config.ProjectConfig;
import com.optimizely.ab.config.audience.AudienceIdCondition;
import com.optimizely.ab.config.audience.Condition;
import com.optimizely.ab.config.audience.OrCondition;
import com.optimizely.ab.optimizelydecision.DecisionReasons;
import com.optimizely.ab.optimizelydecision.DecisionResponse;
import com.optimizely.ab.optimizelydecision.DefaultDecisionReasons;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

public final class ExperimentUtils {

Expand Down Expand Up @@ -62,7 +64,7 @@ public static boolean isExperimentActive(@Nonnull Experiment experiment) {
*/
@Nonnull
public static DecisionResponse<Boolean> doesUserMeetAudienceConditions(@Nonnull ProjectConfig projectConfig,
@Nonnull Experiment experiment,
@Nonnull ExperimentCore experiment,
@Nonnull OptimizelyUserContext user,
@Nonnull String loggingEntityType,
@Nonnull String loggingKey) {
Expand All @@ -86,7 +88,7 @@ public static DecisionResponse<Boolean> doesUserMeetAudienceConditions(@Nonnull

@Nonnull
public static DecisionResponse<Boolean> evaluateAudience(@Nonnull ProjectConfig projectConfig,
@Nonnull Experiment experiment,
@Nonnull ExperimentCore experiment,
@Nonnull OptimizelyUserContext user,
@Nonnull String loggingEntityType,
@Nonnull String loggingKey) {
Expand Down Expand Up @@ -118,7 +120,7 @@ public static DecisionResponse<Boolean> evaluateAudience(@Nonnull ProjectConfig

@Nonnull
public static DecisionResponse<Boolean> evaluateAudienceConditions(@Nonnull ProjectConfig projectConfig,
@Nonnull Experiment experiment,
@Nonnull ExperimentCore experiment,
@Nonnull OptimizelyUserContext user,
@Nonnull String loggingEntityType,
@Nonnull String loggingKey) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@
*/
package com.optimizely.ab.notification;

import java.util.Map;

import com.optimizely.ab.annotations.VisibleForTesting;
import com.optimizely.ab.config.Experiment;
import com.optimizely.ab.config.Variation;
import com.optimizely.ab.event.LogEvent;

import java.util.Map;

/**
* ActivateNotification supplies notification for AB activatation.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,14 @@

package com.optimizely.ab.notification;

import java.util.Map;

import javax.annotation.Nonnull;

import com.optimizely.ab.config.Experiment;
import com.optimizely.ab.config.Variation;
import com.optimizely.ab.event.LogEvent;

import javax.annotation.Nonnull;
import java.util.Map;

/**
* ActivateNotificationListener handles the activate event notification.
*
Expand Down
Loading
Loading