Skip to content

feat(decide): add decide API #407

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 20 commits into from
Oct 16, 2020
Merged
Show file tree
Hide file tree
Changes from 8 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
12 changes: 5 additions & 7 deletions core-api/src/main/java/com/optimizely/ab/Optimizely.java
Original file line number Diff line number Diff line change
Expand Up @@ -78,17 +78,16 @@ public class Optimizely implements AutoCloseable {

private static final Logger logger = LoggerFactory.getLogger(Optimizely.class);

@VisibleForTesting
final DecisionService decisionService;
public final DecisionService decisionService;
@VisibleForTesting
@Deprecated
final EventHandler eventHandler;
@VisibleForTesting
final EventProcessor eventProcessor;
@VisibleForTesting
final ErrorHandler errorHandler;
@VisibleForTesting
final OptimizelyDecideOption[] defaultDecideOptions;

public final OptimizelyDecideOption[] defaultDecideOptions;

private final ProjectConfigManager projectConfigManager;

Expand Down Expand Up @@ -227,7 +226,7 @@ private Variation activate(@Nullable ProjectConfig projectConfig,
return variation;
}

private void sendImpression(@Nonnull ProjectConfig projectConfig,
public void sendImpression(@Nonnull ProjectConfig projectConfig,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we move OptimizelyUserContext into the root com.optimizely.ab package then we don't have to open this up so widely, but instead can be make package private

@Nonnull Experiment experiment,
@Nonnull String userId,
@Nonnull Map<String, ?> filteredAttributes,
Expand Down Expand Up @@ -742,8 +741,7 @@ <T> T getFeatureVariableValueForType(@Nonnull String featureKey,
}

// Helper method which takes type and variable value and convert it to object to use in Listener DecisionInfo object variable value
@VisibleForTesting
Object convertStringToType(String variableValue, String type) {
public Object convertStringToType(String variableValue, String type) {
if (variableValue != null) {
switch (type) {
case FeatureVariable.DOUBLE_TYPE:
Expand Down
73 changes: 58 additions & 15 deletions core-api/src/main/java/com/optimizely/ab/bucketing/Bucketer.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,9 @@

import com.optimizely.ab.annotations.VisibleForTesting;
import com.optimizely.ab.bucketing.internal.MurmurHash3;
import com.optimizely.ab.config.Experiment;
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.config.*;
import com.optimizely.ab.optimizelyusercontext.DecisionReasons;
import com.optimizely.ab.optimizelyusercontext.OptimizelyDecideOption;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

Expand Down Expand Up @@ -71,7 +69,9 @@ private String bucketToEntity(int bucketValue, List<TrafficAllocation> trafficAl

private Experiment bucketToExperiment(@Nonnull Group group,
@Nonnull String bucketingId,
@Nonnull ProjectConfig projectConfig) {
@Nonnull ProjectConfig projectConfig,
@Nullable List<OptimizelyDecideOption> options,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 List makes sense as opposed to array. See comments in #406

@Nullable DecisionReasons reasons) {
// "salt" the bucket id using the group id
String bucketKey = bucketingId + group.getId();

Expand All @@ -91,7 +91,9 @@ private Experiment bucketToExperiment(@Nonnull Group group,
}

private Variation bucketToVariation(@Nonnull Experiment experiment,
@Nonnull String bucketingId) {
@Nonnull String bucketingId,
@Nullable List<OptimizelyDecideOption> options,
@Nullable DecisionReasons reasons) {
// "salt" the bucket id using the experiment id
String experimentId = experiment.getId();
String experimentKey = experiment.getKey();
Expand All @@ -107,14 +109,14 @@ private Variation bucketToVariation(@Nonnull Experiment experiment,
if (bucketedVariationId != null) {
Variation bucketedVariation = experiment.getVariationIdToVariationMap().get(bucketedVariationId);
String variationKey = bucketedVariation.getKey();
logger.info("User with bucketingId \"{}\" is in variation \"{}\" of experiment \"{}\".", bucketingId, variationKey,
DecisionService.logInfo(logger, reasons, "User with bucketingId \"%s\" is in variation \"%s\" of experiment \"%s\".", bucketingId, variationKey,
experimentKey);

return bucketedVariation;
}

// user was not bucketed to a variation
logger.info("User with bucketingId \"{}\" is not in any variation of experiment \"{}\".", bucketingId, experimentKey);
DecisionService.logInfo(logger, reasons, "User with bucketingId \"%s\" is not in any variation of experiment \"%s\".", bucketingId, experimentKey);
return null;
}

Expand All @@ -123,43 +125,77 @@ private Variation bucketToVariation(@Nonnull Experiment experiment,
*
* @param experiment The Experiment in which the user is to be bucketed.
* @param bucketingId string A customer-assigned value used to create the key for the murmur hash.
* @param projectConfig The current projectConfig
* @param options An array of decision options
* @param reasons Decision log messages
* @return Variation the user is bucketed into or null.
*/
@Nullable
public Variation bucket(@Nonnull Experiment experiment,
@Nonnull String bucketingId,
@Nonnull ProjectConfig projectConfig,
@Nullable List<OptimizelyDecideOption> options,
@Nullable DecisionReasons reasons) {

// support existing custom Bucketers
if (isMethodOverriden("bucket", Experiment.class, String.class, ProjectConfig.class)) {
return bucket(experiment, bucketingId, projectConfig);
}

return bucketCore(experiment, bucketingId, projectConfig, options, reasons);
}

/**
* Assign a {@link Variation} of an {@link Experiment} to a user based on hashed value from murmurhash3.
*
* @param experiment The Experiment in which the user is to be bucketed.
* @param bucketingId string A customer-assigned value used to create the key for the murmur hash.
* @param projectConfig The current projectConfig
* @return Variation the user is bucketed into or null.
*/
@Nullable
public Variation bucket(@Nonnull Experiment experiment,
@Nonnull String bucketingId,
@Nonnull ProjectConfig projectConfig) {
return bucketCore(experiment, bucketingId, projectConfig, null, null);
}

@Nullable
public Variation bucketCore(@Nonnull Experiment experiment,
@Nonnull String bucketingId,
@Nonnull ProjectConfig projectConfig,
@Nullable List<OptimizelyDecideOption> options,
@Nullable DecisionReasons reasons) {
// ---------- Bucket User ----------
String groupId = experiment.getGroupId();
// check whether the experiment belongs to a group
if (!groupId.isEmpty()) {
Group experimentGroup = projectConfig.getGroupIdMapping().get(groupId);
// bucket to an experiment only if group entities are to be mutually exclusive
if (experimentGroup.getPolicy().equals(Group.RANDOM_POLICY)) {
Experiment bucketedExperiment = bucketToExperiment(experimentGroup, bucketingId, projectConfig);
Experiment bucketedExperiment = bucketToExperiment(experimentGroup, bucketingId, projectConfig, options, reasons);
if (bucketedExperiment == null) {
logger.info("User with bucketingId \"{}\" is not in any experiment of group {}.", bucketingId, experimentGroup.getId());
DecisionService.logInfo(logger, reasons, "User with bucketingId \"%s\" is not in any experiment of group %s.", bucketingId, experimentGroup.getId());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The use of these static DecisionService.logXXX functions is adding more confusion than clarity. I think it would be more readable to have a function on DecisionReasons that formats the message, then log in the calling class.

// Add and return formatted message
public String addInfof(String format, Object... args) {
    String message = String.format(format, args);
    addInfo(message);
    return message;
}

So this one liner becomes two, but its very transparent what is happening, eg:

String reason = reasons.addInfof("User with bucketingId \"{}\" is not in any experiment of group {}.", bucketingId, experimentGroup.getId());
logger.info(reason);

You can even log directly within DecisionReasons but I bet that will break a lot of log tests.

return null;
} else {

}
// if the experiment a user is bucketed in within a group isn't the same as the experiment provided,
// don't perform further bucketing within the experiment
if (!bucketedExperiment.getId().equals(experiment.getId())) {
logger.info("User with bucketingId \"{}\" is not in experiment \"{}\" of group {}.", bucketingId, experiment.getKey(),
DecisionService.logInfo(logger, reasons, "User with bucketingId \"%s\" is not in experiment \"%s\" of group %s.", bucketingId, experiment.getKey(),
experimentGroup.getId());
return null;
}

logger.info("User with bucketingId \"{}\" is in experiment \"{}\" of group {}.", bucketingId, experiment.getKey(),
DecisionService.logInfo(logger, reasons, "User with bucketingId \"%s\" is in experiment \"%s\" of group %s.", bucketingId, experiment.getKey(),
experimentGroup.getId());
}
}

return bucketToVariation(experiment, bucketingId);
return bucketToVariation(experiment, bucketingId, options, reasons);
}


//======== Helper methods ========//

/**
Expand All @@ -175,5 +211,12 @@ int generateBucketValue(int hashCode) {
return (int) Math.floor(MAX_TRAFFIC_VALUE * ratio);
}

Boolean isMethodOverriden(String methodName, Class<?>... params) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need to worry about custom overrides such as this. Let me know if there's a specific concern or a hypothetical one.

try {
return getClass() != Bucketer.class && getClass().getDeclaredMethod(methodName, params) != null;
} catch (NoSuchMethodException e) {
return false;
}
}

}
Loading