-
Notifications
You must be signed in to change notification settings - Fork 32
Josh/persistent bucketer #1
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
Changes from all commits
f496657
4b7fe39
20c1cd0
6ba9b55
d7687c8
74e910a
0e80db5
e860307
3dd3132
4eaa1e3
dc76c69
b7a9c7d
b842c5d
032435a
91e0427
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -14,6 +14,9 @@ | |
.classpath | ||
.project | ||
|
||
# android studio config files | ||
local.properties | ||
|
||
**/build | ||
out | ||
classes | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,6 @@ | ||
Optimizely Java SDK | ||
=================== | ||
[](https://travis-ci.com/optimizely/java-sdk) | ||
[](https://travis-ci.org/optimizely/java-sdk) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. those are the latest links, public repos use .org not .com for Travis |
||
[](http://www.apache.org/licenses/LICENSE-2.0) | ||
|
||
This repository houses the Java SDK for Optimizely's server-side testing product, which is currently in private beta. | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -18,6 +18,7 @@ | |
|
||
import com.optimizely.ab.annotations.VisibleForTesting; | ||
import com.optimizely.ab.bucketing.Bucketer; | ||
import com.optimizely.ab.bucketing.UserExperimentRecord; | ||
import com.optimizely.ab.config.Attribute; | ||
import com.optimizely.ab.config.EventType; | ||
import com.optimizely.ab.config.Experiment; | ||
|
@@ -93,6 +94,11 @@ private Optimizely(@Nonnull ProjectConfig projectConfig, | |
this.errorHandler = errorHandler; | ||
} | ||
|
||
// Do work here that should be done once per Optimizely lifecycle | ||
@VisibleForTesting void initialize() { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this annotation is pretty cool |
||
bucketer.cleanUserExperimentRecords(); | ||
} | ||
|
||
//======== activate calls ========// | ||
|
||
public @Nullable Variation activate(@Nonnull String experimentKey, | ||
|
@@ -180,21 +186,21 @@ public void track(@Nonnull String eventName, | |
|
||
public void track(@Nonnull String eventName, | ||
@Nonnull String userId, | ||
long revenue) throws UnknownEventTypeException { | ||
track(eventName, userId, Collections.<String, String>emptyMap(), revenue); | ||
long eventValue) throws UnknownEventTypeException { | ||
track(eventName, userId, Collections.<String, String>emptyMap(), eventValue); | ||
} | ||
|
||
public void track(@Nonnull String eventName, | ||
@Nonnull String userId, | ||
@Nonnull Map<String, String> attributes, | ||
long revenue) throws UnknownEventTypeException { | ||
track(eventName, userId, attributes, (Long)revenue); | ||
long eventValue) throws UnknownEventTypeException { | ||
track(eventName, userId, attributes, (Long)eventValue); | ||
} | ||
|
||
private void track(@Nonnull String eventName, | ||
@Nonnull String userId, | ||
@Nonnull Map<String, String> attributes, | ||
@CheckForNull Long revenue) throws UnknownEventTypeException { | ||
@CheckForNull Long eventValue) throws UnknownEventTypeException { | ||
|
||
ProjectConfig currentConfig = getProjectConfig(); | ||
|
||
|
@@ -212,14 +218,14 @@ private void track(@Nonnull String eventName, | |
// create the conversion event request parameters, then dispatch | ||
String endpointUrl = eventBuilder.getEndpointUrl(currentConfig.getProjectId()); | ||
Map<String, String> conversionParams; | ||
if (revenue == null) { | ||
if (eventValue == null) { | ||
conversionParams = eventBuilder.createConversionParams(currentConfig, bucketer, userId, | ||
eventType.getId(), eventType.getKey(), | ||
attributes); | ||
} else { | ||
conversionParams = eventBuilder.createConversionParams(currentConfig, bucketer, userId, | ||
eventType.getId(), eventType.getKey(), attributes, | ||
revenue); | ||
eventValue); | ||
} | ||
|
||
if (conversionParams == null) { | ||
|
@@ -419,6 +425,7 @@ public static class Builder { | |
|
||
private String datafile; | ||
private Bucketer bucketer; | ||
private UserExperimentRecord userExperimentRecord; | ||
private ErrorHandler errorHandler; | ||
private EventHandler eventHandler; | ||
private EventBuilder eventBuilder; | ||
|
@@ -435,6 +442,11 @@ public Builder withErrorHandler(ErrorHandler errorHandler) { | |
return this; | ||
} | ||
|
||
public Builder withUserExperimentRecord(UserExperimentRecord userExperimentRecord) { | ||
this.userExperimentRecord = userExperimentRecord; | ||
return this; | ||
} | ||
|
||
protected Builder withBucketing(Bucketer bucketer) { | ||
this.bucketer = bucketer; | ||
return this; | ||
|
@@ -445,9 +457,7 @@ protected Builder withEventBuilder(EventBuilder eventBuilder) { | |
return this; | ||
} | ||
|
||
/** | ||
* Helper function for making testing easier | ||
*/ | ||
// Helper function for making testing easier | ||
protected Builder withConfig(ProjectConfig projectConfig) { | ||
this.projectConfig = projectConfig; | ||
return this; | ||
|
@@ -460,7 +470,7 @@ public Optimizely build() { | |
|
||
// use the default bucketer and event builder, if no overrides were provided | ||
if (bucketer == null) { | ||
bucketer = new Bucketer(projectConfig); | ||
bucketer = new Bucketer(projectConfig, userExperimentRecord); | ||
} | ||
|
||
if (eventBuilder == null) { | ||
|
@@ -471,7 +481,9 @@ public Optimizely build() { | |
errorHandler = new NoOpErrorHandler(); | ||
} | ||
|
||
return new Optimizely(projectConfig, bucketer, eventHandler, eventBuilder, errorHandler); | ||
Optimizely optimizely = new Optimizely(projectConfig, bucketer, eventHandler, eventBuilder, errorHandler); | ||
optimizely.initialize(); | ||
return optimizely; | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -47,6 +47,8 @@ public class Bucketer { | |
|
||
private final ProjectConfig projectConfig; | ||
|
||
@Nullable private final UserExperimentRecord userExperimentRecord; | ||
|
||
private static final Logger logger = LoggerFactory.getLogger(Bucketer.class); | ||
|
||
private static final int MURMUR_HASH_SEED = 1; | ||
|
@@ -58,7 +60,12 @@ public class Bucketer { | |
static final int MAX_TRAFFIC_VALUE = 10000; | ||
|
||
public Bucketer(ProjectConfig projectConfig) { | ||
this(projectConfig, null); | ||
} | ||
|
||
public Bucketer(ProjectConfig projectConfig, @Nullable UserExperimentRecord userExperimentRecord) { | ||
this.projectConfig = projectConfig; | ||
this.userExperimentRecord = userExperimentRecord; | ||
} | ||
|
||
private String bucketToEntity(int bucketValue, List<TrafficAllocation> trafficAllocations) { | ||
|
@@ -101,8 +108,29 @@ private Experiment bucketToExperiment(@Nonnull Group group, | |
private Variation bucketToVariation(@Nonnull Experiment experiment, | ||
@Nonnull String userId) { | ||
// "salt" the bucket id using the experiment id | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: move this comment directly above where the salting happens on line 113 |
||
String combinedBucketId = userId + experiment.getId(); | ||
String experimentId = experiment.getId(); | ||
String experimentKey = experiment.getKey(); | ||
String combinedBucketId = userId + experimentId; | ||
|
||
// If a user experiment record instance is present then check it for a saved variation | ||
if (userExperimentRecord != null) { | ||
String variationKey = userExperimentRecord.lookup(userId, experimentKey); | ||
if (variationKey != null) { | ||
logger.info("Returning previously activated variation \"{}\" of experiment \"{}\" " | ||
+ "for user \"{}\" from user experiment record.", | ||
variationKey, experimentKey, userId); | ||
// A variation is stored for this combined bucket id | ||
return projectConfig | ||
.getExperimentIdMapping() | ||
.get(experimentId) | ||
.getVariationKeyToVariationMap() | ||
.get(variationKey); | ||
} else { | ||
logger.info("No previously activated variation of experiment \"{}\" " | ||
+ "for user \"{}\" found in user experiment record.", | ||
experimentKey, userId); | ||
} | ||
} | ||
|
||
List<TrafficAllocation> trafficAllocations = experiment.getTrafficAllocation(); | ||
|
||
|
@@ -113,8 +141,22 @@ private Variation bucketToVariation(@Nonnull Experiment experiment, | |
String bucketedVariationId = bucketToEntity(bucketValue, trafficAllocations); | ||
if (bucketedVariationId != null) { | ||
Variation bucketedVariation = experiment.getVariationIdToVariationMap().get(bucketedVariationId); | ||
logger.info("User \"{}\" is in variation \"{}\" of experiment \"{}\".", userId, bucketedVariation.getKey(), | ||
String variationKey = bucketedVariation.getKey(); | ||
logger.info("User \"{}\" is in variation \"{}\" of experiment \"{}\".", userId, variationKey, | ||
experimentKey); | ||
|
||
// If a user experiment record is present give it a variation to store | ||
if (userExperimentRecord != null) { | ||
boolean saved = userExperimentRecord.save(userId, experiment.getKey(), variationKey); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. use |
||
if (saved) { | ||
logger.info("Saved variation \"{}\" of experiment \"{}\" for user \"{}\".", | ||
variationKey, experimentKey, userId); | ||
} else { | ||
logger.warn("Failed to save variation \"{}\" of experiment \"{}\" for user \"{}\".", | ||
variationKey, experimentKey, userId); | ||
} | ||
} | ||
|
||
return bucketedVariation; | ||
} | ||
|
||
|
@@ -180,4 +222,29 @@ int generateBucketValue(int hashCode) { | |
double ratio = (double)(hashCode & 0xFFFFFFFFL) / Math.pow(2, 32); | ||
return (int)Math.floor(MAX_TRAFFIC_VALUE * ratio); | ||
} | ||
|
||
@Nullable | ||
public UserExperimentRecord getUserExperimentRecord() { | ||
return userExperimentRecord; | ||
} | ||
|
||
/** | ||
* Gives implementations of {@link UserExperimentRecord} a chance to remove records | ||
* of experiments that are deleted or not running. | ||
*/ | ||
public void cleanUserExperimentRecords() { | ||
if (userExperimentRecord != null) { | ||
Map<String, Map<String,String>> records = userExperimentRecord.getAllRecords(); | ||
if (records != null) { | ||
for (Map.Entry<String,Map<String,String>> record : records.entrySet()) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. space between Map types There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: can you add some spaces between the Map types |
||
for (String experimentKey : record.getValue().keySet()) { | ||
Experiment experiment = projectConfig.getExperimentKeyMapping().get(experimentKey); | ||
if (experiment == null || !experiment.isRunning()) { | ||
userExperimentRecord.remove(record.getKey(), experimentKey); | ||
} | ||
} | ||
} | ||
} | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,70 @@ | ||
/** | ||
* | ||
* Copyright 2016, Optimizely | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
package com.optimizely.ab.bucketing; | ||
|
||
import java.util.Map; | ||
|
||
/** | ||
* Gives implementors a chance to override {@link Bucketer} bucketing on subsequent activations. | ||
* | ||
* Overriding bucketing for subsequent activations is useful in order to prevent changes to | ||
* user experience after changing traffic allocations. Also, this interface gives users | ||
* a hook to keep track of activation history. | ||
*/ | ||
public interface UserExperimentRecord { | ||
|
||
/** | ||
* Called when implementors should save an activation | ||
* | ||
* @param userId the user id of the activation | ||
* @param experimentKey the experiment key of the activation | ||
* @param variationKey the variation key of the activation | ||
* @return true if saving of the record was successful | ||
*/ | ||
boolean save(String userId, String experimentKey, String variationKey); | ||
|
||
/** | ||
* Called by the bucketer to check for a record of the previous activation | ||
* | ||
* @param userId the user is id of the next activation | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit. Empty line above this. |
||
* @param experimentKey the experiment id of the next activation | ||
* @return the variation key of the next activation, or null if no record exists | ||
*/ | ||
String lookup(String userId, String experimentKey); | ||
|
||
/** | ||
* Called when user experiment record should be removed | ||
* | ||
* Records should be removed when an experiment is not running or when an experiment has been | ||
* deleted. | ||
* | ||
* @param userId the user id of the record to remove | ||
* @param experimentKey the experiment key of the record to remove | ||
* @return true if the record was removed | ||
*/ | ||
boolean remove(String userId, String experimentKey); | ||
|
||
/** | ||
* Called by bucketer to get a mapping of all stored records | ||
* | ||
* This mapping is needed so that the bucketer can {@link #remove(String, String)} outdated | ||
* records. | ||
* @return a map of userIds to a map of experiment keys to variation keys | ||
*/ | ||
Map<String, Map<String,String>> getAllRecords(); | ||
|
||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Dont think it makes sense for Java SDK. Probably for Android SDK.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I will need to expense IntelliJ Ultimate then. Android Studio adds the file automatically. It's also useful to work on the android modules and java module at the same time in Android Studio.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok. Makes sense.