Skip to content

Commit

Permalink
New bucketing support with hash version 2, bump tests to v0.4.1 (#22)
Browse files Browse the repository at this point in the history
* add new HashVersion class

* use HashVersion when calling hash() function

* add seed to Experiment & FeatureRule; use seed in hashing algo; fix inNameSpace() for seed w/ HashVersion

* add notnull annotations to help w/ null safety

* implement hashVersion 2, support null hash value

* null check on hashVersion for utils #hash()

* add new fields to ExperimentResult

* deprecate Namespace

* javadoc for new ExperimentResult fields

* add VariationMeta w/ tests

* add TrackData & tests

* add new class Filter w/ tests

* compare only relevant properties for equality

* add hashVersion to experiment

* make decryption utils fail silently & return null to match desired business logic

* fix bucket from int to float; add bucket & key checks to empty experiment rule a & b & c

* replace rules for test "creates experiments properly"

* replace FeatureResult for "creates experiments properly"

* add key & bucket checks for FeatureResult for "handles integer hashAttribute"

* add key check to FeatureResult.ExperimentResult for test case "include experiments when forced"

* add test case "Force rule with range, ignores coverage"

* add test "Force rule, hash version 2"

* add rule "Force rule, skip due to range"

* add test "Force rule, skip due to filter"

* add test case "Force rule, use seed with range"

* add test case "Support passthrough variations"

* add test case "Support holdout groups"

* add test case for run "Filtered, included"

* add run test case "Filtered, excluded"

* add run test case "Filtered, ignore namespace"

* add run test case "Ranges, ignore coverage and weights"

* run test cases "Ranges, partial coverage" & "Uses seed and hash version"

* add run tests "Uses seed with default weights/coverage" & "Uses seed with weights/coverage"

* add test case "force rules - coverage with bad hash version"

* add new fields to Experiment (ranges, meta, filters, seed, name, phase)

* add new fields to FeatureRule (hashVersion, ranges, meta, filters, seed, name, phase, tracks)

* is included in rollout

* implement util isFilteredOut (WIP)

* add decrypt() test cases JSON

* decryption tests: improve errors, fix malformed input bug

* use isIncludedInRollout & isFilteredOut

* use List<TrackData<T>>

* add some experiment evaluation; add FeatureRule values to experiment; rm builder defaults for empty lists

* pass hashBucket & variation meta to experiment result

* use inRange() for chooseVariation()

* use variationId as fallback for ExperimentResult key

* add logging around  test results

* fix argument order for hash() call

* add custom serializer & deserializer for HashVersion

* add SerializedName annotations

* remove some custom gson serialization/deserialization for Experiment, ExperimentResult

* remove unused Feature-related JSON serialization methods

* add note about Expose annotation

* convert HashVersion to Integer, no builder defaults

* fix default hash attr

* rm custom serializer code

* fix range & rollout logic

* rm comment re: @\Expose annotation: using this will likely cause more trouble than it's worth

* rm hashVersion from GBContext

* fix javadoc comments
  • Loading branch information
tinahollygb committed Apr 18, 2023
1 parent 2ccfdab commit 4edfa84
Show file tree
Hide file tree
Showing 31 changed files with 1,054 additions and 290 deletions.
2 changes: 2 additions & 0 deletions lib/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ repositories {
}

dependencies {
implementation 'org.jetbrains:annotations:24.0.0'

// Use JUnit Jupiter for testing.
testImplementation 'org.junit.jupiter:junit-jupiter:5.8.2'
testImplementation 'org.mockito:mockito-core:4.8.0'
Expand Down
36 changes: 27 additions & 9 deletions lib/src/main/java/growthbook/sdk/java/DecryptionUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
import javax.crypto.NoSuchPaddingException;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
Expand All @@ -16,9 +19,16 @@
* INTERNAL: This class is used internally to decrypt an encrypted features response
*/
class DecryptionUtils {
public static String decrypt(String payload, String encryptionKey) {

public static class DecryptionException extends Exception {
public DecryptionException(String errorMessage) {
super(errorMessage);
}
}

public static String decrypt(String payload, String encryptionKey) throws DecryptionException {
if (!payload.contains(".")) {
throw new IllegalArgumentException("Invalid payload");
throw new DecryptionException("Invalid payload");
}

try {
Expand All @@ -36,19 +46,27 @@ public static String decrypt(String payload, String encryptionKey) {
byte[] decodedCipher = Base64.getDecoder().decode(cipherText.getBytes(StandardCharsets.UTF_8));
byte[] plainText = cipher.doFinal(decodedCipher);

// This decoder ensures no malformed input due to using a mismatching iv key
StandardCharsets.UTF_8
.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(plainText));

return new String(plainText);
} catch (InvalidAlgorithmParameterException e) {
throw new IllegalArgumentException("Invalid payload");
throw new DecryptionException("Invalid payload");
} catch (InvalidKeyException e) {
throw new IllegalArgumentException("Invalid encryption key");
throw new DecryptionException("Invalid encryption key");
} catch (
NoSuchAlgorithmException
| NoSuchPaddingException
| IllegalBlockSizeException
| BadPaddingException e
NoSuchAlgorithmException
| NoSuchPaddingException
| IllegalBlockSizeException
| CharacterCodingException
| IllegalArgumentException
| BadPaddingException e
) {
e.printStackTrace();
throw new RuntimeException(e);
throw new DecryptionException(e.getMessage());
}
}

Expand Down
62 changes: 23 additions & 39 deletions lib/src/main/java/growthbook/sdk/java/Experiment.java
Original file line number Diff line number Diff line change
@@ -1,16 +1,12 @@
package growthbook.sdk.java;

import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonSerializationContext;
import com.google.gson.JsonSerializer;
import com.google.gson.annotations.SerializedName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;

import javax.annotation.Nullable;
import java.lang.reflect.Type;
import java.util.ArrayList;

/**
Expand All @@ -25,7 +21,6 @@ public class Experiment<ValueType> {
*/
String key;


/**
* The different variations to choose between
*/
Expand Down Expand Up @@ -69,6 +64,28 @@ public class Experiment<ValueType> {
@Builder.Default
String hashAttribute = "id";

@Nullable
Integer hashVersion;

@Nullable
ArrayList<BucketRange> ranges;

@Nullable
@SerializedName("meta")
ArrayList<VariationMeta> meta;

@Nullable
ArrayList<Filter> filters;

@Nullable
String seed;

@Nullable
String name;

@Nullable
String phase;

/**
* Get a Gson JsonElement of the experiment
* @return JsonElement
Expand All @@ -92,40 +109,7 @@ public String toString() {
* @return JsonElement
*/
public static <ValueType> JsonElement getJson(Experiment<ValueType> object) {
JsonObject json = new JsonObject();

json.addProperty("key", object.getKey());

JsonElement variationsElement = GrowthBookJsonUtils.getJsonElementForObject(object.getVariations());
json.add("variations", variationsElement);

JsonElement weightsElement = GrowthBookJsonUtils.getJsonElementForObject(object.getWeights());
json.add("weights", weightsElement);

json.addProperty("isActive", object.getIsActive());
json.addProperty("coverage", object.getCoverage());

JsonElement namespaceElement = GrowthBookJsonUtils.getJsonElementForObject(object.getNamespace());
json.add("namespace", namespaceElement);

json.addProperty("force", object.getForce());
json.addProperty("hashAttribute", object.getHashAttribute());

return json;
}

/**
* A Gson serializer for {@link Experiment}
* @return a serializer for {@link Experiment}
* @param <ValueType> value type for the Experiment
*/
public static <ValueType> JsonSerializer<Experiment<ValueType>> getSerializer() {
return new JsonSerializer<Experiment<ValueType>>() {
@Override
public JsonElement serialize(Experiment<ValueType> src, Type typeOfSrc, JsonSerializationContext context) {
return Experiment.getJson(src);
}
};
return GrowthBookJsonUtils.getJsonElementForObject(object);
}

// endregion Serialization
Expand Down
84 changes: 55 additions & 29 deletions lib/src/main/java/growthbook/sdk/java/ExperimentEvaluator.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,7 @@
import com.google.gson.JsonObject;

import javax.annotation.Nullable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.*;

/**
* <b>INTERNAL</b>: Implementation of experiment evaluation
Expand All @@ -25,13 +22,13 @@ public <ValueType> ExperimentResult<ValueType> evaluateExperiment(Experiment<Val
experimentVariations = new ArrayList<>();
}
if ((context.getEnabled() != null && !context.getEnabled()) || experimentVariations.size() < 2) {
return getExperimentResult(experiment, context, 0, false, false, featureId);
return getExperimentResult(experiment, context, 0, false, false, featureId, null);
}

// Query string overrides
Integer override = GrowthBookUtils.getQueryStringOverride(experiment.getKey(), context.getUrl(), experimentVariations.size());
if (override != null) {
return getExperimentResult(experiment, context, override, true, false, featureId);
return getExperimentResult(experiment, context, override, true, false, featureId, null);
}

// If no forced variation, not in experiment, variation 0
Expand All @@ -41,12 +38,12 @@ public <ValueType> ExperimentResult<ValueType> evaluateExperiment(Experiment<Val
}
Integer forcedVariation = forcedVariations.get(experiment.getKey());
if (forcedVariation != null) {
return getExperimentResult(experiment, context, forcedVariation, true, false, featureId);
return getExperimentResult(experiment, context, forcedVariation, true, false, featureId, null);
}

// If experiment is not active, not in experiment, variation 0
if (experiment.getIsActive() != null && !experiment.getIsActive()) {
return getExperimentResult(experiment, context, 0, false, false, featureId);
return getExperimentResult(experiment, context, 0, false, false, featureId, null);
}

// Get the user hash attribute and the value. If empty, not in experiment, variation 0
Expand All @@ -68,18 +65,24 @@ public <ValueType> ExperimentResult<ValueType> evaluateExperiment(Experiment<Val
attributeValueElement.getAsJsonPrimitive().isString() &&
Objects.equals(attributeValueElement.getAsString(), ""))
) {
return getExperimentResult(experiment, context, 0, false, false, featureId);
return getExperimentResult(experiment, context, 0, false, false, featureId, null);
}

String attributeValue = attributeValueElement.getAsString();

// If experiment namespace is set, check if the hash value is included in the range, and if not
// user is not in the experiment, variation 0.
List<Filter> filters = experiment.getFilters();
Namespace namespace = experiment.getNamespace();
if (namespace != null) {
if (filters != null) {
// Exclude if user is filtered out (used to be called "namespace")
if (GrowthBookUtils.isFilteredOut(filters, attributes)) {
return getExperimentResult(experiment, context, 0, false, false, featureId, null);
}
} else if (namespace != null) {
// If experiment namespace is set, check if the hash value is included in the range, and if not
// user is not in the experiment, variation 0.
Boolean isInNamespace = GrowthBookUtils.inNameSpace(attributeValue, namespace);
if (!isInNamespace) {
return getExperimentResult(experiment, context, 0, false, false, featureId);
return getExperimentResult(experiment, context, 0, false, false, featureId, null);
}
}

Expand All @@ -89,7 +92,7 @@ public <ValueType> ExperimentResult<ValueType> evaluateExperiment(Experiment<Val
String attributesJson = GrowthBookJsonUtils.getInstance().gson.toJson(attributes);
Boolean shouldEvaluate = conditionEvaluator.evaluateCondition(attributesJson, jsonStringCondition);
if (!shouldEvaluate) {
return getExperimentResult(experiment, context, 0, false, false, featureId);
return getExperimentResult(experiment, context, 0, false, false, featureId, null);
}
}

Expand All @@ -107,34 +110,49 @@ public <ValueType> ExperimentResult<ValueType> evaluateExperiment(Experiment<Val
}

// Bucket ranges
ArrayList<BucketRange> bucketRanges = GrowthBookUtils.getBucketRanges(
ArrayList<BucketRange> bucketRanges = experiment.getRanges();
if (bucketRanges == null) {
bucketRanges = GrowthBookUtils.getBucketRanges(
experiment.getVariations().size(),
coverage,
weights
);
);
}

// Assigned variations
// If not assigned a variation (-1), not in experiment, variation 0
Float hash = GrowthBookUtils.hash(attributeValue + experiment.getKey());
String seed = experiment.getSeed();
if (seed == null) {
seed = experiment.getKey();
}
Integer hashVersion = experiment.getHashVersion();
if (hashVersion == null) {
hashVersion = 1;
}
Float hash = GrowthBookUtils.hash(attributeValue, hashVersion, seed);
if (hash == null) {
return getExperimentResult(experiment, context, 0, false, false, featureId, null);
}
Integer assignedVariation = GrowthBookUtils.chooseVariation(hash, bucketRanges);
if (assignedVariation == -1) {
return getExperimentResult(experiment, context, 0, false, false, featureId);
// NOTE: While a hash is used to determine if the user is assigned a variation, since they aren't, hash passed is null
return getExperimentResult(experiment, context, 0, false, false, featureId, null);
}

// If experiment has a forced index, not in experiment, variation is the forced experiment
Integer force = experiment.getForce();
if (force != null) {
return getExperimentResult(experiment, context, force, true, false, featureId);
return getExperimentResult(experiment, context, force, true, false, featureId, null);
}

// If QA mode is enabled, not in experiment, variation 0
if (context.getIsQaMode() != null && context.getIsQaMode()) {
return getExperimentResult(experiment, context, 0, false, false, featureId);
return getExperimentResult(experiment, context, 0, false, false, featureId, null);
}

// User is in an experiment.
// Call the tracking callback with the result.
ExperimentResult<ValueType> result = getExperimentResult(experiment, context, assignedVariation, true, true, featureId);
ExperimentResult<ValueType> result = getExperimentResult(experiment, context, assignedVariation, true, true, featureId, hash);
TrackingCallback trackingCallback = context.getTrackingCallback();
if (trackingCallback != null) {
trackingCallback.onTrack(experiment, result);
Expand All @@ -149,9 +167,9 @@ private <ValueType> ExperimentResult<ValueType> getExperimentResult(
Integer variationIndex,
Boolean inExperiment,
Boolean hashUsed,
String featureId
String featureId,
@Nullable Float hashBucket
) {
Integer targetVariationIndex = variationIndex;
ArrayList<ValueType> experimentVariations = experiment.getVariations();
if (experimentVariations == null) {
experimentVariations = new ArrayList<>();
Expand All @@ -163,13 +181,8 @@ private <ValueType> ExperimentResult<ValueType> getExperimentResult(

ValueType targetValue = null;

if (targetVariationIndex < 0 || targetVariationIndex >= experimentVariations.size()) {
// Set to 0
targetVariationIndex = 0;
}

if (!experimentVariations.isEmpty()) {
targetValue = experiment.getVariations().get(targetVariationIndex);
targetValue = experiment.getVariations().get(variationIndex);
}

String hashAttribute = experiment.getHashAttribute();
Expand All @@ -186,15 +199,28 @@ private <ValueType> ExperimentResult<ValueType> getExperimentResult(
}
}

VariationMeta maybeMeta = null;
ArrayList<VariationMeta> metaList = experiment.getMeta();
if (metaList == null) {
metaList = new ArrayList<>();
}
if (variationIndex < metaList.size()) {
maybeMeta = metaList.get(variationIndex);
}

return ExperimentResult
.<ValueType>builder()
.inExperiment(inExperiment)
.variationId(variationIndex)
.key(maybeMeta == null ? variationIndex.toString() : maybeMeta.getKey())
.featureId(featureId)
.hashValue(hashValue)
.hashUsed(hashUsed)
.hashAttribute(hashAttribute)
.value(targetValue)
.bucket(hashBucket)
.name(maybeMeta == null ? null : maybeMeta.getName())
.passThrough(maybeMeta == null ? null : maybeMeta.getPassThrough())
.build();
}
}
Loading

0 comments on commit 4edfa84

Please sign in to comment.