Skip to content
Open
13 changes: 13 additions & 0 deletions core-api/src/main/java/com/optimizely/ab/Optimizely.java
Original file line number Diff line number Diff line change
Expand Up @@ -1356,6 +1356,19 @@ private OptimizelyDecision createOptimizelyDecision(
cmabUuid);
}

if (flagDecision.holdoutDecision != null && !allOptions.contains(OptimizelyDecideOption.DISABLE_DECISION_EVENT)) {
decisionEventDispatched = sendImpression(
projectConfig,
flagDecision.holdoutDecision.experiment,
userId,
copiedAttributes,
flagDecision.holdoutDecision.variation,
flagKey,
flagDecision.holdoutDecision.decisionSource != null ? flagDecision.holdoutDecision.decisionSource.toString() : FeatureDecision.DecisionSource.HOLDOUT.toString(),
flagDecision.holdoutDecision.variation != null && flagDecision.holdoutDecision.variation.getFeatureEnabled(),
null) || decisionEventDispatched;
}

DecisionNotification decisionNotification = DecisionNotification.newFlagDecisionNotificationBuilder()
.withUserId(userId)
.withAttributes(copiedAttributes)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/****************************************************************************
* Copyright 2017-2022, 2024, Optimizely, Inc. and contributors *
* Copyright 2017-2022, 2024, 2026, Optimizely, Inc. and contributors *
* *
* 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 @@ -328,43 +328,66 @@ public List<DecisionResponse<FeatureDecision>> getVariationsForFeatureList(@Non

// Evaluate global holdouts at flag level (before any rules are iterated)
List<Holdout> globalHoldouts = projectConfig.getGlobalHoldouts();
FeatureDecision globalHoldoutDecision = null;
boolean excludeTargetedDeliveries = false;
if (!globalHoldouts.isEmpty()) {
for (Holdout holdout : globalHoldouts) {
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;
globalHoldoutDecision = new FeatureDecision(holdout, holdoutDecision.getResult(), FeatureDecision.DecisionSource.HOLDOUT);
excludeTargetedDeliveries = holdout.isExcludeTargetedDeliveries();
break;
}
}
}

DecisionResponse<FeatureDecision> decisionVariationResponse = getVariationFromExperiment(projectConfig, featureFlag, user, options, userProfileTracker, decisionPath);
reasons.merge(decisionVariationResponse.getReasons());
if (globalHoldoutDecision != null && !excludeTargetedDeliveries) {
decisions.add(new DecisionResponse<>(globalHoldoutDecision, reasons));
continue flagLoop;
}

if (globalHoldoutDecision != null && excludeTargetedDeliveries) {
reasons.addInfo("Holdout \"%s\" has excludeTargetedDeliveries enabled, continuing to rollout evaluation.", globalHoldoutDecision.experiment.getKey());
}
Comment thread
jaeopt marked this conversation as resolved.

FeatureDecision decision = decisionVariationResponse.getResult();
boolean error = decisionVariationResponse.isError();
if (globalHoldoutDecision == null) {
DecisionResponse<FeatureDecision> decisionVariationResponse = getVariationFromExperiment(projectConfig, featureFlag, user, options, userProfileTracker, decisionPath);
reasons.merge(decisionVariationResponse.getReasons());

if (decision != null) {
decisions.add(new DecisionResponse(decision, reasons, error, decision.cmabUuid));
continue;
FeatureDecision decision = decisionVariationResponse.getResult();
boolean error = decisionVariationResponse.isError();

if (decision != null) {
decisions.add(new DecisionResponse(decision, reasons, error, decision.cmabUuid));
continue flagLoop;
}
}

DecisionResponse<FeatureDecision> decisionFeatureResponse = getVariationForFeatureInRollout(featureFlag, user, projectConfig);
reasons.merge(decisionFeatureResponse.getReasons());
decision = decisionFeatureResponse.getResult();
FeatureDecision decision = decisionFeatureResponse.getResult();

String message;
if (decision.variation == null) {
message = reasons.addInfo("The user \"%s\" was not bucketed into a rollout for feature flag \"%s\".",
if (decision != null && decision.variation != null) {
if (globalHoldoutDecision != null) {
decision.setHoldoutDecision(globalHoldoutDecision);
}
String message = reasons.addInfo("The user \"%s\" was bucketed into a rollout for feature flag \"%s\".",
user.getUserId(), featureFlag.getKey());
logger.info(message);
decisions.add(new DecisionResponse(decision, reasons));
} else {
message = reasons.addInfo("The user \"%s\" was bucketed into a rollout for feature flag \"%s\".",
if (globalHoldoutDecision != null) {
if (decision == null) {
decision = new FeatureDecision(null, null, null);
}
decision.setHoldoutDecision(globalHoldoutDecision);
}
String message = reasons.addInfo("The user \"%s\" was not bucketed into a rollout for feature flag \"%s\".",
user.getUserId(), featureFlag.getKey());
logger.info(message);
decisions.add(new DecisionResponse(decision, reasons));
}
logger.info(message);

decisions.add(new DecisionResponse(decision, reasons));
}

if (userProfileService != null && !ignoreUPS) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ public class FeatureDecision {
@Nullable
public String cmabUuid;

@Nullable
public FeatureDecision holdoutDecision;

public void setHoldoutDecision(@Nullable FeatureDecision holdoutDecision) {
this.holdoutDecision = holdoutDecision;
}

public enum DecisionSource {
FEATURE_TEST("feature-test"),
ROLLOUT("rollout"),
Expand Down
15 changes: 12 additions & 3 deletions core-api/src/main/java/com/optimizely/ab/config/Holdout.java
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ public class Holdout implements ExperimentCore {
@Nullable
private final List<String> includedRules;

private final boolean excludeTargetedDeliveries;

private final Map<String, Variation> variationKeyToVariationMap;
private final Map<String, Variation> variationIdToVariationMap;
// Not necessary for HO
Expand All @@ -75,7 +77,7 @@ public String toString() {

@VisibleForTesting
public Holdout(String id, String key) {
this(id, key, "Running", Collections.emptyList(), null, Collections.emptyList(), Collections.emptyList(), null);
this(id, key, "Running", Collections.emptyList(), null, Collections.emptyList(), Collections.emptyList(), null, false);
}

/**
Expand All @@ -88,7 +90,7 @@ public Holdout(@Nonnull String id,
@Nullable Condition audienceConditions,
@Nonnull List<Variation> variations,
@Nonnull List<TrafficAllocation> trafficAllocation) {
this(id, key, status, audienceIds, audienceConditions, variations, trafficAllocation, null);
this(id, key, status, audienceIds, audienceConditions, variations, trafficAllocation, null, false);
}

/**
Expand All @@ -105,7 +107,8 @@ public Holdout(@JsonProperty("id") @Nonnull String id,
@JsonProperty("audienceConditions") @Nullable Condition audienceConditions,
@JsonProperty("variations") @Nonnull List<Variation> variations,
@JsonProperty("trafficAllocation") @Nonnull List<TrafficAllocation> trafficAllocation,
@JsonProperty("includedRules") @Nullable List<String> includedRules) {
@JsonProperty("includedRules") @Nullable List<String> includedRules,
@JsonProperty("exclude_targeted_deliveries") @Nullable Boolean excludeTargetedDeliveries) {
this.id = id;
this.key = key;
this.status = status;
Expand All @@ -114,6 +117,7 @@ public Holdout(@JsonProperty("id") @Nonnull String id,
this.variations = variations;
this.trafficAllocation = trafficAllocation;
this.includedRules = includedRules;
this.excludeTargetedDeliveries = excludeTargetedDeliveries != null ? excludeTargetedDeliveries : false;
this.variationKeyToVariationMap = ProjectConfigUtils.generateNameMapping(this.variations);
this.variationIdToVariationMap = ProjectConfigUtils.generateIdMapping(this.variations);
}
Expand Down Expand Up @@ -188,6 +192,10 @@ public List<String> getIncludedRules() {
*
* @return true if this is a global holdout, false if it is a local holdout
*/
public boolean isExcludeTargetedDeliveries() {
return excludeTargetedDeliveries;
}

public boolean isGlobal() {
return includedRules == null;
}
Expand All @@ -204,6 +212,7 @@ public String toString() {
+ ", variationKeyToVariationMap=" + variationKeyToVariationMap
+ ", trafficAllocation=" + trafficAllocation
+ ", includedRules=" + includedRules
+ ", excludeTargetedDeliveries=" + excludeTargetedDeliveries
+ '}';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,8 @@ private static Holdout stripIncludedRules(Holdout holdout) {
holdout.getAudienceConditions(),
holdout.getVariations(),
holdout.getTrafficAllocation(),
null
null,
holdout.isExcludeTargetedDeliveries()
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
*
* Copyright 2016-2017, 2019, Optimizely and contributors
* Copyright 2016-2017, 2019, 2026, Optimizely and contributors
*
* 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 @@ -212,7 +212,12 @@ static Holdout parseHoldout(JsonObject holdoutJson, JsonDeserializationContext c
}
}

return new Holdout(id, key, status, audienceIds, conditions, variations, trafficAllocations, includedRules);
boolean excludeTargetedDeliveries = false;
if (holdoutJson.has("exclude_targeted_deliveries") && !holdoutJson.get("exclude_targeted_deliveries").isJsonNull()) {
excludeTargetedDeliveries = holdoutJson.get("exclude_targeted_deliveries").getAsBoolean();
}

return new Holdout(id, key, status, audienceIds, conditions, variations, trafficAllocations, includedRules, excludeTargetedDeliveries);
}

static FeatureFlag parseFeatureFlag(JsonObject featureFlagJson, JsonDeserializationContext context) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
*
* Copyright 2016-2022, Optimizely and contributors
* Copyright 2016-2022, 2026, Optimizely and contributors
*
* 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 @@ -238,8 +238,13 @@ private List<Holdout> parseHoldouts(JSONArray holdoutJson) {
}
}

boolean excludeTargetedDeliveries = false;
if (holdoutObject.has("exclude_targeted_deliveries") && !holdoutObject.isNull("exclude_targeted_deliveries")) {
excludeTargetedDeliveries = holdoutObject.getBoolean("exclude_targeted_deliveries");
}

holdouts.add(new Holdout(id, key, status, audienceIds, conditions, variations,
trafficAllocations, includedRules));
trafficAllocations, includedRules, excludeTargetedDeliveries));
}

return holdouts;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
*
* Copyright 2016-2022, Optimizely and contributors
* Copyright 2016-2022, 2026, Optimizely and contributors
*
* 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 @@ -257,8 +257,13 @@ private List<Holdout> parseHoldouts(JSONArray holdoutJson) {
}
}

boolean excludeTargetedDeliveries = false;
if (hoObject.containsKey("exclude_targeted_deliveries") && hoObject.get("exclude_targeted_deliveries") != null) {
excludeTargetedDeliveries = (Boolean) hoObject.get("exclude_targeted_deliveries");
}

holdouts.add(new Holdout(id, key, status, audienceIds, conditions, variations,
trafficAllocations, includedRules));
trafficAllocations, includedRules, excludeTargetedDeliveries));
}

return holdouts;
Expand Down
Loading
Loading